diff --git a/backend/routers/photos.py b/backend/routers/photos.py index 0d083bc..6039973 100644 --- a/backend/routers/photos.py +++ b/backend/routers/photos.py @@ -1,14 +1,152 @@ -from fastapi import APIRouter, HTTPException -from fastapi.responses import FileResponse +from fastapi import APIRouter, HTTPException, UploadFile, File, Depends +from fastapi.responses import FileResponse, JSONResponse from pathlib import Path -from typing import List +from typing import List, Optional +from datetime import datetime import os +import shutil +from PIL import Image +from PIL.ExifTags import TAGS, GPSTAGS +from sqlalchemy.orm import Session +from backend.database import get_db +from backend.models import RosterUnit router = APIRouter(prefix="/api", tags=["photos"]) PHOTOS_BASE_DIR = Path("data/photos") +def extract_exif_data(image_path: Path) -> dict: + """ + Extract EXIF metadata from an image file. + Returns dict with timestamp, GPS coordinates, and other metadata. + """ + try: + image = Image.open(image_path) + exif_data = image._getexif() + + if not exif_data: + return {} + + metadata = {} + + # Extract standard EXIF tags + for tag_id, value in exif_data.items(): + tag = TAGS.get(tag_id, tag_id) + + # Extract datetime + if tag == "DateTime" or tag == "DateTimeOriginal": + try: + metadata["timestamp"] = datetime.strptime(str(value), "%Y:%m:%d %H:%M:%S") + except: + pass + + # Extract GPS data + if tag == "GPSInfo": + gps_data = {} + for gps_tag_id in value: + gps_tag = GPSTAGS.get(gps_tag_id, gps_tag_id) + gps_data[gps_tag] = value[gps_tag_id] + + # Convert GPS data to decimal degrees + lat = gps_data.get("GPSLatitude") + lat_ref = gps_data.get("GPSLatitudeRef") + lon = gps_data.get("GPSLongitude") + lon_ref = gps_data.get("GPSLongitudeRef") + + if lat and lon and lat_ref and lon_ref: + # Convert to decimal degrees + lat_decimal = convert_to_degrees(lat) + if lat_ref == "S": + lat_decimal = -lat_decimal + + lon_decimal = convert_to_degrees(lon) + if lon_ref == "W": + lon_decimal = -lon_decimal + + metadata["latitude"] = lat_decimal + metadata["longitude"] = lon_decimal + metadata["coordinates"] = f"{lat_decimal},{lon_decimal}" + + return metadata + except Exception as e: + print(f"Error extracting EXIF data: {e}") + return {} + + +def convert_to_degrees(value): + """ + Convert GPS coordinates from degrees/minutes/seconds to decimal degrees. + """ + d, m, s = value + return float(d) + (float(m) / 60.0) + (float(s) / 3600.0) + + +@router.post("/unit/{unit_id}/upload-photo") +async def upload_photo( + unit_id: str, + photo: UploadFile = File(...), + auto_populate_coords: bool = True, + db: Session = Depends(get_db) +): + """ + Upload a photo for a unit and extract EXIF metadata. + If GPS data exists and auto_populate_coords is True, update the unit's coordinates. + """ + # Validate file type + allowed_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"} + file_ext = Path(photo.filename).suffix.lower() + + if file_ext not in allowed_extensions: + raise HTTPException( + status_code=400, + detail=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}" + ) + + # Create photos directory for this unit + unit_photo_dir = PHOTOS_BASE_DIR / unit_id + unit_photo_dir.mkdir(parents=True, exist_ok=True) + + # Generate filename with timestamp to avoid collisions + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{timestamp}_{photo.filename}" + file_path = unit_photo_dir / filename + + # Save the file + try: + with open(file_path, "wb") as buffer: + shutil.copyfileobj(photo.file, buffer) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to save photo: {str(e)}") + + # Extract EXIF metadata + metadata = extract_exif_data(file_path) + + # Update unit coordinates if GPS data exists and auto_populate_coords is True + coordinates_updated = False + if auto_populate_coords and "coordinates" in metadata: + roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + + if roster_unit: + roster_unit.coordinates = metadata["coordinates"] + roster_unit.last_updated = datetime.utcnow() + db.commit() + coordinates_updated = True + + return JSONResponse(content={ + "success": True, + "filename": filename, + "file_path": f"/api/unit/{unit_id}/photo/{filename}", + "metadata": { + "timestamp": metadata.get("timestamp").isoformat() if metadata.get("timestamp") else None, + "latitude": metadata.get("latitude"), + "longitude": metadata.get("longitude"), + "coordinates": metadata.get("coordinates") + }, + "coordinates_updated": coordinates_updated + }) + + @router.get("/unit/{unit_id}/photos") def get_unit_photos(unit_id: str): """ @@ -51,6 +189,46 @@ def get_unit_photos(unit_id: str): } +@router.get("/recent-photos") +def get_recent_photos(limit: int = 12): + """ + Get the most recently uploaded photos across all units. + Returns photos sorted by modification time (newest first). + """ + if not PHOTOS_BASE_DIR.exists(): + return {"photos": []} + + all_photos = [] + image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"} + + # Scan all unit directories + for unit_dir in PHOTOS_BASE_DIR.iterdir(): + if not unit_dir.is_dir(): + continue + + unit_id = unit_dir.name + + # Get all photos in this unit's directory + for file_path in unit_dir.iterdir(): + if file_path.is_file() and file_path.suffix.lower() in image_extensions: + all_photos.append({ + "unit_id": unit_id, + "filename": file_path.name, + "path": f"/api/unit/{unit_id}/photo/{file_path.name}", + "modified": file_path.stat().st_mtime, + "modified_iso": datetime.fromtimestamp(file_path.stat().st_mtime).isoformat() + }) + + # Sort by modification time (most recent first) and limit + all_photos.sort(key=lambda x: x["modified"], reverse=True) + recent_photos = all_photos[:limit] + + return { + "photos": recent_photos, + "total": len(all_photos) + } + + @router.get("/unit/{unit_id}/photo/{filename}") def get_photo(unit_id: str, filename: str): """ diff --git a/requirements.txt b/requirements.txt index f526daf..86b1adc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pydantic==2.5.0 python-multipart==0.0.6 jinja2==3.1.2 aiofiles==23.2.1 +Pillow==10.1.0 diff --git a/templates/dashboard.html b/templates/dashboard.html index e2c3796..5bb86ec 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -159,6 +159,26 @@ + +
Loading recent photos...
+${photo.unit_id}
+No photos uploaded yet. Upload photos from unit detail pages.
'; + } + } catch (error) { + console.error('Error loading recent photos:', error); + document.getElementById('recentPhotosGallery').innerHTML = 'Failed to load recent photos
'; + } +} + +// Load recent photos on page load and refresh every 30 seconds +loadRecentPhotos(); +setInterval(loadRecentPhotos, 30000); {% endblock %} diff --git a/templates/unit_detail.html b/templates/unit_detail.html index 547000e..55ebe6a 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -177,6 +177,38 @@--
Loading photos...
+No photos yet. Add a photo to get started.
'; + } + } catch (error) { + console.error('Error loading photos:', error); + document.getElementById('photoGallery').innerHTML = 'Failed to load photos
'; + } +} + +// Upload photo with EXIF metadata extraction +async function uploadPhoto(file) { + if (!file) return; + + const statusDiv = document.getElementById('uploadStatus'); + statusDiv.className = 'mt-4 p-4 rounded-lg bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'; + statusDiv.textContent = 'Uploading photo and extracting metadata...'; + statusDiv.classList.remove('hidden'); + + const formData = new FormData(); + formData.append('photo', file); + + try { + const response = await fetch(`/api/unit/${unitId}/upload-photo`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error('Upload failed'); + } + + const result = await response.json(); + + // Show success message with metadata info + let message = 'Photo uploaded successfully!'; + if (result.metadata && result.metadata.coordinates) { + message += ` GPS location detected: ${result.metadata.coordinates}`; + if (result.coordinates_updated) { + message += ' (Unit coordinates updated automatically)'; + } + } else { + message += ' No GPS data found in photo.'; + } + + statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'; + statusDiv.textContent = message; + + // Reload photos and unit data + await loadPhotos(); + if (result.coordinates_updated) { + await loadUnitData(); + } + + // Hide status after 5 seconds + setTimeout(() => { + statusDiv.classList.add('hidden'); + }, 5000); + + // Reset both file inputs + document.getElementById('photoCameraUpload').value = ''; + document.getElementById('photoLibraryUpload').value = ''; + + } catch (error) { + console.error('Error uploading photo:', error); + statusDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'; + statusDiv.textContent = `Error uploading photo: ${error.message}`; + + // Hide error after 5 seconds + setTimeout(() => { + statusDiv.classList.add('hidden'); + }, 5000); + } +} + // Load data when page loads -loadUnitData(); +loadUnitData().then(() => { + loadPhotos(); +}); {% endblock %}