| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360 |
- import db
- import schemas
- import auth_utils
- import notifications
- import config
- from typing import List, Optional
- import json
- import os
- import uuid
- import shutil
- import hashlib
- import preview_utils
- import slicer_utils
- from fastapi import APIRouter, Request, Form, Depends, HTTPException, BackgroundTasks, UploadFile, File
- from services import pricing, order_processing, event_hooks
- from services.audit_service import audit_service
- router = APIRouter(prefix="/orders", tags=["orders"])
- @router.post("")
- async def create_order(
- request: Request,
- background_tasks: BackgroundTasks,
- first_name: str = Form(...),
- last_name: str = Form(...),
- phone: str = Form(...),
- email: str = Form(...),
- shipping_address: str = Form(...),
- model_link: Optional[str] = Form(None),
- allow_portfolio: bool = Form(False),
- notes: Optional[str] = Form(None),
- material_id: int = Form(...),
- file_ids: str = Form("[]"),
- file_quantities: str = Form("[]"),
- quantity: int = Form(1),
- color_name: Optional[str] = Form(None),
- is_company: bool = Form(False),
- company_name: Optional[str] = Form(None),
- company_pib: Optional[str] = Form(None),
- company_address: Optional[str] = Form(None),
- token: str = Depends(auth_utils.oauth2_scheme_optional)
- ):
- user_id = None
- if token:
- payload = auth_utils.decode_token(token)
- if payload:
- user_id = payload.get("id")
- parsed_ids = []
- parsed_quantities = []
- if file_ids:
- try:
- parsed_ids = json.loads(file_ids)
- parsed_quantities = json.loads(file_quantities)
- except:
- pass
- lang = request.query_params.get("lang", "en")
- name_col = f"name_{lang}" if lang in ["en", "ru", "me"] else "name_en"
-
- mat_info = db.execute_query(f"SELECT {name_col}, price_per_cm3 FROM materials WHERE id = %s", (material_id,))
- mat_name = mat_info[0][name_col] if mat_info else "Unknown"
- mat_price = mat_info[0]['price_per_cm3'] if mat_info else 0.0
- file_sizes = []
- if parsed_ids:
- format_strings = ','.join(['%s'] * len(parsed_ids))
- file_rows = db.execute_query(f"SELECT file_size FROM order_files WHERE id IN ({format_strings})", tuple(parsed_ids))
- file_sizes = [r['file_size'] for r in file_rows]
- estimated_price, item_prices = pricing.calculate_estimated_price(material_id, file_sizes, parsed_quantities if parsed_quantities else None, return_details=True)
- # Snapshoting initial parameters
- original_params = json.dumps({
- "material_name": mat_name,
- "material_price": float(mat_price) if mat_price is not None else 0.0,
- "estimated_price": float(estimated_price) if estimated_price is not None else 0.0,
- "quantity": quantity,
- "color_name": color_name,
- "notes": notes,
- "first_name": first_name,
- "last_name": last_name,
- "phone": phone,
- "email": email,
- "shipping_address": shipping_address,
- "model_link": model_link,
- "is_company": is_company,
- "company_name": company_name,
- "company_pib": company_pib,
- "company_address": company_address
- })
- order_query = """
- 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, color_name, quantity, notes, original_params)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
- """
- 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, color_name, quantity, notes, original_params)
-
- try:
- order_insert_id = db.execute_commit(order_query, order_params)
- if parsed_ids:
- for idx, f_id in enumerate(parsed_ids):
- qty = parsed_quantities[idx] if idx < len(parsed_quantities) else 1
- unit_p = item_prices[idx] if idx < len(item_prices) else 0.0
- 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))
- background_tasks.add_task(order_processing.process_order_slicing, order_insert_id)
- background_tasks.add_task(event_hooks.on_order_created, order_insert_id)
- return {"status": "success", "order_id": order_insert_id, "message": "Order submitted successfully"}
- except Exception as e:
- print(f"Error creating order: {e}")
- raise HTTPException(status_code=500, detail="Internal server error occurred while processing order")
- @router.get("/my")
- async def get_my_orders(token: str = Depends(auth_utils.oauth2_scheme)):
- payload = auth_utils.decode_token(token)
- if not payload:
- raise HTTPException(status_code=401, detail="Invalid token")
-
- user_id = payload.get("id")
- query = """
- SELECT o.*,
- (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,
- 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
- FROM orders o
- LEFT JOIN order_files f ON o.id = f.order_id
- WHERE o.user_id = %s
- GROUP BY o.id
- ORDER BY o.created_at DESC
- """
- results = db.execute_query(query, (user_id,))
- for row in results:
- if row['files']:
- try: row['files'] = json.loads(f"[{row['files']}]")
- except: row['files'] = []
- else: row['files'] = []
- return results
- @router.post("/estimate")
- async def get_price_estimate(data: schemas.EstimateRequest):
- material = db.execute_query("SELECT price_per_cm3 FROM materials WHERE id = %s", (data.material_id,))
- price_per_cm3 = float(material[0]['price_per_cm3']) if material else 0.0
-
- file_prices = []
- base_fee = 5.0
- for size in data.file_sizes:
- total_size_mb = size / (1024 * 1024)
- estimated_volume = total_size_mb * 8.0
- file_cost = base_fee + (estimated_volume * price_per_cm3)
- file_prices.append(round(file_cost, 2))
-
- qts = data.file_quantities if data.file_quantities else [1]*len(data.file_sizes)
- return {"file_prices": file_prices, "total_estimate": round(sum(p * q for p, q in zip(file_prices, qts)), 2)}
- # --- ADMIN ORDER ENDPOINTS ---
- @router.get("/admin/list") # Using /admin/list to avoid conflict with /my
- async def get_admin_orders(token: str = Depends(auth_utils.oauth2_scheme)):
- payload = auth_utils.decode_token(token)
- if not payload or payload.get("role") != 'admin':
- raise HTTPException(status_code=403, detail="Admin role required")
-
- query = """
- SELECT o.*, u.can_chat,
- (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,
- 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
- FROM orders o
- LEFT JOIN users u ON o.user_id = u.id
- LEFT JOIN order_files f ON o.id = f.order_id
- GROUP BY o.id
- ORDER BY o.created_at DESC
- """
- results = db.execute_query(query)
- import session_utils
- for row in results:
- row['is_online'] = session_utils.is_user_online(row['user_id']) if row.get('user_id') else False
- if row['files']:
- try: row['files'] = json.loads(f"[{row['files']}]")
- except: row['files'] = []
- else: row['files'] = []
- photos = db.execute_query("SELECT id, file_path, is_public FROM order_photos WHERE order_id = %s", (row['id'],))
- row['photos'] = photos
- return results
- @router.patch("/{order_id}/admin")
- async def update_order_admin(
- order_id: int,
- data: schemas.AdminOrderUpdate,
- background_tasks: BackgroundTasks,
- request: Request,
- token: str = Depends(auth_utils.oauth2_scheme)
- ):
- payload = auth_utils.decode_token(token)
- if not payload or payload.get("role") != 'admin':
- raise HTTPException(status_code=403, detail="Admin role required")
-
- update_fields = []
- params = []
- if data.status:
- order_info = db.execute_query("SELECT * FROM orders WHERE id = %s", (order_id,))
- if order_info:
- background_tasks.add_task(
- event_hooks.on_order_status_changed,
- order_id,
- data.status,
- order_info[0],
- data.send_notification
- )
-
- # Generate PDF Document on moving to "shipped" step
- if data.status == 'shipped' and not order_info[0].get('invoice_path'):
- from services.uplatnica_generator import generate_uplatnica, generate_predracun
- o = order_info[0]
- price = float(o['total_price'] if o.get('total_price') is not None else o.get('estimated_price', 0))
-
- try:
- if o.get('is_company'):
- # Fetch items for Predracun
- files = db.execute_query("SELECT filename as name, quantity, unit_price as price FROM order_files WHERE order_id = %s", (order_id,))
- pdf_path = generate_predracun(
- order_id,
- o.get('company_name'),
- o.get('company_pib'),
- o.get('company_address') or o.get('shipping_address'),
- price,
- files
- )
- else:
- payer_name = f"{o['first_name']} {o.get('last_name', '')}".strip()
- addr = o.get('shipping_address', '')
- pdf_path = generate_uplatnica(order_id, payer_name, addr, price)
-
- update_fields.append("invoice_path = %s")
- params.append(pdf_path)
- except Exception as e:
- print(f"Failed to generate invoice PDF: {e}")
- if data.status:
- update_fields.append("status = %s")
- params.append(data.status)
- if data.total_price is not None:
- update_fields.append("total_price = %s")
- params.append(data.total_price)
-
- if data.material_id is not None:
- update_fields.append("material_id = %s")
- params.append(data.material_id)
- # Also update snapshot names and prices from handbook
- mat_info = db.execute_query("SELECT name_en, price_per_cm3 FROM materials WHERE id = %s", (data.material_id,))
- if mat_info:
- update_fields.append("material_name = %s")
- params.append(mat_info[0]['name_en'])
- update_fields.append("material_price = %s")
- params.append(mat_info[0]['price_per_cm3'])
- elif data.material_name is not None:
- update_fields.append("material_name = %s")
- params.append(data.material_name)
-
- if data.color_name is not None:
- update_fields.append("color_name = %s")
- params.append(data.color_name)
-
- if data.quantity is not None:
- update_fields.append("quantity = %s")
- params.append(data.quantity)
-
- if update_fields:
- query = f"UPDATE orders SET {', '.join(update_fields)} WHERE id = %s"
- params.append(order_id)
- db.execute_commit(query, tuple(params))
-
- # LOG ACTION
- await audit_service.log(
- user_id=payload.get("id"),
- action="update_order",
- target_type="order",
- target_id=order_id,
- details={"updated_fields": {k.split(" = ")[0]: v for k, v in zip(update_fields, params)}},
- request=request
- )
- return {"id": order_id, "status": "updated"}
- @router.post("/{order_id}/attach-file")
- async def admin_attach_file(
- order_id: int,
- file: UploadFile = File(...),
- token: str = Depends(auth_utils.oauth2_scheme)
- ):
- payload = auth_utils.decode_token(token)
- if not payload or payload.get("role") != 'admin':
- raise HTTPException(status_code=403, detail="Admin role required")
-
- unique_filename = f"{uuid.uuid4()}{os.path.splitext(file.filename)[1]}"
- file_path = os.path.join(config.UPLOAD_DIR, unique_filename)
- db_file_path = f"uploads/{unique_filename}"
-
- sha256_hash = hashlib.sha256()
- with open(file_path, "wb") as buffer:
- while chunk := file.file.read(8192):
- sha256_hash.update(chunk)
- buffer.write(chunk)
-
- preview_path = None
- db_preview_path = None
- if file_path.lower().endswith(".stl"):
- preview_filename = f"{uuid.uuid4()}.png"
- preview_path = os.path.join(config.PREVIEW_DIR, preview_filename)
- db_preview_path = f"uploads/previews/{preview_filename}"
- preview_utils.generate_stl_preview(file_path, preview_path)
- filament_g = None
- print_time = None
- if file_path.lower().endswith(".stl"):
- result = slicer_utils.slice_model(file_path)
- if result and result.get('success'):
- filament_g = result.get('filament_g')
- print_time = result.get('print_time_str')
- 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)"
- 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))
-
- return {"file_id": f_id, "filename": file.filename, "preview_path": db_preview_path, "filament_g": filament_g, "print_time": print_time}
- @router.delete("/{order_id}/files/{file_id}")
- async def admin_delete_file(
- order_id: int,
- file_id: int,
- request: Request,
- token: str = Depends(auth_utils.oauth2_scheme)
- ):
- payload = auth_utils.decode_token(token)
- if not payload or payload.get("role") != 'admin':
- raise HTTPException(status_code=403, detail="Admin role required")
-
- file_record = db.execute_query("SELECT file_path, preview_path FROM order_files WHERE id = %s AND order_id = %s", (file_id, order_id))
- if not file_record:
- raise HTTPException(status_code=404, detail="File not found")
-
- base_dir = config.BASE_DIR
- try:
- if file_record[0]['file_path']:
- os.remove(os.path.join(base_dir, file_record[0]['file_path']))
- if file_record[0]['preview_path']:
- os.remove(os.path.join(base_dir, file_record[0]['preview_path']))
- except Exception as e:
- print(f"Error removing file from disk: {e}")
-
- db.execute_commit("DELETE FROM order_files WHERE id = %s AND order_id = %s", (file_id, order_id))
-
- # LOG ACTION
- await audit_service.log(
- user_id=payload.get("id"),
- action="delete_order_file",
- target_type="order",
- target_id=order_id,
- details={"file_id": file_id},
- request=request
- )
- return {"status": "success"}
|