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() { } /> } /> } /> + } /> } /> } /> diff --git a/Frontend/src/components/OrgDisplayCard.js b/Frontend/src/components/OrgDisplayCard.js index 85337a7..9068ab0 100644 --- a/Frontend/src/components/OrgDisplayCard.js +++ b/Frontend/src/components/OrgDisplayCard.js @@ -15,68 +15,112 @@ const categoryColors = { // Organization display card component - used to display organization information in a card format export default function OrgDisplayCard({ org }) { const navigate = useNavigate(); + const categoryColor = categoryColors[org.category?.toLowerCase()] || categoryColors.default; - function handleProfileLinkClick(org) { + const handleProfileLinkClick = (e, org) => { + e.stopPropagation(); navigate('/org_profile', { state: { org } }); - } + }; + + const getAbbreviation = (name) => { + return name + .split(" ") + .map((word) => word[0]) + .join("") + .substring(0, 3) + .toUpperCase(); + }; - const categoryColor = categoryColors[org.category?.toLowerCase()] || categoryColors.default; return (
handleProfileLinkClick(org)} + className="group relative bg-white rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden" + onClick={(e) => handleProfileLinkClick(e, org)} > -
-
-

+ {/* Card Header with Logo and Category Tags */} +
+ {/* Organization Logo */} +
+
+ {org.org_logo_filename ? ( + {`${org.org_name} { + e.target.style.display = 'none'; + e.target.parentElement.innerHTML = ` + + ${org.org_name.substring(0, 2).toUpperCase()} + + `; + }} + /> + ) : ( + + {getAbbreviation(org.org_name)} + + )} +
+
+ + {/* Organization Info */} +
+

{org.org_name}

- -
+ + {/* Focus Areas */} +
{org.focus_areas.map((focus_area, index) => ( - - {focus_area.description} || {focus_area.name}} - > - {focus_area.name} - - {index < org.focus_areas.length - 1 && ( - - )} - + + {focus_area.name} + ))}
- + + {/* Organization Overview */} +

+ {org.org_overview} +

-

- {org.org_overview} -

-
+
+ + {/* Card Footer with Metadata and CTA */} +
+
- - {org.location || 'Location N/A'} + + + {org.location || 'Location N/A'} +
- - {org.beneficiaries || 'Beneficiaries N/A'} + + + {org.beneficiaries || 'Beneficiaries N/A'} +
- - {org.established || 'Est. N/A'} + + + {org.established || 'Est. N/A'} +
+ + {/* CTA Button */}
diff --git a/Frontend/src/components/OrgProfileFormComponents/ProfileImages.js b/Frontend/src/components/OrgProfileFormComponents/ProfileImages.js new file mode 100644 index 0000000..4cc6db4 --- /dev/null +++ b/Frontend/src/components/OrgProfileFormComponents/ProfileImages.js @@ -0,0 +1,326 @@ +import React, { useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { + Upload, + Image as ImageIcon, + Save, + CheckCircle2, +} from "lucide-react"; +import { useApi } from '../../contexts/ApiProvider'; +import { useAuth } from '../../contexts/AuthProvider'; + +export function ProfileImages({ + onEdit = false, + }) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedImages, setSelectedImages] = useState({ + logo: null, + cover_photo: null, + }); + const [previews, setPreviews] = useState({ + logo: null, + cover_photo: null, + }); + + const apiClient = useApi(); + const { user } = useAuth(); + const location = useLocation(); + const orgData = location.state?.orgData; + const navigate = useNavigate(); + + const handleImageSelect = (type, e) => { + const file = e.target.files[0]; + if (!file) return; + + // Validate file type + const validTypes = ["image/jpeg", "image/png"]; + if (!validTypes.includes(file.type)) { + setError(`Please select a JPG or PNG image for ${type}`); + return; + } + + // Validate file size (max 5MB) + if (file.size > 5 * 1024 * 1024) { + setError(`${type} image size should be less than 5MB`); + return; + } + + // Create preview + const reader = new FileReader(); + reader.onloadend = () => { + setPreviews((prev) => ({ + ...prev, + [type]: reader.result, + })); + }; + reader.readAsDataURL(file); + + setSelectedImages((prev) => ({ + ...prev, + [type]: file, + })); + + setError(null); + }; + + const handleNavigateToProfile = () => { + + // Navigate to profile page + navigate("/org_profile", { + state: { + org: { user_id: orgData.user_id }, + }, + }); + }; + + const handleSubmit = async () => { + // Skip if no images selected + if (!selectedImages.logo && !selectedImages.cover_photo) { + return; + } + + try { + setLoading(true); + setError(null); + + const formData = new FormData(); + formData.append("user_id", user.id); + + if (selectedImages.logo) { + formData.append("logo", selectedImages.logo); + } + if (selectedImages.cover_photo) { + formData.append("cover_photo", selectedImages.cover_photo); + } + + const response = await apiClient.post('/profile/upload-images', formData); + + const { message, results } = response?.body; + + console.log('Upload successful:', { + message, + logoUrl: results.logo?.url, + coverUrl: results.cover_photo?.url + }); + + if (results.logo?.success || results.cover_photo?.success) { + setPreviews((prev) => ({ + ...prev, + logo: results.logo?.url, + cover_photo: results.cover_photo?.url, + })); + } else { + throw new Error(response.body.error || 'Upload failed'); + } + + } catch (err) { + setError(err.response?.data?.error || err.message || 'Upload failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Enhanced Header with animation */} +
+
+
+ +

+ Bring Your Profile to Life +

+
+

+ Add visual identity to your organization's profile with a logo and + cover photo +

+
+
+ + {/* Main content with enhanced visuals */} +
+
+
+ {/* Logo Upload with enhanced UI */} +
+
+
+ +
+
+

+ Organization Logo +

+

+ Perfect square ratio (Minimum: 100x100px) +

+
+
+ +
+ {previews.logo ? ( +
+ Logo preview +
+ +
+ +
+
+ ) : ( + + )} +
+
+ + {/* Cover Photo Upload with enhanced UI */} +
+
+
+ +
+
+

+ Cover Photo +

+

+ 2:1 ratio recommended (Minimum: 800x400px) +

+
+
+ +
+ {previews.cover_photo ? ( +
+ Cover photo preview +
+ +
+ +
+
+ ) : ( + + )} +
+
+
+ + {/* Enhanced Error Message */} + {error && ( +
+
+
⚠️
+

{error}

+
+
+ )} + + {/* Enhanced Navigation buttons */} +
+ + + {/* render if edit is false */} + {!onEdit && ( + + )} +
+
+
+ + {/* Optional tip */} +
+ Tip: Your organization's visual identity helps build trust and + recognition +
+
+
+ ); + } + +export default ProfileImages; \ No newline at end of file diff --git a/Frontend/src/pages/EditProfilePage.js b/Frontend/src/pages/EditProfilePage.js index 43910b5..1b104a7 100644 --- a/Frontend/src/pages/EditProfilePage.js +++ b/Frontend/src/pages/EditProfilePage.js @@ -4,11 +4,13 @@ import { BuildingOfficeIcon, DocumentIcon, ArrowRightIcon, + CameraIcon } from '@heroicons/react/24/outline'; import EditSupportNeeds from '../components/EditingComponents/EditSupportNeeds.js'; import EditBasicInfo from '../components/EditingComponents/EditBasicInfo.js'; import EditInitiatives from '../components/EditingComponents/EditInitiatives.js'; import EditProjects from '../components/EditingComponents/EditProjects.js'; +import ProfileImages from '../components/OrgProfileFormComponents/ProfileImages.js'; export default function EditProfile({ }) { const location = useLocation(); @@ -22,7 +24,8 @@ export default function EditProfile({ }) { { id: 'basic', label: 'Basic Information', icon: BuildingOfficeIcon }, { id: 'initiatives', label: 'Initiatives', icon: DocumentIcon }, { id: 'projects', label: 'Projects', icon: DocumentIcon }, - { id: 'skills', label: 'Skills Needed', icon: DocumentIcon } + { id: 'skills', label: 'Skills Needed', icon: DocumentIcon }, + { id: 'images', label: 'Images', icon: CameraIcon} ]; const handleEdit = (section) => { @@ -144,6 +147,15 @@ export default function EditProfile({ }) { setLocalData={setLocalData} /> )} + + {/* Images editing sections */} + {activeTab === 'images' && ( + + )} + + {/* Return to Profile Button */}