diff --git a/Backend/app/config.py b/Backend/app/config.py
index 27d1262..785afff 100644
--- a/Backend/app/config.py
+++ b/Backend/app/config.py
@@ -86,6 +86,8 @@ class AppConfig:
GOOGLE_CLOUD_PROJECT = os.getenv("GOOGLE_CLOUD_PROJECT")
GOOGLE_APPLICATION_CREDENTIALS = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
+ GCS_BUCKET_NAME = os.environ.get('GCS_BUCKET_NAME')
+
class TestConfig(AppConfig):
SQLALCHEMY_DATABASE_URI = "sqlite://"
TESTING = True
diff --git a/Backend/app/main/routes.py b/Backend/app/main/routes.py
index 6d64458..d268746 100644
--- a/Backend/app/main/routes.py
+++ b/Backend/app/main/routes.py
@@ -51,6 +51,7 @@ def get_all_orgs():
"org_overview": org.org_overview,
"focus_areas": [focus_area.serialize() for focus_area in org.focus_areas],
"skills_needed": [skill.serialize() for skill in org.skills_needed],
+ "org_logo_filename": org.logo_url,
}
orgs_data.append(org_data)
@@ -107,7 +108,7 @@ def match_volunteer_skills():
except Exception as e:
db.session.rollback()
return jsonify({"message": f"An error occurred: {str(e)}"}), 500
-
+
logger = logging.getLogger(__name__)
def get_translate_client():
@@ -255,4 +256,3 @@ def translate_text():
return jsonify({
'error': f'Server error: {str(e)}'
}), 500
-
diff --git a/Backend/app/models.py b/Backend/app/models.py
index c3421b6..c5239e0 100644
--- a/Backend/app/models.py
+++ b/Backend/app/models.py
@@ -147,6 +147,16 @@ def cover_photo_url(self):
bucket_name = current_app.config['GCS_BUCKET_NAME']
return f"https://storage.googleapis.com/{bucket_name}/{self.org_cover_photo_filename}"
+
+ def has_images(self):
+ """Check if org has any images uploaded"""
+ return bool(self.org_logo_filename or self.org_cover_photo_filename)
+
+ def clear_images(self):
+ """Remove all image references"""
+ self.org_logo_filename = None
+ self.org_cover_photo_filename = None
+ db.session.commit()
def __repr__(self):
return f"{self.org_name}"
diff --git a/Backend/app/profile/routes.py b/Backend/app/profile/routes.py
index 5ba1446..a623e92 100644
--- a/Backend/app/profile/routes.py
+++ b/Backend/app/profile/routes.py
@@ -1,10 +1,14 @@
+import logging
from flask import Blueprint, jsonify, request
from flask_login import login_required, current_user
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import joinedload
-from datetime import datetime
+from datetime import datetime, timezone
from app import db
+from app.utils.image_handler import ImageHandler
+from google.cloud import storage
+import os
from app.models import (
User,
@@ -606,4 +610,209 @@ def edit_org_skills():
except Exception as e:
db.session.rollback()
print(f"Unexpected error: {str(e)}")
- return jsonify({"status": "error", "message": str(e)}), 500
\ No newline at end of file
+ return jsonify({"status": "error", "message": str(e)}), 500
+
+
+@profile.route("/profile/upload-images", methods=["POST"])
+def upload_org_images():
+ """
+ Handle simultaneous upload of organization logo and cover photo
+ Expected form data:
+ - user_id: The current user's ID
+ - org_id: The organization profile ID
+ - logo: (optional) The logo file
+ - cover_photo: (optional) The cover photo file
+
+ Returns:
+ tuple: (json_response, status_code)
+ Response includes:
+ - message: Status message
+ - results: Dictionary containing upload results for each image type
+ Status codes:
+ - 200: All uploads successful
+ - 207: Partial success (some uploads failed)
+ - 400: Bad request (missing data or all uploads failed)
+ - 403: Unauthorized access
+ - 404: Organization not found
+ - 500: Server error
+ """
+ try:
+ # Get the current user's ID
+ current_user_id = request.form.get("user_id")
+ if not current_user_id:
+ return jsonify({"error": "User ID is required"}), 400
+
+ # Get and validate org_id
+ org_profile = OrgProfile.query.filter_by(user_id=current_user_id).first()
+ if not org_profile:
+ return jsonify({"error": "Organization profile not found"}), 404
+
+ org_id = org_profile.id
+ if not org_id:
+ return jsonify({"error": "Organization ID is required"}), 400
+
+ # Verify ownership
+ logging.warning(
+ f"User {current_user_id} attempting to upload images for org {org_id}"
+ )
+ logging.warning(f"Org profile user ID: {org_profile.user_id}")
+ if int(org_profile.user_id) != int(current_user_id):
+ logging.warning(
+ f"Unauthorized image upload attempt for org {org_id} by user {current_user_id}"
+ )
+ return jsonify({"error": "Unauthorized access"}), 403
+
+ # Validate that at least one file was provided
+ if not any(request.files.get(type_) for type_ in ["logo", "cover_photo"]):
+ return jsonify({"error": "No image files provided"}), 400
+
+ # Initialize image handler
+ image_handler = ImageHandler()
+
+ # Track upload results for each image type
+ results = {"logo": None, "cover_photo": None}
+
+ # Handle both file uploads
+ for image_type in ["logo", "cover_photo"]:
+ if image_type in request.files:
+ file = request.files[image_type]
+ if file and file.filename:
+ try:
+ # Get the current filename for this image type
+ old_filename = (
+ org_profile.org_logo_filename
+ if image_type == "logo"
+ else org_profile.org_cover_photo_filename
+ )
+
+ # Delete old image if it exists
+ if old_filename:
+ logging.info(f"Deleting old {image_type}: {old_filename}")
+ image_handler.delete_image(old_filename)
+
+ # Upload new image
+ logging.info(f"Uploading new {image_type}: {file.filename}")
+ filename = image_handler.upload_image(file, image_type)
+
+ # Update database field
+ if image_type == "logo":
+ org_profile.org_logo_filename = filename
+ else:
+ org_profile.org_cover_photo_filename = filename
+
+ # Store result with public URL
+ bucket_name = os.getenv("GCS_BUCKET_NAME")
+ results[image_type] = {
+ "success": True,
+ "filename": filename,
+ "url": f"https://storage.googleapis.com/{bucket_name}/{filename}",
+ }
+ logging.info(f"Successfully uploaded {image_type}")
+
+ except ValueError as ve:
+ logging.error(f"Error uploading {image_type}: {str(ve)}")
+ results[image_type] = {"success": False, "error": str(ve)}
+
+ # Update organization profile timestamp
+ org_profile.updated_at = datetime.now(timezone.utc)
+
+ # Commit changes to database
+ try:
+ db.session.commit()
+ logging.info(f"Successfully updated org profile {org_id} with new images")
+ except SQLAlchemyError as e:
+ logging.error(f"Database error updating org profile {org_id}: {str(e)}")
+ db.session.rollback()
+ return jsonify({"error": "Failed to update database"}), 500
+
+ # Prepare response with upload results
+ response = {"message": "Image upload completed", "results": results}
+
+ # Determine appropriate status code based on results
+ if all(r and r.get("success") for r in results.values() if r):
+ status_code = 200 # All uploads successful
+ elif any(r and r.get("success") for r in results.values() if r):
+ status_code = 207 # Partial success
+ else:
+ status_code = 400 # All uploads failed
+
+ return jsonify(response), status_code
+
+ except Exception as e:
+ logging.error(f"Unexpected error in upload_org_images: {str(e)}")
+ db.session.rollback()
+ return (
+ jsonify(
+ {
+ "error": "Internal server error",
+ "message": "An unexpected error occurred while processing the upload",
+ }
+ ),
+ 500,
+ )
+
+
+@profile.route("/api/test/gcs", methods=["GET"])
+def test_gcs_connection():
+ """Test GCS connectivity and operations. Only available in development."""
+ if os.getenv("ENV") != "development":
+ return jsonify({"error": "Test endpoint only available in development"}), 403
+
+ results = {
+ "connection": False,
+ "bucket_access": False,
+ "list_files": False,
+ "test_upload": False,
+ "details": {},
+ }
+
+ try:
+ # Test 1: Basic connection
+ storage_client = storage.Client()
+ results["connection"] = True
+ results["details"]["project"] = storage_client.project
+
+ # Test 2: Bucket access
+ bucket_name = os.getenv("GCS_BUCKET_NAME")
+ bucket = storage_client.bucket(bucket_name)
+ results["bucket_access"] = True
+ results["details"]["bucket_name"] = bucket_name
+
+ # Test 3: List files
+ blobs = list(bucket.list_blobs(max_results=5))
+ results["list_files"] = True
+ results["details"]["files"] = [
+ {"name": blob.name, "size": blob.size, "updated": blob.updated.isoformat()}
+ for blob in blobs
+ ]
+
+ # Test 4: Test upload
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ test_blob = bucket.blob(f"test/gcs_test_{timestamp}.txt")
+ test_blob.upload_from_string(
+ "GCS Test at " + datetime.now().isoformat(), content_type="text/plain"
+ )
+ results["test_upload"] = True
+ results["details"]["test_file"] = {
+ "name": test_blob.name,
+ "url": f"https://storage.googleapis.com/{bucket_name}/{test_blob.name}",
+ }
+
+ # Add credentials info
+ creds_path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
+ results["details"]["credentials_path"] = (
+ creds_path if creds_path else "Using default credentials"
+ )
+
+ return jsonify(
+ {"success": True, "message": "All GCS tests passed", "results": results}
+ )
+
+ except Exception as e:
+ results["error"] = str(e)
+ return (
+ jsonify(
+ {"success": False, "message": "GCS test failed", "results": results}
+ ),
+ 500,
+ )
\ No newline at end of file
diff --git a/Backend/app/utils/__init__.py b/Backend/app/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/Backend/app/utils/image_handler.py b/Backend/app/utils/image_handler.py
new file mode 100644
index 0000000..1c6a2a7
--- /dev/null
+++ b/Backend/app/utils/image_handler.py
@@ -0,0 +1,152 @@
+# Backend: app/utils/image_handler.py
+from google.cloud import storage
+import uuid
+from PIL import Image
+from io import BytesIO
+from flask import current_app
+import logging
+import os
+from datetime import datetime
+
+
+class ImageHandler:
+ """Handles image upload, validation, and storage for organization profiles"""
+
+ ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg"}
+
+ def __init__(self):
+ """Initialize GCS client and bucket"""
+ self.storage_client = storage.Client()
+ self.bucket = self.storage_client.bucket(os.getenv("GCS_BUCKET_NAME"))
+
+ def allowed_file(self, filename):
+ """
+ Check if the file extension is allowed
+ Args:
+ filename (str): Name of the uploaded file
+ Returns:
+ bool: True if file extension is allowed, False otherwise
+ """
+ return (
+ "." in filename
+ and filename.rsplit(".", 1)[1].lower() in self.ALLOWED_EXTENSIONS
+ )
+
+ def process_image(self, file, image_type):
+ """
+ Process and optimize image before upload
+ Args:
+ file: FileStorage object from request
+ image_type (str): Either 'logo' or 'cover_photo'
+ Returns:
+ tuple: (processed_image_bytes, new_filename)
+ Raises:
+ ValueError: If image dimensions are too small
+ """
+ try:
+ # Read image and convert to PIL Image
+ image = Image.open(file)
+
+ # Define size limits based on image type
+ if image_type == "logo":
+ max_size = (400, 400) # Max size for logos
+ min_size = (100, 100) # Min size for logos
+ else: # cover_photo
+ max_size = (1920, 1080) # Max size for cover photos
+ min_size = (800, 400) # Min size for cover photos
+
+ # Check minimum dimensions
+ if image.size[0] < min_size[0] or image.size[1] < min_size[1]:
+ raise ValueError(
+ f"Image too small. Minimum size is {min_size[0]}x{min_size[1]} pixels. "
+ f"Uploaded image is {image.size[0]}x{image.size[1]} pixels."
+ )
+
+ # Resize if larger than maximum size while maintaining aspect ratio
+ image.thumbnail(max_size, Image.Resampling.LANCZOS)
+
+ # Convert to RGB if necessary (handles RGBA PNG files)
+ if image.mode in ("RGBA", "P"):
+ image = image.convert("RGB")
+
+ # Save to BytesIO with optimization
+ output = BytesIO()
+ image.save(output, format="JPEG", quality=85, optimize=True)
+ output.seek(0)
+
+ # Generate unique filename with timestamp
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ new_filename = f"{image_type}/{timestamp}_{str(uuid.uuid4())[:8]}.jpg"
+
+ return output.getvalue(), new_filename
+
+ except Exception as e:
+ logging.error(f"Error processing image: {str(e)}")
+ raise ValueError(f"Error processing image: {str(e)}")
+
+ def upload_image(self, file, image_type):
+ """
+ Upload image to Google Cloud Storage
+ Args:
+ file: FileStorage object from request
+ image_type (str): Either 'logo' or 'cover_photo'
+ Returns:
+ str: The filename of the uploaded image
+ Raises:
+ ValueError: If file validation or upload fails
+ """
+ if not file:
+ raise ValueError("No file provided")
+
+ if not self.allowed_file(file.filename):
+ raise ValueError(
+ f"File type not allowed. Supported types: {', '.join(self.ALLOWED_EXTENSIONS)}"
+ )
+
+ try:
+ # Process the image
+ image_bytes, filename = self.process_image(file, image_type)
+
+ # Create new blob and upload the file
+ blob = self.bucket.blob(filename)
+ blob.upload_from_string(
+ image_bytes,
+ content_type="image/jpeg",
+ timeout=30, # Add timeout for upload
+ )
+
+ return filename
+
+ except ValueError as e:
+ raise # Re-raise ValueError for handling in route
+ except Exception as e:
+ logging.error(f"Error uploading image: {str(e)}")
+ raise ValueError(f"Error uploading image: {str(e)}")
+
+ def delete_image(self, filename):
+ """
+ Delete image from Google Cloud Storage
+ Args:
+ filename (str): Name of file to delete
+ """
+ if not filename:
+ return
+
+ try:
+ blob = self.bucket.blob(filename)
+ blob.delete(timeout=30)
+ except Exception as e:
+ logging.error(f"Error deleting image: {str(e)}")
+ # Don't raise - deletion errors shouldn't block new uploads
+
+ def get_public_url(self, filename):
+ """
+ Get the public URL for an uploaded file
+ Args:
+ filename (str): Name of the file
+ Returns:
+ str or None: Public URL of the file, or None if filename is empty
+ """
+ if not filename:
+ return None
+ return f"https://storage.googleapis.com/{self.bucket.name}/{filename}"
diff --git a/Backend/requirements.dev.txt b/Backend/requirements.dev.txt
index 49f0f44..7f8acc0 100644
--- a/Backend/requirements.dev.txt
+++ b/Backend/requirements.dev.txt
@@ -13,9 +13,11 @@ setuptools>=65.5.1
google-api-core>=2.14.0
google-cloud-core>=2.3.3
google-cloud-translate>=3.18.0
+google-cloud-storage>=3.0.0
google-auth>= 2.37.0
gunicorn>=21.2.0
itsdangerous>=2.0.1
+pillow>=11.1.0
pg8000>=1.31.2
python-dotenv>=0.21.1
psycopg2-binary>=2.9.10
diff --git a/Backend/requirements.prod.txt b/Backend/requirements.prod.txt
index ad8fa51..8987514 100644
--- a/Backend/requirements.prod.txt
+++ b/Backend/requirements.prod.txt
@@ -13,9 +13,11 @@ setuptools>=65.5.1
google-api-core>=2.14.0
google-cloud-core>=2.3.3
google-cloud-translate>=3.18.0
+google-cloud-storage>=3.0.0
google-auth>=2.23.0
gunicorn==21.2.0
itsdangerous==2.0.1
+pillow>=11.1.0
python-dotenv==0.21.1
SQLAlchemy==2.0.12
Werkzeug==2.2.2
diff --git a/Frontend/src/App.js b/Frontend/src/App.js
index f22c474..a6a9b2e 100644
--- a/Frontend/src/App.js
+++ b/Frontend/src/App.js
@@ -17,6 +17,7 @@ import VolunteerPage from './pages/VolunteerPage';
import SelectUserTypePage from './pages/SelectUserTypePage';
import EstablishmentGuide from './pages/EstablishmentGuide';
import EditProfile from './pages/EditProfilePage';
+import ProfileImages from './components/OrgProfileFormComponents/ProfileImages';
function App() {
@@ -40,6 +41,7 @@ function App() {
+
-
+ Add visual identity to your organization's profile with a logo and + cover photo +
++ Perfect square ratio (Minimum: 100x100px) +
++ 2:1 ratio recommended (Minimum: 800x400px) +
+{error}
+