orders.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
  1. import db
  2. import schemas
  3. import auth_utils
  4. import notifications
  5. import config
  6. from typing import List, Optional
  7. import json
  8. import os
  9. import uuid
  10. import shutil
  11. import hashlib
  12. import preview_utils
  13. import slicer_utils
  14. from fastapi import APIRouter, Request, Form, Depends, HTTPException, BackgroundTasks, UploadFile, File
  15. from services import pricing
  16. from services import order_processing
  17. from services import event_hooks
  18. from services.audit_service import audit_service
  19. from services.rate_limit_service import rate_limit_service
  20. from dependencies import get_current_user, require_admin, get_current_user_optional
  21. from pydantic import BaseModel
  22. from datetime import datetime
  23. import locales
  24. from services.global_manager import global_manager
  25. router = APIRouter(prefix="/orders", tags=["orders"])
  26. @router.post("")
  27. async def create_order(
  28. request: Request,
  29. background_tasks: BackgroundTasks,
  30. first_name: str = Form(...),
  31. last_name: str = Form(...),
  32. phone: str = Form(...),
  33. email: str = Form(...),
  34. shipping_address: str = Form(...),
  35. model_link: Optional[str] = Form(None),
  36. allow_portfolio: bool = Form(False),
  37. notes: Optional[str] = Form(None),
  38. material_id: int = Form(...),
  39. file_ids: str = Form("[]"),
  40. file_quantities: str = Form("[]"),
  41. quantity: int = Form(1),
  42. color_name: Optional[str] = Form(None),
  43. is_company: bool = Form(False),
  44. company_name: Optional[str] = Form(None),
  45. company_pib: Optional[str] = Form(None),
  46. company_address: Optional[str] = Form(None),
  47. user: Optional[dict] = Depends(get_current_user_optional)
  48. ):
  49. ip = request.client.host if request.client else "unknown"
  50. email_addr = email.lower()
  51. lang = request.query_params.get("lang", "en")
  52. is_admin = user.get("role") == "admin" if user else False
  53. if not is_admin and rate_limit_service.is_order_flooding(email_addr, ip):
  54. raise HTTPException(
  55. status_code=429,
  56. detail=locales.translate_error("flood_control", lang)
  57. )
  58. user_id = user.get("id") if user else None
  59. parsed_ids = []
  60. parsed_quantities = []
  61. if file_ids:
  62. try:
  63. parsed_ids = json.loads(file_ids)
  64. parsed_quantities = json.loads(file_quantities)
  65. except:
  66. pass
  67. name_col = f"name_{lang}" if lang in ["en", "ru", "me"] else "name_en"
  68. # Fetch material info including technology-specific hourly price if available
  69. mat_query = """
  70. SELECT m.name_en, m.name_ru, m.name_ua, m.name_me, m.price_per_cm3,
  71. COALESCE(NULLIF(m.price_per_hour, 0), s.price_per_hour, %s) as price_per_hour,
  72. m.speed_coeff
  73. FROM materials m
  74. LEFT JOIN services s ON m.service_id = s.id
  75. WHERE m.id = %s
  76. """
  77. mat_info = db.execute_query(mat_query, (config.DEFAULT_PRINTER_HOUR_PRICE, material_id))
  78. mat_name = mat_info[0][name_col] if mat_info else "Unknown"
  79. mat_price = mat_info[0]['price_per_cm3'] if mat_info else 0.0
  80. hour_price = mat_info[0]['price_per_hour'] if mat_info else config.DEFAULT_PRINTER_HOUR_PRICE
  81. speed_coeff = mat_info[0]['speed_coeff'] if mat_info else 1.0
  82. file_sizes = []
  83. if parsed_ids:
  84. format_strings = ','.join(['%s'] * len(parsed_ids))
  85. file_rows = db.execute_query(f"SELECT file_size FROM order_files WHERE id IN ({format_strings})", tuple(parsed_ids))
  86. file_sizes = [r['file_size'] for r in file_rows]
  87. estimated_price, item_prices, pricing_metadata = pricing.calculate_estimated_price(material_id, file_sizes, parsed_quantities if parsed_quantities else None, return_details=True)
  88. # Snapshoting initial parameters
  89. original_params = json.dumps({
  90. "material_name": mat_name,
  91. "material_price": float(mat_price) if mat_price is not None else 0.0,
  92. "printer_hour_price": float(pricing_metadata['price_per_hour']),
  93. "speed_coeff": float(pricing_metadata['speed_coeff']),
  94. "base_fee": float(pricing_metadata['base_fee']),
  95. "discount_percent": float(pricing_metadata['discount_percent']),
  96. "estimated_price": float(estimated_price) if estimated_price is not None else 0.0,
  97. "quantity": quantity,
  98. "color_name": color_name,
  99. "notes": notes,
  100. "first_name": first_name,
  101. "last_name": last_name,
  102. "phone": phone,
  103. "email": email,
  104. "shipping_address": shipping_address,
  105. "model_link": model_link,
  106. "is_company": is_company,
  107. "company_name": company_name,
  108. "company_pib": company_pib,
  109. "company_address": company_address
  110. })
  111. order_query = """
  112. INSERT INTO orders (user_id, material_id, first_name, last_name, phone, email, shipping_address, model_link, status, is_company, company_name, company_pib, company_address, allow_portfolio, estimated_price, material_name, material_price, printer_hour_price, speed_coeff, discount_percent, color_name, quantity, notes, original_params)
  113. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  114. """
  115. order_params = (user_id, material_id, first_name, last_name, phone, email, shipping_address, model_link, is_company, company_name, company_pib, company_address, allow_portfolio, estimated_price, mat_name, mat_price, hour_price, speed_coeff, pricing_metadata['discount_percent'], color_name, quantity, notes, original_params)
  116. try:
  117. order_insert_id = db.execute_commit(order_query, order_params)
  118. if parsed_ids:
  119. for idx, f_id in enumerate(parsed_ids):
  120. qty = parsed_quantities[idx] if idx < len(parsed_quantities) else 1
  121. unit_p = item_prices[idx] if idx < len(item_prices) else 0.0
  122. db.execute_commit("UPDATE order_files SET order_id = %s, quantity = %s, unit_price = %s WHERE id = %s", (order_insert_id, qty, unit_p, f_id))
  123. # Create corresponding fixed order item
  124. file_row = db.execute_query("SELECT filename FROM order_files WHERE id = %s", (f_id,))
  125. fname = file_row[0]['filename'] if file_row else f"File #{f_id}"
  126. db.execute_commit(
  127. "INSERT INTO order_items (order_id, description, quantity, unit_price, total_price) VALUES (%s, %s, %s, %s, %s)",
  128. (order_insert_id, f"3D Print: {fname}", qty, unit_p, round(qty * unit_p, 2))
  129. )
  130. background_tasks.add_task(order_processing.process_order_slicing, order_insert_id)
  131. background_tasks.add_task(event_hooks.on_order_created, order_insert_id)
  132. # LOG ACTION
  133. await audit_service.log(
  134. user_id=user_id,
  135. action="order_created",
  136. target_type="order",
  137. target_id=order_insert_id,
  138. details={
  139. "email": email_addr,
  140. "mat_id": material_id,
  141. "price": float(estimated_price) if estimated_price is not None else 0.0,
  142. "quantity": quantity
  143. }
  144. )
  145. # Record placement for rate limiting
  146. rate_limit_service.record_order_placement(email_addr, ip)
  147. return {"status": "success", "order_id": order_insert_id, "message": "Order submitted successfully"}
  148. except Exception as e:
  149. print(f"Error creating order: {e}")
  150. raise HTTPException(status_code=500, detail="Internal server error occurred while processing order")
  151. @router.get("/my")
  152. async def get_my_orders(
  153. page: int = 1,
  154. size: int = 10,
  155. user: dict = Depends(get_current_user)
  156. ):
  157. user_id = user.get("id")
  158. offset = (page - 1) * size
  159. # Get total count
  160. count_query = "SELECT COUNT(*) as total FROM orders WHERE user_id = %s"
  161. count_res = db.execute_query(count_query, (user_id,))
  162. total = count_res[0]['total'] if count_res else 0
  163. query = """
  164. SELECT o.*,
  165. (SELECT count(*) FROM order_messages om WHERE om.order_id = o.id AND om.is_from_admin = TRUE AND om.is_read = FALSE) as unread_count,
  166. GROUP_CONCAT(IF(f.id IS NOT NULL, JSON_OBJECT('id', f.id, 'filename', f.filename, 'file_path', f.file_path, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g, 'dimensions', f.dimensions), NULL)) as files
  167. FROM orders o
  168. LEFT JOIN order_files f ON o.id = f.order_id
  169. WHERE o.user_id = %s
  170. GROUP BY o.id
  171. ORDER BY
  172. CASE
  173. WHEN status IN ('pending', 'processing', 'shipped') THEN 0
  174. ELSE 1
  175. END ASC,
  176. o.created_at DESC
  177. LIMIT %s OFFSET %s
  178. """
  179. results = db.execute_query(query, (user_id, size, offset))
  180. for row in results:
  181. if row['files']:
  182. try: row['files'] = json.loads(f"[{row['files']}]")
  183. except: row['files'] = []
  184. else: row['files'] = []
  185. row['items'] = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (row['id'],))
  186. return {"orders": results, "total": total}
  187. @router.post("/{order_id}/review")
  188. async def post_order_review(order_id: int, review: schemas.OrderReview, user: dict = Depends(get_current_user)):
  189. # Check if order belongs to user and is in appropriate status
  190. order = db.execute_query("SELECT id, status FROM orders WHERE id = %s AND user_id = %s", (order_id, user['id']))
  191. if not order:
  192. raise HTTPException(status_code=404, detail="Order not found or access denied")
  193. if order[0]['status'] not in ['shipped', 'completed']:
  194. raise HTTPException(status_code=400, detail="Reviews can only be posted for shipped or completed orders")
  195. db.execute_commit(
  196. "UPDATE orders SET review_text = %s, rating = %s, review_approved = FALSE WHERE id = %s",
  197. (review.review_text, review.rating, order_id)
  198. )
  199. # Create audit log
  200. await audit_service.log(user['id'], "ORDER_REVIEW", f"Posted review for order {order_id}", order_id)
  201. return {"message": "Review submitted successfully and is awaiting moderation"}
  202. @router.patch("/{order_id}/review/approve")
  203. async def approve_order_review(order_id: int, admin: dict = Depends(require_admin)):
  204. db.execute_commit("UPDATE orders SET review_approved = TRUE WHERE id = %s", (order_id,))
  205. await audit_service.log(admin['id'], "ORDER_REVIEW_APPROVE", f"Approved review for order {order_id}", order_id)
  206. return {"message": "Review approved successfully"}
  207. @router.get("/reviews/public", response_model=List[schemas.PublicReview])
  208. async def get_public_reviews():
  209. # Only return approved reviews, anonymized (strictly only the first word of the first name)
  210. query = "SELECT SUBSTRING_INDEX(first_name, ' ', 1) as first_name, rating, review_text FROM orders WHERE review_approved = TRUE ORDER BY created_at DESC LIMIT 10"
  211. return db.execute_query(query)
  212. @router.post("/estimate")
  213. async def get_price_estimate(data: schemas.EstimateRequest):
  214. estimated_total, file_prices, metadata = pricing.calculate_estimated_price(
  215. data.material_id,
  216. data.file_sizes,
  217. data.file_quantities,
  218. return_details=True
  219. )
  220. qts = data.file_quantities if data.file_quantities else [1]*len(data.file_sizes)
  221. return {
  222. "file_prices": file_prices,
  223. "total_estimate": estimated_total,
  224. "metadata": metadata
  225. }
  226. # --- ADMIN ORDER ENDPOINTS ---
  227. @router.get("/admin/list") # Using /admin/list to avoid conflict with /my
  228. async def get_admin_orders(
  229. search: Optional[str] = None,
  230. status: Optional[str] = None,
  231. date_from: Optional[str] = None,
  232. date_to: Optional[str] = None,
  233. admin: dict = Depends(require_admin)
  234. ):
  235. where_clauses = []
  236. params = []
  237. if search:
  238. search_term = f"%{search}%"
  239. where_clauses.append("(o.id LIKE %s OR o.email LIKE %s OR o.first_name LIKE %s OR o.last_name LIKE %s OR o.company_name LIKE %s OR o.phone LIKE %s OR o.shipping_address LIKE %s)")
  240. params.extend([search_term] * 7)
  241. if status and status != 'all':
  242. where_clauses.append("o.status = %s")
  243. params.append(status)
  244. if date_from:
  245. where_clauses.append("o.created_at >= %s")
  246. params.append(date_from)
  247. if date_to:
  248. where_clauses.append("o.created_at <= %s")
  249. params.append(date_to)
  250. where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
  251. query = f"""
  252. SELECT o.*, u.can_chat, COALESCE(o.total_price, o.estimated_price) AS invoice_amount,
  253. (SELECT count(*) FROM order_messages om WHERE om.order_id = o.id AND om.is_from_admin = FALSE AND om.is_read = FALSE) as unread_count,
  254. GROUP_CONCAT(IF(f.id IS NOT NULL, JSON_OBJECT('id', f.id, 'filename', f.filename, 'file_path', f.file_path, 'file_size', f.file_size, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g, 'dimensions', f.dimensions), NULL)) as files
  255. FROM orders o
  256. LEFT JOIN users u ON o.user_id = u.id
  257. LEFT JOIN order_files f ON o.id = f.order_id
  258. {where_sql}
  259. GROUP BY o.id
  260. ORDER BY o.created_at DESC
  261. """
  262. results = db.execute_query(query, tuple(params))
  263. import session_utils
  264. for row in results:
  265. row['is_online'] = session_utils.is_user_online(row['user_id']) if row.get('user_id') else False
  266. if row['files']:
  267. try: row['files'] = json.loads(f"[{row['files']}]")
  268. except: row['files'] = []
  269. else: row['files'] = []
  270. row['items'] = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (row['id'],))
  271. photos = db.execute_query("SELECT id, file_path, is_public FROM order_photos WHERE order_id = %s", (row['id'],))
  272. row['photos'] = photos
  273. return results
  274. @router.patch("/{order_id}")
  275. async def update_order(
  276. order_id: int,
  277. data: schemas.AdminOrderUpdate,
  278. background_tasks: BackgroundTasks,
  279. admin: dict = Depends(require_admin)
  280. ):
  281. print(f"DEBUG: update_order {order_id} - payload: {data.model_dump(exclude_unset=True)}")
  282. order_info = db.execute_query("SELECT * FROM orders WHERE id = %s", (order_id,))
  283. if not order_info: raise HTTPException(status_code=404, detail="Order not found")
  284. update_fields = []
  285. params = []
  286. if data.status:
  287. if order_info:
  288. background_tasks.add_task(
  289. event_hooks.on_order_status_changed,
  290. order_id,
  291. data.status,
  292. order_info[0],
  293. data.send_notification
  294. )
  295. # Generate Payment Documents based on status transitions
  296. from services.invoice_service import InvoiceService
  297. o = order_info[0]
  298. price_val = data.total_price if data.total_price is not None else (o.get('total_price') or o.get('estimated_price') or 0)
  299. price = float(price_val)
  300. # 1. Proforma / Payment Slip (on 'processing' or any initial active state)
  301. if data.status in ['processing', 'shipped']:
  302. try:
  303. if o.get('is_company'):
  304. pdf_path = InvoiceService.generate_document(o, doc_type="predracun", override_price=price)
  305. else:
  306. payer_name = f"{o['first_name']} {o.get('last_name', '')}".strip()
  307. pdf_path = InvoiceService.generate_uplatnica(order_id, payer_name, o.get('shipping_address', ''), price)
  308. update_fields.append("proforma_path = %s")
  309. params.append(pdf_path)
  310. except Exception as e:
  311. print(f"Failed to generate proforma: {e}")
  312. # 2. Final Invoice (only on 'shipped')
  313. if data.status == 'shipped':
  314. try:
  315. pdf_path = InvoiceService.generate_document(o, doc_type="faktura", override_price=price)
  316. update_fields.append("invoice_path = %s")
  317. params.append(pdf_path)
  318. except Exception as e:
  319. print(f"Failed to generate final invoice: {e}")
  320. if data.status:
  321. update_fields.append("status = %s")
  322. params.append(data.status)
  323. if data.total_price is not None:
  324. update_fields.append("total_price = %s")
  325. params.append(data.total_price)
  326. # Sync estimated_price as well to ensure display consistency in all views
  327. update_fields.append("estimated_price = %s")
  328. params.append(data.total_price)
  329. if data.material_id is not None:
  330. update_fields.append("material_id = %s")
  331. params.append(data.material_id)
  332. # Also update snapshot names and prices from handbook
  333. mat_info = db.execute_query("SELECT name_en, price_per_cm3, price_per_hour FROM materials WHERE id = %s", (data.material_id,))
  334. if mat_info:
  335. update_fields.append("material_name = %s")
  336. params.append(mat_info[0]['name_en'])
  337. update_fields.append("material_price = %s")
  338. params.append(mat_info[0]['price_per_cm3'])
  339. update_fields.append("printer_hour_price = %s")
  340. params.append(mat_info[0]['price_per_hour'] if mat_info[0]['price_per_hour'] is not None else config.DEFAULT_PRINTER_HOUR_PRICE)
  341. elif data.material_name is not None:
  342. update_fields.append("material_name = %s")
  343. params.append(data.material_name)
  344. if data.color_name is not None:
  345. update_fields.append("color_name = %s")
  346. params.append(data.color_name)
  347. if data.quantity is not None:
  348. update_fields.append("quantity = %s")
  349. params.append(data.quantity)
  350. if data.fiscal_qr_url is not None:
  351. update_fields.append("fiscal_qr_url = %s")
  352. params.append(data.fiscal_qr_url)
  353. # Auto-set fiscalized_at if adding URL for the first time
  354. if order_info[0].get('fiscalized_at') is None:
  355. update_fields.append("fiscalized_at = %s")
  356. params.append(datetime.now())
  357. if data.ikof is not None:
  358. update_fields.append("ikof = %s")
  359. params.append(data.ikof)
  360. if data.jikr is not None:
  361. update_fields.append("jikr = %s")
  362. params.append(data.jikr)
  363. if data.first_name is not None:
  364. update_fields.append("first_name = %s")
  365. params.append(data.first_name)
  366. if data.last_name is not None:
  367. update_fields.append("last_name = %s")
  368. params.append(data.last_name)
  369. if data.email is not None:
  370. update_fields.append("email = %s")
  371. params.append(data.email)
  372. if data.phone is not None:
  373. update_fields.append("phone = %s")
  374. params.append(data.phone)
  375. if data.shipping_address is not None:
  376. update_fields.append("shipping_address = %s")
  377. params.append(data.shipping_address)
  378. if data.notes is not None:
  379. update_fields.append("notes = %s")
  380. params.append(data.notes)
  381. if data.review_text is not None:
  382. update_fields.append("review_text = %s")
  383. params.append(data.review_text)
  384. if data.rating is not None:
  385. update_fields.append("rating = %s")
  386. params.append(data.rating)
  387. if data.review_approved is not None:
  388. update_fields.append("review_approved = %s")
  389. params.append(data.review_approved)
  390. if update_fields:
  391. query = f"UPDATE orders SET {', '.join(update_fields)} WHERE id = %s"
  392. params.append(order_id)
  393. db.execute_commit(query, tuple(params))
  394. # LOG ACTION
  395. await audit_service.log(
  396. user_id=admin.get("id"),
  397. action="update_order",
  398. target_type="order",
  399. target_id=order_id,
  400. details={"updated_fields": {k.split(" = ")[0]: v for k, v in zip(update_fields, params)}}
  401. )
  402. # NOTIFY USER VIA WEBSOCKET
  403. await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
  404. return {"id": order_id, "status": "updated"}
  405. @router.post("/{order_id}/attach-file")
  406. async def admin_attach_file(
  407. order_id: int,
  408. background_tasks: BackgroundTasks,
  409. file: UploadFile = File(...),
  410. admin: dict = Depends(require_admin)
  411. ):
  412. unique_filename = f"{uuid.uuid4()}{os.path.splitext(file.filename)[1]}"
  413. file_path = os.path.join(config.UPLOAD_DIR, unique_filename)
  414. db_file_path = f"uploads/{unique_filename}"
  415. sha256_hash = hashlib.sha256()
  416. with open(file_path, "wb") as buffer:
  417. while chunk := file.file.read(8192):
  418. sha256_hash.update(chunk)
  419. buffer.write(chunk)
  420. query = "INSERT INTO order_files (order_id, filename, file_path, file_size, quantity, file_hash, print_time, filament_g, preview_path) VALUES (%s, %s, %s, %s, 1, %s, NULL, NULL, NULL)"
  421. f_id = db.execute_commit(query, (order_id, file.filename, db_file_path, file.size, sha256_hash.hexdigest()))
  422. # Run heavy processing in background to avoid worker timeouts
  423. background_tasks.add_task(order_processing.process_single_file_async, f_id)
  424. # NOTIFY USER VIA WEBSOCKET
  425. order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
  426. if order_info:
  427. await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
  428. return {"file_id": f_id, "filename": file.filename, "status": "processing"}
  429. @router.delete("/{order_id}/files/{file_id}")
  430. async def admin_delete_file(
  431. order_id: int,
  432. file_id: int,
  433. admin: dict = Depends(require_admin)
  434. ):
  435. file_record = db.execute_query("SELECT file_path, preview_path FROM order_files WHERE id = %s AND order_id = %s", (file_id, order_id))
  436. if not file_record:
  437. raise HTTPException(status_code=404, detail="File not found")
  438. base_dir = config.BASE_DIR
  439. try:
  440. if file_record[0]['file_path']:
  441. os.remove(os.path.join(base_dir, file_record[0]['file_path']))
  442. if file_record[0]['preview_path']:
  443. os.remove(os.path.join(base_dir, file_record[0]['preview_path']))
  444. except Exception as e:
  445. print(f"Error removing file from disk: {e}")
  446. db.execute_commit("DELETE FROM order_files WHERE id = %s AND order_id = %s", (file_id, order_id))
  447. # LOG ACTION
  448. await audit_service.log(
  449. user_id=admin.get("id"),
  450. action="delete_order_file",
  451. target_type="order",
  452. target_id=order_id,
  453. details={"file_id": file_id}
  454. )
  455. # NOTIFY USER VIA WEBSOCKET
  456. order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
  457. if order_info:
  458. await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
  459. return {"status": "success"}
  460. @router.patch("/{order_id}/files/{file_id}")
  461. async def admin_update_file(
  462. order_id: int,
  463. file_id: int,
  464. data: dict,
  465. admin: dict = Depends(require_admin)
  466. ):
  467. if "quantity" in data:
  468. db.execute_commit("UPDATE order_files SET quantity = %s WHERE id = %s AND order_id = %s", (data['quantity'], file_id, order_id))
  469. # Notify user/admin via WS
  470. order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
  471. if order_info:
  472. await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
  473. return {"status": "success"}
  474. class OrderItemSchema(BaseModel):
  475. description: str
  476. quantity: int
  477. unit_price: float
  478. @router.get("/{order_id}/items")
  479. async def get_order_items(order_id: int):
  480. items = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (order_id,))
  481. return items
  482. @router.put("/{order_id}/items")
  483. async def update_order_items(order_id: int, items: List[OrderItemSchema], admin: dict = Depends(require_admin)):
  484. db.execute_commit("DELETE FROM order_items WHERE order_id = %s", (order_id,))
  485. total_order_price = 0
  486. items_summary = []
  487. for item in items:
  488. tot_p = round(item.quantity * item.unit_price, 2)
  489. total_order_price += tot_p
  490. db.execute_commit(
  491. "INSERT INTO order_items (order_id, description, quantity, unit_price, total_price) VALUES (%s, %s, %s, %s, %s)",
  492. (order_id, item.description, item.quantity, item.unit_price, tot_p)
  493. )
  494. items_summary.append(item.dict())
  495. # Sync main order total_price
  496. db.execute_commit("UPDATE orders SET total_price = %s WHERE id = %s", (total_order_price, order_id))
  497. # LOG ACTION
  498. await audit_service.log(
  499. user_id=admin.get("id"),
  500. action="update_order_items",
  501. target_type="order",
  502. target_id=order_id,
  503. details={"total_price": total_order_price, "items": items_summary}
  504. )
  505. # NOTIFY USER VIA WEBSOCKET
  506. order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
  507. if order_info:
  508. await global_manager.notify_order_update(order_info[0]['user_id'], order_id)
  509. return {"status": "success", "total_price": total_order_price}
  510. @router.delete("/{order_id}/admin")
  511. async def delete_order_admin(
  512. order_id: int,
  513. admin: dict = Depends(require_admin)
  514. ):
  515. # Fetch user_id before deletion to notify later
  516. order_info = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
  517. if not order_info: raise HTTPException(status_code=404, detail="Order not found")
  518. customer_id = order_info[0]['user_id']
  519. # 1. Find all related files to delete from disk
  520. files = db.execute_query("SELECT file_path, preview_path FROM order_files WHERE order_id = %s", (order_id,))
  521. photos = db.execute_query("SELECT file_path FROM order_photos WHERE order_id = %s", (order_id,))
  522. base_dir = config.BASE_DIR
  523. # Delete order_files from disk
  524. for f in files:
  525. try:
  526. if f.get('file_path'):
  527. fpath = os.path.join(base_dir, f['file_path'])
  528. if os.path.exists(fpath): os.remove(fpath)
  529. if f.get('preview_path'):
  530. ppath = os.path.join(base_dir, f['preview_path'])
  531. if os.path.exists(ppath): os.remove(ppath)
  532. except Exception as e:
  533. print(f"Error deleting order file {f.get('file_path')}: {e}")
  534. # Delete photos from disk
  535. for p in photos:
  536. try:
  537. if p.get('file_path'):
  538. fpath = os.path.join(base_dir, p['file_path'])
  539. if os.path.exists(fpath): os.remove(fpath)
  540. except Exception as e:
  541. print(f"Error deleting order photo {p.get('file_path')}: {e}")
  542. # 2. Delete from DB tables (due to possible lack of CASCADE or to be explicit)
  543. try:
  544. db.execute_commit("DELETE FROM order_messages WHERE order_id = %s", (order_id,))
  545. db.execute_commit("DELETE FROM order_items WHERE order_id = %s", (order_id,))
  546. db.execute_commit("DELETE FROM order_files WHERE order_id = %s", (order_id,))
  547. db.execute_commit("DELETE FROM order_photos WHERE order_id = %s", (order_id,))
  548. db.execute_commit("DELETE FROM orders WHERE id = %s", (order_id,))
  549. # LOG ACTION
  550. await audit_service.log(
  551. user_id=admin.get("id"),
  552. action="delete_order_entirely",
  553. target_type="order",
  554. target_id=order_id,
  555. details={"order_id": order_id}
  556. )
  557. # NOTIFY USER VIA WEBSOCKET
  558. await global_manager.notify_order_update(customer_id, order_id)
  559. return {"status": "success", "message": f"Order {order_id} deleted entirely"}
  560. except Exception as e:
  561. print(f"Failed to delete order {order_id}: {e}")
  562. raise HTTPException(status_code=500, detail=str(e))