portfolio.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File
  2. from typing import Optional
  3. from pydantic import BaseModel
  4. import db
  5. import schemas
  6. import auth_utils
  7. import config
  8. import os
  9. import uuid
  10. import shutil
  11. import zipfile
  12. import json
  13. from services.global_manager import global_manager
  14. from dependencies import require_admin
  15. from services.audit_service import audit_service
  16. router = APIRouter(tags=["portfolio"])
  17. class ScanUpdate(BaseModel):
  18. is_public: bool
  19. @router.get("/portfolio")
  20. async def get_public_portfolio():
  21. query = """
  22. SELECT p.id, p.file_path, COALESCE(o.material_name, 'Showcase') as material_name, p.order_id, p.caption
  23. FROM order_photos p
  24. LEFT JOIN orders o ON p.order_id = o.id
  25. WHERE p.is_public = TRUE AND (o.id IS NULL OR o.allow_portfolio = TRUE)
  26. ORDER BY p.created_at DESC
  27. """
  28. return db.execute_query(query)
  29. @router.get("/admin/all-photos")
  30. async def admin_get_all_photos(admin: dict = Depends(require_admin)):
  31. query = """
  32. SELECT p.id, p.file_path, p.is_public, p.order_id, p.caption, o.allow_portfolio,
  33. o.first_name, o.last_name, COALESCE(o.material_name, 'Manual') as material_name
  34. FROM order_photos p
  35. LEFT JOIN orders o ON p.order_id = o.id
  36. ORDER BY p.created_at DESC
  37. """
  38. return db.execute_query(query)
  39. @router.post("/admin/orders/{order_id}/photos")
  40. async def admin_upload_order_photo(
  41. order_id: int,
  42. is_public: bool = Form(False),
  43. caption: Optional[str] = Form(None),
  44. file: UploadFile = File(...),
  45. admin: dict = Depends(require_admin)
  46. ):
  47. order = db.execute_query("SELECT allow_portfolio FROM orders WHERE id = %s", (order_id,))
  48. if not order: raise HTTPException(status_code=404, detail="Order not found")
  49. if is_public and not order[0]['allow_portfolio']:
  50. raise HTTPException(status_code=400, detail="Cannot make public: User did not consent to portfolio usage")
  51. if not file.filename: raise HTTPException(status_code=400, detail="Invalid file")
  52. unique_filename = f"{uuid.uuid4()}{os.path.splitext(file.filename)[1]}"
  53. disk_path = os.path.join(config.UPLOAD_DIR, unique_filename)
  54. db_file_path = f"uploads/{unique_filename}"
  55. with open(disk_path, "wb") as buffer:
  56. shutil.copyfileobj(file.file, buffer)
  57. query = "INSERT INTO order_photos (order_id, file_path, is_public, caption) VALUES (%s, %s, %s, %s)"
  58. photo_id = db.execute_commit(query, (order_id, db_file_path, is_public, caption))
  59. await audit_service.log(
  60. user_id=admin['id'],
  61. action="upload_order_photo",
  62. target_type="order",
  63. target_id=order_id,
  64. details={"photo_id": photo_id, "is_public": is_public, "caption": caption}
  65. )
  66. # NOTIFY USER VIA WEBSOCKET
  67. order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
  68. if order_info:
  69. await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
  70. return {"id": photo_id, "file_path": db_file_path, "is_public": is_public, "caption": caption}
  71. @router.post("/admin/portfolio/upload")
  72. async def admin_upload_portfolio_photo(
  73. is_public: bool = Form(True),
  74. caption: Optional[str] = Form(None),
  75. file: UploadFile = File(...),
  76. admin: dict = Depends(require_admin)
  77. ):
  78. if not file.filename: raise HTTPException(status_code=400, detail="Invalid file")
  79. unique_filename = f"{uuid.uuid4()}{os.path.splitext(file.filename)[1]}"
  80. disk_path = os.path.join(config.UPLOAD_DIR, unique_filename)
  81. db_file_path = f"uploads/{unique_filename}"
  82. with open(disk_path, "wb") as buffer:
  83. shutil.copyfileobj(file.file, buffer)
  84. query = "INSERT INTO order_photos (order_id, file_path, is_public, caption) VALUES (%s, %s, %s, %s)"
  85. photo_id = db.execute_commit(query, (None, db_file_path, is_public, caption))
  86. await audit_service.log(
  87. user_id=admin['id'],
  88. action="upload_portfolio_photo",
  89. target_type="photo",
  90. target_id=photo_id,
  91. details={"is_public": is_public, "caption": caption}
  92. )
  93. return {"id": photo_id, "file_path": db_file_path, "is_public": is_public, "caption": caption}
  94. @router.patch("/admin/photos/{photo_id}")
  95. async def admin_update_photo_status(photo_id: int, data: schemas.PhotoUpdate, admin: dict = Depends(require_admin)):
  96. query = "SELECT p.*, o.allow_portfolio FROM order_photos p LEFT JOIN orders o ON p.order_id = o.id WHERE p.id = %s"
  97. photo_data = db.execute_query(query, (photo_id,))
  98. if not photo_data: raise HTTPException(status_code=404, detail="Photo not found")
  99. # Check consent if linked to order
  100. if data.is_public and photo_data[0]['order_id'] is not None and not photo_data[0]['allow_portfolio']:
  101. raise HTTPException(status_code=400, detail="Cannot make public: User did not consent to portfolio usage")
  102. db.execute_commit("UPDATE order_photos SET is_public = %s, caption = %s WHERE id = %s", (data.is_public, data.caption, photo_id))
  103. await audit_service.log(
  104. user_id=admin['id'],
  105. action="update_photo_visibility",
  106. target_type="photo",
  107. target_id=photo_id,
  108. details={"is_public": data.is_public, "caption": data.caption}
  109. )
  110. # NOTIFY USER VIA WEBSOCKET if linked to order
  111. order_id = photo_data[0]['order_id']
  112. if order_id:
  113. order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
  114. if order_info:
  115. await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
  116. return {"id": photo_id, "is_public": data.is_public, "caption": data.caption}
  117. @router.delete("/admin/photos/{photo_id}")
  118. async def admin_delete_photo(photo_id: int, admin: dict = Depends(require_admin)):
  119. photo = db.execute_query("SELECT file_path, order_id FROM order_photos WHERE id = %s", (photo_id,))
  120. if not photo:
  121. raise HTTPException(status_code=404, detail="Photo not found")
  122. order_id = photo[0]['order_id']
  123. try:
  124. path = os.path.join(config.BASE_DIR, photo[0]['file_path'])
  125. if os.path.exists(path):
  126. os.remove(path)
  127. except Exception as e:
  128. print(f"Error deleting photo file: {e}")
  129. db.execute_commit("DELETE FROM order_photos WHERE id = %s", (photo_id,))
  130. await audit_service.log(
  131. user_id=admin['id'],
  132. action="delete_photo",
  133. target_type="photo",
  134. target_id=photo_id,
  135. details={"order_id": order_id}
  136. )
  137. # NOTIFY USER VIA WEBSOCKET
  138. order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
  139. if order_info:
  140. await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
  141. return {"id": photo_id, "status": "deleted"}
  142. # --- 3D Scan Portfolio Endpoints ---
  143. @router.get("/scans")
  144. async def get_public_scans():
  145. query = """
  146. SELECT id, title_en, title_ru, title_me, title_ua,
  147. description_en, description_ru, description_me, description_ua,
  148. folder_name, frames_count, created_at
  149. FROM portfolio_scans
  150. WHERE is_public = TRUE
  151. ORDER BY created_at DESC
  152. """
  153. return db.execute_query(query)
  154. @router.get("/admin/scans")
  155. async def admin_get_all_scans(admin: dict = Depends(require_admin)):
  156. query = """
  157. SELECT id, title_en, title_ru, title_me, title_ua,
  158. description_en, description_ru, description_me, description_ua,
  159. folder_name, frames_count, is_public, created_at
  160. FROM portfolio_scans
  161. ORDER BY created_at DESC
  162. """
  163. return db.execute_query(query)
  164. @router.post("/admin/scans/upload-bundle")
  165. async def admin_upload_scan_bundle(
  166. file: UploadFile = File(...),
  167. admin: dict = Depends(require_admin)
  168. ):
  169. if not file.filename or not file.filename.endswith(".zip"):
  170. raise HTTPException(status_code=400, detail="Invalid file: must be a ZIP archive")
  171. scan_id = str(uuid.uuid4())
  172. scans_dir = os.path.join(config.UPLOAD_DIR, "scans")
  173. os.makedirs(scans_dir, exist_ok=True)
  174. dest_dir = os.path.join(scans_dir, f"scan_{scan_id}")
  175. os.makedirs(dest_dir, exist_ok=True)
  176. temp_zip_path = os.path.join(dest_dir, "temp_bundle.zip")
  177. with open(temp_zip_path, "wb") as buffer:
  178. shutil.copyfileobj(file.file, buffer)
  179. try:
  180. # Extract files
  181. with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref:
  182. zip_ref.extractall(dest_dir)
  183. # Clean up zip
  184. if os.path.exists(temp_zip_path):
  185. os.remove(temp_zip_path)
  186. # Validate metadata.json
  187. metadata_path = os.path.join(dest_dir, "metadata.json")
  188. if not os.path.exists(metadata_path):
  189. raise HTTPException(status_code=400, detail="Invalid bundle: metadata.json is missing")
  190. with open(metadata_path, "r", encoding="utf-8") as f:
  191. metadata = json.load(f)
  192. title_en = metadata.get("title_en")
  193. if not title_en:
  194. raise HTTPException(status_code=400, detail="Invalid bundle: title_en is required")
  195. title_ru = metadata.get("title_ru", "") or title_en
  196. title_me = metadata.get("title_me", "") or title_en
  197. title_ua = metadata.get("title_ua", "") or title_en
  198. description_en = metadata.get("description_en", "")
  199. description_ru = metadata.get("description_ru", "") or description_en
  200. description_me = metadata.get("description_me", "") or description_en
  201. description_ua = metadata.get("description_ua", "") or description_en
  202. frames_count = int(metadata.get("frames_count", 48))
  203. # Verify frame WebP files
  204. for i in range(frames_count):
  205. frame_path = os.path.join(dest_dir, f"{i:03d}.webp")
  206. if not os.path.exists(frame_path):
  207. raise HTTPException(status_code=400, detail=f"Invalid bundle: frame {i:03d}.webp is missing")
  208. # Verify thumbnail
  209. thumbnail_path = os.path.join(dest_dir, "thumbnail.webp")
  210. if not os.path.exists(thumbnail_path):
  211. raise HTTPException(status_code=400, detail="Invalid bundle: thumbnail.webp is missing")
  212. # Insert DB record
  213. query = """
  214. INSERT INTO portfolio_scans (
  215. title_en, title_ru, title_me, title_ua,
  216. description_en, description_ru, description_me, description_ua,
  217. folder_name, frames_count, is_public
  218. ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  219. """
  220. db_id = db.execute_commit(query, (
  221. title_en, title_ru, title_me, title_ua,
  222. description_en, description_ru, description_me, description_ua,
  223. f"scan_{scan_id}", frames_count, 1
  224. ))
  225. await audit_service.log(
  226. user_id=admin['id'],
  227. action="upload_scan_bundle",
  228. target_type="scan",
  229. target_id=db_id,
  230. details={"title_en": title_en, "folder_name": f"scan_{scan_id}"}
  231. )
  232. return {
  233. "id": db_id,
  234. "title_en": title_en,
  235. "folder_name": f"scan_{scan_id}",
  236. "frames_count": frames_count
  237. }
  238. except Exception as e:
  239. if os.path.exists(dest_dir):
  240. shutil.rmtree(dest_dir)
  241. if isinstance(e, HTTPException):
  242. raise e
  243. raise HTTPException(status_code=500, detail=f"Error processing bundle: {str(e)}")
  244. @router.patch("/admin/scans/{scan_id}")
  245. async def admin_update_scan_status(
  246. scan_id: int,
  247. data: ScanUpdate,
  248. admin: dict = Depends(require_admin)
  249. ):
  250. scan = db.execute_query("SELECT id FROM portfolio_scans WHERE id = %s", (scan_id,))
  251. if not scan:
  252. raise HTTPException(status_code=404, detail="Scan not found")
  253. db.execute_commit("UPDATE portfolio_scans SET is_public = %s WHERE id = %s", (data.is_public, scan_id))
  254. await audit_service.log(
  255. user_id=admin['id'],
  256. action="update_scan_visibility",
  257. target_type="scan",
  258. target_id=scan_id,
  259. details={"is_public": data.is_public}
  260. )
  261. return {"id": scan_id, "is_public": data.is_public}
  262. @router.delete("/admin/scans/{scan_id}")
  263. async def admin_delete_scan(scan_id: int, admin: dict = Depends(require_admin)):
  264. scan = db.execute_query("SELECT folder_name, title_en FROM portfolio_scans WHERE id = %s", (scan_id,))
  265. if not scan:
  266. raise HTTPException(status_code=404, detail="Scan not found")
  267. folder_name = scan[0]['folder_name']
  268. title_en = scan[0]['title_en']
  269. dest_dir = os.path.join(config.UPLOAD_DIR, "scans", folder_name)
  270. try:
  271. if os.path.exists(dest_dir):
  272. shutil.rmtree(dest_dir)
  273. except Exception as e:
  274. print(f"Error removing scan directory {folder_name}: {e}")
  275. db.execute_commit("DELETE FROM portfolio_scans WHERE id = %s", (scan_id,))
  276. await audit_service.log(
  277. user_id=admin['id'],
  278. action="delete_scan_bundle",
  279. target_type="scan",
  280. target_id=scan_id,
  281. details={"title_en": title_en, "folder_name": folder_name}
  282. )
  283. return {"id": scan_id, "status": "deleted"}