orders.py 13 KB

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