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 from pydantic import BaseModel from datetime import datetime 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)) # Create corresponding fixed order item file_row = db.execute_query("SELECT filename FROM order_files WHERE id = %s", (f_id,)) fname = file_row[0]['filename'] if file_row else f"File #{f_id}" db.execute_commit( "INSERT INTO order_items (order_id, description, quantity, unit_price, total_price) VALUES (%s, %s, %s, %s, %s)", (order_insert_id, f"3D Print: {fname}", qty, unit_p, round(qty * unit_p, 2)) ) 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( page: int = 1, size: int = 10, 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") offset = (page - 1) * size # Get total count count_query = "SELECT COUNT(*) as total FROM orders WHERE user_id = %s" count_res = db.execute_query(count_query, (user_id,)) total = count_res[0]['total'] if count_res else 0 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(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), NULL)) 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 CASE WHEN status IN ('pending', 'processing', 'shipped') THEN 0 ELSE 1 END ASC, o.created_at DESC LIMIT %s OFFSET %s """ results = db.execute_query(query, (user_id, size, offset)) for row in results: if row['files']: try: row['files'] = json.loads(f"[{row['files']}]") except: row['files'] = [] else: row['files'] = [] row['items'] = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (row['id'],)) return {"orders": results, "total": total} @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( search: Optional[str] = None, status: Optional[str] = None, date_from: Optional[str] = None, date_to: Optional[str] = None, 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") where_clauses = [] params = [] if search: search_term = f"%{search}%" 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)") params.extend([search_term] * 7) if status and status != 'all': where_clauses.append("o.status = %s") params.append(status) if date_from: where_clauses.append("o.created_at >= %s") params.append(date_from) if date_to: where_clauses.append("o.created_at <= %s") params.append(date_to) where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" query = f""" 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(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), NULL)) 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 {where_sql} GROUP BY o.id ORDER BY o.created_at DESC """ results = db.execute_query(query, tuple(params)) 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'] = [] row['items'] = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (row['id'],)) 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 Payment Documents based on status transitions from services.invoice_service import InvoiceService o = order_info[0] price_val = data.total_price if data.total_price is not None else (o.get('total_price') or o.get('estimated_price') or 0) price = float(price_val) # 1. Proforma / Payment Slip (on 'processing' or any initial active state) if data.status in ['processing', 'shipped']: try: if o.get('is_company'): pdf_path = InvoiceService.generate_document(o, doc_type="predracun", override_price=price) else: payer_name = f"{o['first_name']} {o.get('last_name', '')}".strip() pdf_path = InvoiceService.generate_uplatnica(order_id, payer_name, o.get('shipping_address', ''), price) update_fields.append("proforma_path = %s") params.append(pdf_path) except Exception as e: print(f"Failed to generate proforma: {e}") # 2. Final Invoice (only on 'shipped') if data.status == 'shipped': try: pdf_path = InvoiceService.generate_document(o, doc_type="faktura", override_price=price) update_fields.append("invoice_path = %s") params.append(pdf_path) except Exception as e: print(f"Failed to generate final invoice: {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 data.fiscal_qr_url is not None: update_fields.append("fiscal_qr_url = %s") params.append(data.fiscal_qr_url) # Auto-set fiscalized_at if adding URL for the first time if order_info[0].get('fiscalized_at') is None: update_fields.append("fiscalized_at = %s") params.append(datetime.now()) if data.ikof is not None: update_fields.append("ikof = %s") params.append(data.ikof) if data.jikr is not None: update_fields.append("jikr = %s") params.append(data.jikr) 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"} class OrderItemSchema(BaseModel): description: str quantity: int unit_price: float @router.get("/{order_id}/items") async def get_order_items(order_id: int): items = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (order_id,)) return items @router.put("/{order_id}/items") async def update_order_items(order_id: int, items: List[OrderItemSchema], 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") db.execute_commit("DELETE FROM order_items WHERE order_id = %s", (order_id,)) total_order_price = 0 for item in items: tot_p = round(item.quantity * item.unit_price, 2) total_order_price += tot_p db.execute_commit( "INSERT INTO order_items (order_id, description, quantity, unit_price, total_price) VALUES (%s, %s, %s, %s, %s)", (order_id, item.description, item.quantity, item.unit_price, tot_p) ) # Sync main order total_price db.execute_commit("UPDATE orders SET total_price = %s WHERE id = %s", (total_order_price, order_id)) return {"status": "success", "total_price": total_order_price} @router.delete("/{order_id}/admin") async def delete_order_admin( order_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") # 1. Find all related files to delete from disk files = db.execute_query("SELECT file_path, preview_path FROM order_files WHERE order_id = %s", (order_id,)) photos = db.execute_query("SELECT file_path FROM order_photos WHERE order_id = %s", (order_id,)) base_dir = config.BASE_DIR # Delete order_files from disk for f in files: try: if f.get('file_path'): fpath = os.path.join(base_dir, f['file_path']) if os.path.exists(fpath): os.remove(fpath) if f.get('preview_path'): ppath = os.path.join(base_dir, f['preview_path']) if os.path.exists(ppath): os.remove(ppath) except Exception as e: print(f"Error deleting order file {f.get('file_path')}: {e}") # Delete photos from disk for p in photos: try: if p.get('file_path'): fpath = os.path.join(base_dir, p['file_path']) if os.path.exists(fpath): os.remove(fpath) except Exception as e: print(f"Error deleting order photo {p.get('file_path')}: {e}") # 2. Delete from DB tables (due to possible lack of CASCADE or to be explicit) try: db.execute_commit("DELETE FROM order_messages WHERE order_id = %s", (order_id,)) db.execute_commit("DELETE FROM order_items WHERE order_id = %s", (order_id,)) db.execute_commit("DELETE FROM order_files WHERE order_id = %s", (order_id,)) db.execute_commit("DELETE FROM order_photos WHERE order_id = %s", (order_id,)) db.execute_commit("DELETE FROM orders WHERE id = %s", (order_id,)) # LOG ACTION await audit_service.log( user_id=payload.get("id"), action="delete_order_entirely", target_type="order", target_id=order_id, details={"order_id": order_id}, request=request ) return {"status": "success", "message": f"Order {order_id} deleted entirely"} except Exception as e: print(f"Failed to delete order {order_id}: {e}") raise HTTPException(status_code=500, detail=str(e))