from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File from typing import Optional from pydantic import BaseModel import db import schemas import auth_utils import config import os import uuid import shutil import zipfile import json from services.global_manager import global_manager from dependencies import require_admin from services.audit_service import audit_service # Test deployment trigger - comment update router = APIRouter(tags=["portfolio"]) class ScanUpdate(BaseModel): is_public: bool @router.get("/portfolio") async def get_public_portfolio(): query = """ SELECT p.id, p.file_path, COALESCE(o.material_name, 'Showcase') as material_name, p.order_id, p.caption FROM order_photos p LEFT JOIN orders o ON p.order_id = o.id WHERE p.is_public = TRUE AND (o.id IS NULL OR o.allow_portfolio = TRUE) ORDER BY p.created_at DESC """ return db.execute_query(query) @router.get("/admin/all-photos") async def admin_get_all_photos(admin: dict = Depends(require_admin)): query = """ SELECT p.id, p.file_path, p.is_public, p.order_id, p.caption, o.allow_portfolio, o.first_name, o.last_name, COALESCE(o.material_name, 'Manual') as material_name FROM order_photos p LEFT JOIN orders o ON p.order_id = o.id ORDER BY p.created_at DESC """ return db.execute_query(query) @router.post("/admin/orders/{order_id}/photos") async def admin_upload_order_photo( order_id: int, is_public: bool = Form(False), caption: Optional[str] = Form(None), file: UploadFile = File(...), admin: dict = Depends(require_admin) ): order = db.execute_query("SELECT allow_portfolio FROM orders WHERE id = %s", (order_id,)) if not order: raise HTTPException(status_code=404, detail="Order not found") if is_public and not order[0]['allow_portfolio']: raise HTTPException(status_code=400, detail="Cannot make public: User did not consent to portfolio usage") if not file.filename: raise HTTPException(status_code=400, detail="Invalid file") unique_filename = f"{uuid.uuid4()}{os.path.splitext(file.filename)[1]}" disk_path = os.path.join(config.UPLOAD_DIR, unique_filename) db_file_path = f"uploads/{unique_filename}" with open(disk_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) query = "INSERT INTO order_photos (order_id, file_path, is_public, caption) VALUES (%s, %s, %s, %s)" photo_id = db.execute_commit(query, (order_id, db_file_path, is_public, caption)) await audit_service.log( user_id=admin['id'], action="upload_order_photo", target_type="order", target_id=order_id, details={"photo_id": photo_id, "is_public": is_public, "caption": caption} ) # NOTIFY USER VIA WEBSOCKET order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,)) if order_info: await global_manager.notify_order_update(order_info[0]['user_id'], order_id) return {"id": photo_id, "file_path": db_file_path, "is_public": is_public, "caption": caption} @router.post("/admin/portfolio/upload") async def admin_upload_portfolio_photo( is_public: bool = Form(True), caption: Optional[str] = Form(None), file: UploadFile = File(...), admin: dict = Depends(require_admin) ): if not file.filename: raise HTTPException(status_code=400, detail="Invalid file") unique_filename = f"{uuid.uuid4()}{os.path.splitext(file.filename)[1]}" disk_path = os.path.join(config.UPLOAD_DIR, unique_filename) db_file_path = f"uploads/{unique_filename}" with open(disk_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) query = "INSERT INTO order_photos (order_id, file_path, is_public, caption) VALUES (%s, %s, %s, %s)" photo_id = db.execute_commit(query, (None, db_file_path, is_public, caption)) await audit_service.log( user_id=admin['id'], action="upload_portfolio_photo", target_type="photo", target_id=photo_id, details={"is_public": is_public, "caption": caption} ) return {"id": photo_id, "file_path": db_file_path, "is_public": is_public, "caption": caption} @router.patch("/admin/photos/{photo_id}") async def admin_update_photo_status(photo_id: int, data: schemas.PhotoUpdate, admin: dict = Depends(require_admin)): query = "SELECT p.*, o.allow_portfolio FROM order_photos p LEFT JOIN orders o ON p.order_id = o.id WHERE p.id = %s" photo_data = db.execute_query(query, (photo_id,)) if not photo_data: raise HTTPException(status_code=404, detail="Photo not found") # Check consent if linked to order if data.is_public and photo_data[0]['order_id'] is not None and not photo_data[0]['allow_portfolio']: raise HTTPException(status_code=400, detail="Cannot make public: User did not consent to portfolio usage") db.execute_commit("UPDATE order_photos SET is_public = %s, caption = %s WHERE id = %s", (data.is_public, data.caption, photo_id)) await audit_service.log( user_id=admin['id'], action="update_photo_visibility", target_type="photo", target_id=photo_id, details={"is_public": data.is_public, "caption": data.caption} ) # NOTIFY USER VIA WEBSOCKET if linked to order order_id = photo_data[0]['order_id'] if order_id: order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,)) if order_info: await global_manager.notify_order_update(order_info[0]['user_id'], order_id) return {"id": photo_id, "is_public": data.is_public, "caption": data.caption} @router.delete("/admin/photos/{photo_id}") async def admin_delete_photo(photo_id: int, admin: dict = Depends(require_admin)): photo = db.execute_query("SELECT file_path, order_id FROM order_photos WHERE id = %s", (photo_id,)) if not photo: raise HTTPException(status_code=404, detail="Photo not found") order_id = photo[0]['order_id'] try: path = os.path.join(config.BASE_DIR, photo[0]['file_path']) if os.path.exists(path): os.remove(path) except Exception as e: print(f"Error deleting photo file: {e}") db.execute_commit("DELETE FROM order_photos WHERE id = %s", (photo_id,)) await audit_service.log( user_id=admin['id'], action="delete_photo", target_type="photo", target_id=photo_id, details={"order_id": order_id} ) # NOTIFY USER VIA WEBSOCKET order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,)) if order_info: await global_manager.notify_order_update(order_info[0]['user_id'], order_id) return {"id": photo_id, "status": "deleted"} # --- 3D Scan Portfolio Endpoints --- @router.get("/scans") async def get_public_scans(page: int = 1, size: int = 10): offset = (page - 1) * size # Get total count count_res = db.execute_query("SELECT COUNT(*) as total FROM portfolio_scans WHERE is_public = TRUE") total = count_res[0]['total'] if count_res else 0 query = """ SELECT id, title_en, title_ru, title_me, title_ua, description_en, description_ru, description_me, description_ua, folder_name, frames_count, created_at FROM portfolio_scans WHERE is_public = TRUE ORDER BY created_at DESC LIMIT %s OFFSET %s """ scans = db.execute_query(query, (size, offset)) scans_dir = os.path.join(config.UPLOAD_DIR, "scans") for scan in scans: photo_path = os.path.join(scans_dir, scan["folder_name"], "photo.webp") scan["has_photo"] = os.path.exists(photo_path) return {"scans": scans, "total": total} @router.get("/admin/scans") async def admin_get_all_scans(page: int = 1, size: int = 10, admin: dict = Depends(require_admin)): offset = (page - 1) * size # Get total count count_res = db.execute_query("SELECT COUNT(*) as total FROM portfolio_scans") total = count_res[0]['total'] if count_res else 0 query = """ SELECT id, title_en, title_ru, title_me, title_ua, description_en, description_ru, description_me, description_ua, folder_name, frames_count, is_public, created_at FROM portfolio_scans ORDER BY created_at DESC LIMIT %s OFFSET %s """ scans = db.execute_query(query, (size, offset)) scans_dir = os.path.join(config.UPLOAD_DIR, "scans") for scan in scans: photo_path = os.path.join(scans_dir, scan["folder_name"], "photo.webp") scan["has_photo"] = os.path.exists(photo_path) return {"scans": scans, "total": total} @router.post("/admin/scans/upload-bundle") async def admin_upload_scan_bundle( file: UploadFile = File(...), admin: dict = Depends(require_admin) ): if not file.filename or not file.filename.endswith(".zip"): raise HTTPException(status_code=400, detail="Invalid file: must be a ZIP archive") scan_id = str(uuid.uuid4()) scans_dir = os.path.join(config.UPLOAD_DIR, "scans") os.makedirs(scans_dir, exist_ok=True) dest_dir = os.path.join(scans_dir, f"scan_{scan_id}") os.makedirs(dest_dir, exist_ok=True) temp_zip_path = os.path.join(dest_dir, "temp_bundle.zip") with open(temp_zip_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) try: # Extract files with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref: zip_ref.extractall(dest_dir) # Clean up zip if os.path.exists(temp_zip_path): os.remove(temp_zip_path) # Validate metadata.json metadata_path = os.path.join(dest_dir, "metadata.json") if not os.path.exists(metadata_path): raise HTTPException(status_code=400, detail="Invalid bundle: metadata.json is missing") with open(metadata_path, "r", encoding="utf-8") as f: metadata = json.load(f) title_en = metadata.get("title_en") if not title_en: raise HTTPException(status_code=400, detail="Invalid bundle: title_en is required") title_ru = metadata.get("title_ru", "") or title_en title_me = metadata.get("title_me", "") or title_en title_ua = metadata.get("title_ua", "") or title_en description_en = metadata.get("description_en", "") description_ru = metadata.get("description_ru", "") or description_en description_me = metadata.get("description_me", "") or description_en description_ua = metadata.get("description_ua", "") or description_en frames_count = int(metadata.get("frames_count", 48)) # Verify frame WebP files for i in range(frames_count): frame_path = os.path.join(dest_dir, f"{i:03d}.webp") if not os.path.exists(frame_path): raise HTTPException(status_code=400, detail=f"Invalid bundle: frame {i:03d}.webp is missing") # Verify thumbnail thumbnail_path = os.path.join(dest_dir, "thumbnail.webp") if not os.path.exists(thumbnail_path): raise HTTPException(status_code=400, detail="Invalid bundle: thumbnail.webp is missing") # Check and convert original photograph if present for ext in [".webp", ".png", ".jpg", ".jpeg"]: p_path = os.path.join(dest_dir, f"photo{ext}") if os.path.exists(p_path): if ext != ".webp": from PIL import Image try: with Image.open(p_path) as img: img.save(os.path.join(dest_dir, "photo.webp"), "WEBP", quality=85) os.remove(p_path) except Exception as ex: print(f"Failed to convert photo to WebP: {ex}") break # Insert DB record query = """ INSERT INTO portfolio_scans ( title_en, title_ru, title_me, title_ua, description_en, description_ru, description_me, description_ua, folder_name, frames_count, is_public ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """ db_id = db.execute_commit(query, ( title_en, title_ru, title_me, title_ua, description_en, description_ru, description_me, description_ua, f"scan_{scan_id}", frames_count, 1 )) await audit_service.log( user_id=admin['id'], action="upload_scan_bundle", target_type="scan", target_id=db_id, details={"title_en": title_en, "folder_name": f"scan_{scan_id}"} ) return { "id": db_id, "title_en": title_en, "folder_name": f"scan_{scan_id}", "frames_count": frames_count } except Exception as e: if os.path.exists(dest_dir): shutil.rmtree(dest_dir) if isinstance(e, HTTPException): raise e raise HTTPException(status_code=500, detail=f"Error processing bundle: {str(e)}") @router.patch("/admin/scans/{scan_id}") async def admin_update_scan_status( scan_id: int, data: ScanUpdate, admin: dict = Depends(require_admin) ): scan = db.execute_query("SELECT id FROM portfolio_scans WHERE id = %s", (scan_id,)) if not scan: raise HTTPException(status_code=404, detail="Scan not found") db.execute_commit("UPDATE portfolio_scans SET is_public = %s WHERE id = %s", (data.is_public, scan_id)) await audit_service.log( user_id=admin['id'], action="update_scan_visibility", target_type="scan", target_id=scan_id, details={"is_public": data.is_public} ) return {"id": scan_id, "is_public": data.is_public} @router.delete("/admin/scans/{scan_id}") async def admin_delete_scan(scan_id: int, admin: dict = Depends(require_admin)): scan = db.execute_query("SELECT folder_name, title_en FROM portfolio_scans WHERE id = %s", (scan_id,)) if not scan: raise HTTPException(status_code=404, detail="Scan not found") folder_name = scan[0]['folder_name'] title_en = scan[0]['title_en'] dest_dir = os.path.join(config.UPLOAD_DIR, "scans", folder_name) try: if os.path.exists(dest_dir): shutil.rmtree(dest_dir) except Exception as e: print(f"Error removing scan directory {folder_name}: {e}") db.execute_commit("DELETE FROM portfolio_scans WHERE id = %s", (scan_id,)) await audit_service.log( user_id=admin['id'], action="delete_scan_bundle", target_type="scan", target_id=scan_id, details={"title_en": title_en, "folder_name": folder_name} ) return {"id": scan_id, "status": "deleted"}