orders.py 12 KB

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