orders.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  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, order_processing, event_hooks
  16. from services.audit_service import audit_service
  17. router = APIRouter(prefix="/orders", tags=["orders"])
  18. @router.post("")
  19. async def create_order(
  20. request: Request,
  21. background_tasks: BackgroundTasks,
  22. first_name: str = Form(...),
  23. last_name: str = Form(...),
  24. phone: str = Form(...),
  25. email: str = Form(...),
  26. shipping_address: str = Form(...),
  27. model_link: Optional[str] = Form(None),
  28. allow_portfolio: bool = Form(False),
  29. notes: Optional[str] = Form(None),
  30. material_id: int = Form(...),
  31. file_ids: str = Form("[]"),
  32. file_quantities: str = Form("[]"),
  33. quantity: int = Form(1),
  34. color_name: Optional[str] = Form(None),
  35. token: str = Depends(auth_utils.oauth2_scheme_optional)
  36. ):
  37. user_id = None
  38. if token:
  39. payload = auth_utils.decode_token(token)
  40. if payload:
  41. user_id = payload.get("id")
  42. parsed_ids = []
  43. parsed_quantities = []
  44. if file_ids:
  45. try:
  46. parsed_ids = json.loads(file_ids)
  47. parsed_quantities = json.loads(file_quantities)
  48. except:
  49. pass
  50. lang = request.query_params.get("lang", "en")
  51. name_col = f"name_{lang}" if lang in ["en", "ru", "me"] else "name_en"
  52. mat_info = db.execute_query(f"SELECT {name_col}, price_per_cm3 FROM materials WHERE id = %s", (material_id,))
  53. mat_name = mat_info[0][name_col] if mat_info else "Unknown"
  54. mat_price = mat_info[0]['price_per_cm3'] if mat_info else 0.0
  55. file_sizes = []
  56. if parsed_ids:
  57. format_strings = ','.join(['%s'] * len(parsed_ids))
  58. file_rows = db.execute_query(f"SELECT file_size FROM order_files WHERE id IN ({format_strings})", tuple(parsed_ids))
  59. file_sizes = [r['file_size'] for r in file_rows]
  60. estimated_price = pricing.calculate_estimated_price(material_id, file_sizes, parsed_quantities if parsed_quantities else None)
  61. # Snapshoting initial parameters
  62. original_params = json.dumps({
  63. "material_name": mat_name,
  64. "material_price": float(mat_price) if mat_price is not None else 0.0,
  65. "estimated_price": float(estimated_price) if estimated_price is not None else 0.0,
  66. "quantity": quantity,
  67. "color_name": color_name,
  68. "notes": notes,
  69. "first_name": first_name,
  70. "last_name": last_name,
  71. "phone": phone,
  72. "email": email,
  73. "shipping_address": shipping_address,
  74. "model_link": model_link
  75. })
  76. order_query = """
  77. INSERT INTO orders (user_id, material_id, first_name, last_name, phone, email, shipping_address, model_link, status, allow_portfolio, estimated_price, material_name, material_price, color_name, quantity, notes, original_params)
  78. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s, %s, %s, %s, %s, %s, %s)
  79. """
  80. order_params = (user_id, material_id, first_name, last_name, phone, email, shipping_address, model_link, allow_portfolio, estimated_price, mat_name, mat_price, color_name, quantity, notes, original_params)
  81. try:
  82. order_insert_id = db.execute_commit(order_query, order_params)
  83. if parsed_ids:
  84. for idx, f_id in enumerate(parsed_ids):
  85. qty = parsed_quantities[idx] if idx < len(parsed_quantities) else 1
  86. db.execute_commit("UPDATE order_files SET order_id = %s, quantity = %s WHERE id = %s", (order_insert_id, qty, f_id))
  87. background_tasks.add_task(order_processing.process_order_slicing, order_insert_id)
  88. background_tasks.add_task(event_hooks.on_order_created, order_insert_id)
  89. return {"status": "success", "order_id": order_insert_id, "message": "Order submitted successfully"}
  90. except Exception as e:
  91. print(f"Error creating order: {e}")
  92. raise HTTPException(status_code=500, detail="Internal server error occurred while processing order")
  93. @router.get("/my")
  94. async def get_my_orders(token: str = Depends(auth_utils.oauth2_scheme)):
  95. payload = auth_utils.decode_token(token)
  96. if not payload:
  97. raise HTTPException(status_code=401, detail="Invalid token")
  98. user_id = payload.get("id")
  99. query = """
  100. SELECT o.*,
  101. (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,
  102. GROUP_CONCAT(JSON_OBJECT('file_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)) as files
  103. FROM orders o
  104. LEFT JOIN order_files f ON o.id = f.order_id
  105. WHERE o.user_id = %s
  106. GROUP BY o.id
  107. ORDER BY o.created_at DESC
  108. """
  109. results = db.execute_query(query, (user_id,))
  110. for row in results:
  111. if row['files']:
  112. try: row['files'] = json.loads(f"[{row['files']}]")
  113. except: row['files'] = []
  114. else: row['files'] = []
  115. return results
  116. @router.post("/estimate")
  117. async def get_price_estimate(data: schemas.EstimateRequest):
  118. material = db.execute_query("SELECT price_per_cm3 FROM materials WHERE id = %s", (data.material_id,))
  119. price_per_cm3 = float(material[0]['price_per_cm3']) if material else 0.0
  120. file_prices = []
  121. base_fee = 5.0
  122. for size in data.file_sizes:
  123. total_size_mb = size / (1024 * 1024)
  124. estimated_volume = total_size_mb * 8.0
  125. file_cost = base_fee + (estimated_volume * price_per_cm3)
  126. file_prices.append(round(file_cost, 2))
  127. qts = data.file_quantities if data.file_quantities else [1]*len(data.file_sizes)
  128. return {"file_prices": file_prices, "total_estimate": round(sum(p * q for p, q in zip(file_prices, qts)), 2)}
  129. # --- ADMIN ORDER ENDPOINTS ---
  130. @router.get("/admin/list") # Using /admin/list to avoid conflict with /my
  131. async def get_admin_orders(token: str = Depends(auth_utils.oauth2_scheme)):
  132. payload = auth_utils.decode_token(token)
  133. if not payload or payload.get("role") != 'admin':
  134. raise HTTPException(status_code=403, detail="Admin role required")
  135. query = """
  136. SELECT o.*, u.can_chat,
  137. (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,
  138. GROUP_CONCAT(JSON_OBJECT('file_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)) as files
  139. FROM orders o
  140. LEFT JOIN users u ON o.user_id = u.id
  141. LEFT JOIN order_files f ON o.id = f.order_id
  142. GROUP BY o.id
  143. ORDER BY o.created_at DESC
  144. """
  145. results = db.execute_query(query)
  146. import session_utils
  147. for row in results:
  148. row['is_online'] = session_utils.is_user_online(row['user_id']) if row.get('user_id') else False
  149. if row['files']:
  150. try: row['files'] = json.loads(f"[{row['files']}]")
  151. except: row['files'] = []
  152. else: row['files'] = []
  153. photos = db.execute_query("SELECT id, file_path, is_public FROM order_photos WHERE order_id = %s", (row['id'],))
  154. row['photos'] = photos
  155. return results
  156. @router.patch("/{order_id}/admin")
  157. async def update_order_admin(
  158. order_id: int,
  159. data: schemas.AdminOrderUpdate,
  160. background_tasks: BackgroundTasks,
  161. request: Request,
  162. token: str = Depends(auth_utils.oauth2_scheme)
  163. ):
  164. payload = auth_utils.decode_token(token)
  165. if not payload or payload.get("role") != 'admin':
  166. raise HTTPException(status_code=403, detail="Admin role required")
  167. update_fields = []
  168. params = []
  169. if data.status:
  170. order_info = db.execute_query("SELECT * FROM orders WHERE id = %s", (order_id,))
  171. if order_info:
  172. background_tasks.add_task(
  173. event_hooks.on_order_status_changed,
  174. order_id,
  175. data.status,
  176. order_info[0],
  177. data.send_notification
  178. )
  179. # Generate Uplatnica PDF Document on moving to "shipped" step
  180. if data.status == 'shipped' and not order_info[0].get('invoice_path'):
  181. from services.uplatnica_generator import generate_uplatnica
  182. o = order_info[0]
  183. payer_name = f"{o['first_name']} {o.get('last_name', '')}".strip()
  184. addr = o.get('shipping_address', '')
  185. price = float(o['total_price'] if o.get('total_price') is not None else o.get('estimated_price', 0))
  186. try:
  187. pdf_path = generate_uplatnica(order_id, payer_name, addr, price)
  188. update_fields.append("invoice_path = %s")
  189. params.append(pdf_path)
  190. except Exception as e:
  191. print(f"Failed to generate invoice PDF: {e}")
  192. if data.status:
  193. update_fields.append("status = %s")
  194. params.append(data.status)
  195. if data.total_price is not None:
  196. update_fields.append("total_price = %s")
  197. params.append(data.total_price)
  198. if data.material_id is not None:
  199. update_fields.append("material_id = %s")
  200. params.append(data.material_id)
  201. # Also update snapshot names and prices from handbook
  202. mat_info = db.execute_query("SELECT name_en, price_per_cm3 FROM materials WHERE id = %s", (data.material_id,))
  203. if mat_info:
  204. update_fields.append("material_name = %s")
  205. params.append(mat_info[0]['name_en'])
  206. update_fields.append("material_price = %s")
  207. params.append(mat_info[0]['price_per_cm3'])
  208. elif data.material_name is not None:
  209. update_fields.append("material_name = %s")
  210. params.append(data.material_name)
  211. if data.color_name is not None:
  212. update_fields.append("color_name = %s")
  213. params.append(data.color_name)
  214. if data.quantity is not None:
  215. update_fields.append("quantity = %s")
  216. params.append(data.quantity)
  217. if update_fields:
  218. query = f"UPDATE orders SET {', '.join(update_fields)} WHERE id = %s"
  219. params.append(order_id)
  220. db.execute_commit(query, tuple(params))
  221. # LOG ACTION
  222. await audit_service.log(
  223. user_id=payload.get("id"),
  224. action="update_order",
  225. target_type="order",
  226. target_id=order_id,
  227. details={"updated_fields": {k.split(" = ")[0]: v for k, v in zip(update_fields, params)}},
  228. request=request
  229. )
  230. return {"id": order_id, "status": "updated"}
  231. @router.post("/{order_id}/attach-file")
  232. async def admin_attach_file(
  233. order_id: int,
  234. file: UploadFile = File(...),
  235. token: str = Depends(auth_utils.oauth2_scheme)
  236. ):
  237. payload = auth_utils.decode_token(token)
  238. if not payload or payload.get("role") != 'admin':
  239. raise HTTPException(status_code=403, detail="Admin role required")
  240. unique_filename = f"{uuid.uuid4()}{os.path.splitext(file.filename)[1]}"
  241. file_path = os.path.join(config.UPLOAD_DIR, unique_filename)
  242. db_file_path = f"uploads/{unique_filename}"
  243. sha256_hash = hashlib.sha256()
  244. with open(file_path, "wb") as buffer:
  245. while chunk := file.file.read(8192):
  246. sha256_hash.update(chunk)
  247. buffer.write(chunk)
  248. preview_path = None
  249. db_preview_path = None
  250. if file_path.lower().endswith(".stl"):
  251. preview_filename = f"{uuid.uuid4()}.png"
  252. preview_path = os.path.join(config.PREVIEW_DIR, preview_filename)
  253. db_preview_path = f"uploads/previews/{preview_filename}"
  254. preview_utils.generate_stl_preview(file_path, preview_path)
  255. filament_g = None
  256. print_time = None
  257. if file_path.lower().endswith(".stl"):
  258. result = slicer_utils.slice_model(file_path)
  259. if result and result.get('success'):
  260. filament_g = result.get('filament_g')
  261. print_time = result.get('print_time_str')
  262. 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, %s, %s, %s)"
  263. f_id = db.execute_commit(query, (order_id, file.filename, db_file_path, file.size, sha256_hash.hexdigest(), print_time, filament_g, db_preview_path))
  264. return {"file_id": f_id, "filename": file.filename, "preview_path": db_preview_path, "filament_g": filament_g, "print_time": print_time}
  265. @router.delete("/{order_id}/files/{file_id}")
  266. async def admin_delete_file(
  267. order_id: int,
  268. file_id: int,
  269. request: Request,
  270. token: str = Depends(auth_utils.oauth2_scheme)
  271. ):
  272. payload = auth_utils.decode_token(token)
  273. if not payload or payload.get("role") != 'admin':
  274. raise HTTPException(status_code=403, detail="Admin role required")
  275. file_record = db.execute_query("SELECT file_path, preview_path FROM order_files WHERE id = %s AND order_id = %s", (file_id, order_id))
  276. if not file_record:
  277. raise HTTPException(status_code=404, detail="File not found")
  278. base_dir = config.BASE_DIR
  279. try:
  280. if file_record[0]['file_path']:
  281. os.remove(os.path.join(base_dir, file_record[0]['file_path']))
  282. if file_record[0]['preview_path']:
  283. os.remove(os.path.join(base_dir, file_record[0]['preview_path']))
  284. except Exception as e:
  285. print(f"Error removing file from disk: {e}")
  286. db.execute_commit("DELETE FROM order_files WHERE id = %s AND order_id = %s", (file_id, order_id))
  287. # LOG ACTION
  288. await audit_service.log(
  289. user_id=payload.get("id"),
  290. action="delete_order_file",
  291. target_type="order",
  292. target_id=order_id,
  293. details={"file_id": file_id},
  294. request=request
  295. )
  296. return {"status": "success"}