Photo mode feature added.

This commit is contained in:
serversdwn
2025-12-15 18:46:26 +00:00
parent 6db958ffa6
commit 191dceff2b
4 changed files with 375 additions and 4 deletions

View File

@@ -1,14 +1,152 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, JSONResponse
from pathlib import Path from pathlib import Path
from typing import List from typing import List, Optional
from datetime import datetime
import os 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"]) router = APIRouter(prefix="/api", tags=["photos"])
PHOTOS_BASE_DIR = Path("data/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") @router.get("/unit/{unit_id}/photos")
def get_unit_photos(unit_id: str): 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}") @router.get("/unit/{unit_id}/photo/{filename}")
def get_photo(unit_id: str, filename: str): def get_photo(unit_id: str, filename: str):
""" """

View File

@@ -5,3 +5,4 @@ pydantic==2.5.0
python-multipart==0.0.6 python-multipart==0.0.6
jinja2==3.1.2 jinja2==3.1.2
aiofiles==23.2.1 aiofiles==23.2.1
Pillow==10.1.0

View File

@@ -159,6 +159,26 @@
</div> </div>
</div> </div>
<!-- Recent Photos Section -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="recent-photos-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
<div class="flex items-center gap-2">
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-photos-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
<div class="card-content" id="recent-photos-content">
<div id="recentPhotosGallery" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading recent photos...</p>
</div>
</div>
</div>
<!-- Fleet Status Section with Tabs --> <!-- Fleet Status Section with Tabs -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-status-card"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-status-card">
@@ -471,6 +491,46 @@ function parseLocation(location) {
// TODO: Add geocoding support for address strings // TODO: Add geocoding support for address strings
return null; return null;
} }
// Load and display recent photos
async function loadRecentPhotos() {
try {
const response = await fetch('/api/recent-photos?limit=12');
if (!response.ok) {
throw new Error('Failed to load recent photos');
}
const data = await response.json();
const gallery = document.getElementById('recentPhotosGallery');
if (data.photos && data.photos.length > 0) {
gallery.innerHTML = '';
data.photos.forEach(photo => {
const photoDiv = document.createElement('div');
photoDiv.className = 'relative group';
photoDiv.innerHTML = `
<a href="/unit/${photo.unit_id}" class="block">
<img src="${photo.path}" alt="${photo.unit_id}"
class="w-full h-32 object-cover rounded-lg shadow hover:shadow-lg transition-shadow">
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 rounded-b-lg">
<p class="text-white text-xs font-semibold">${photo.unit_id}</p>
</div>
</a>
`;
gallery.appendChild(photoDiv);
});
} else {
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos uploaded yet. Upload photos from unit detail pages.</p>';
}
} catch (error) {
console.error('Error loading recent photos:', error);
document.getElementById('recentPhotosGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load recent photos</p>';
}
}
// Load recent photos on page load and refresh every 30 seconds
loadRecentPhotos();
setInterval(loadRecentPhotos, 30000);
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -177,6 +177,38 @@
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label> <label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p> <p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
</div> </div>
<!-- Photos -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex justify-between items-start mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Photos</h3>
<div class="flex flex-col sm:flex-row gap-2">
<!-- Take Photo Button (Camera) -->
<label for="photoCameraUpload" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="hidden sm:inline">Take Photo</span>
<span class="sm:hidden">Camera</span>
</label>
<!-- Choose from Library Button -->
<label for="photoLibraryUpload" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<span class="hidden sm:inline">Choose Photo</span>
<span class="sm:hidden">Library</span>
</label>
<input type="file" id="photoCameraUpload" accept="image/*" capture="environment" class="hidden" onchange="uploadPhoto(this.files[0])">
<input type="file" id="photoLibraryUpload" accept="image/*" class="hidden" onchange="uploadPhoto(this.files[0])">
</div>
</div>
<div id="photoGallery" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading photos...</p>
</div>
<div id="uploadStatus" class="hidden mt-4 p-4 rounded-lg"></div>
</div>
</div> </div>
</div> </div>
@@ -632,7 +664,107 @@ function parseLocation(location) {
return null; return null;
} }
// Load and display photos
async function loadPhotos() {
try {
const response = await fetch(`/api/unit/${unitId}/photos`);
if (!response.ok) {
throw new Error('Failed to load photos');
}
const data = await response.json();
const gallery = document.getElementById('photoGallery');
if (data.photos && data.photos.length > 0) {
gallery.innerHTML = '';
data.photo_urls.forEach((url, index) => {
const photoDiv = document.createElement('div');
photoDiv.className = 'relative group';
photoDiv.innerHTML = `
<img src="${url}" alt="Unit photo ${index + 1}"
class="w-full h-48 object-cover rounded-lg shadow cursor-pointer hover:shadow-lg transition-shadow"
onclick="window.open('${url}', '_blank')">
${index === 0 ? '<span class="absolute top-2 left-2 bg-seismo-orange text-white text-xs px-2 py-1 rounded">Primary</span>' : ''}
`;
gallery.appendChild(photoDiv);
});
} else {
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos yet. Add a photo to get started.</p>';
}
} catch (error) {
console.error('Error loading photos:', error);
document.getElementById('photoGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load photos</p>';
}
}
// 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 // Load data when page loads
loadUnitData(); loadUnitData().then(() => {
loadPhotos();
});
</script> </script>
{% endblock %} {% endblock %}