| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- 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
- 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():
- 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
- """
- scans = db.execute_query(query)
- 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
- @router.get("/admin/scans")
- async def admin_get_all_scans(admin: dict = Depends(require_admin)):
- 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
- """
- scans = db.execute_query(query)
- 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
- @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"}
|