main.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import os
  2. import json
  3. # Fix matplotlib/gunicorn permission issues in server environment
  4. _temp_dir = os.path.join(os.getcwd(), 'temp')
  5. os.makedirs(_temp_dir, exist_ok=True)
  6. os.environ['MPLCONFIGDIR'] = _temp_dir
  7. os.environ['HOME'] = _temp_dir
  8. from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect, Query
  9. from fastapi.staticfiles import StaticFiles
  10. from fastapi.middleware.cors import CORSMiddleware
  11. from fastapi.exceptions import RequestValidationError
  12. from fastapi.responses import JSONResponse
  13. import traceback
  14. import os
  15. import auth_utils
  16. import session_utils
  17. from services.global_manager import global_manager
  18. from services.chat_manager import manager
  19. import locales
  20. import config
  21. import db
  22. from routers import auth, orders, catalog, portfolio, files, chat, blog, admin, contact, warehouse
  23. app = FastAPI(title="Radionica 3D API")
  24. @app.on_event("startup")
  25. async def startup_event():
  26. print("Application starting up...")
  27. try:
  28. import migrate_deleted
  29. migrate_deleted.migrate()
  30. except Exception as e:
  31. print(f"Error running migration: {e}")
  32. # Configure CORS
  33. origins = [
  34. "http://localhost:5173",
  35. "http://127.0.0.1:5173",
  36. "http://localhost:5000",
  37. "http://localhost:8001",
  38. "https://radionica3d.me",
  39. ]
  40. extra_origins = os.getenv("CORS_ORIGINS")
  41. if extra_origins:
  42. origins.extend(extra_origins.split(","))
  43. app.add_middleware(
  44. CORSMiddleware,
  45. allow_origins=origins,
  46. allow_credentials=True,
  47. allow_methods=["*"],
  48. allow_headers=["*"],
  49. )
  50. @app.exception_handler(RequestValidationError)
  51. async def validation_exception_handler(request: Request, exc: RequestValidationError):
  52. lang = request.query_params.get("lang", "en")
  53. errors = []
  54. for error in exc.errors():
  55. error_type = error.get("type", "unknown")
  56. ctx = error.get("ctx", {})
  57. translated_msg = locales.translate_error(error_type, lang, **ctx)
  58. loc = ".".join(str(l) for l in error.get("loc", [])[1:])
  59. errors.append({
  60. "loc": error.get("loc"),
  61. "msg": f"{loc}: {translated_msg}" if loc else translated_msg,
  62. "type": error_type
  63. })
  64. return JSONResponse(status_code=422, content={"detail": errors})
  65. @app.exception_handler(Exception)
  66. async def all_exception_handler(request: Request, exc: Exception):
  67. print(f"ERROR: {exc}")
  68. traceback.print_exc()
  69. if config.DEBUG:
  70. return JSONResponse(
  71. status_code=500,
  72. content={"detail": str(exc), "traceback": traceback.format_exc()}
  73. )
  74. return JSONResponse(status_code=500, content={"detail": "Internal server error"})
  75. # Add custom exception logging or other middleware here if needed
  76. # Include Routers
  77. app.include_router(auth.router)
  78. app.include_router(orders.router)
  79. app.include_router(catalog.router)
  80. app.include_router(portfolio.router)
  81. app.include_router(files.router)
  82. app.include_router(chat.router)
  83. app.include_router(blog.router)
  84. app.include_router(admin.router)
  85. app.include_router(contact.router)
  86. app.include_router(warehouse.router)
  87. # WebSocket Global Handler (Centralized to handle various proxy prefixes)
  88. @app.websocket("/global")
  89. async def ws_global(websocket: WebSocket, token: str = Query(...)):
  90. payload = auth_utils.decode_token(token)
  91. if not payload:
  92. print(f"DEBUG: WS Global Auth failed for token: {token[:10]}...")
  93. await websocket.close(code=4001)
  94. return
  95. user_id = payload.get("id")
  96. role = payload.get("role")
  97. if not user_id:
  98. print(f"DEBUG: WS Global Auth failed: no user_id in payload")
  99. await websocket.close(code=4001)
  100. return
  101. await global_manager.connect(websocket, user_id, role)
  102. session_utils.track_user_ping(user_id)
  103. # Send initial unread count
  104. if role != 'admin':
  105. await global_manager.notify_user(user_id)
  106. else:
  107. await global_manager.notify_admins()
  108. try:
  109. while True:
  110. data = await websocket.receive_text()
  111. if data == "ping":
  112. session_utils.track_user_ping(user_id)
  113. except WebSocketDisconnect:
  114. global_manager.disconnect(websocket, user_id)
  115. except Exception as e:
  116. global_manager.disconnect(websocket, user_id)
  117. @app.websocket("/chat")
  118. async def ws_chat(websocket: WebSocket, token: str = Query(...), order_id: int = Query(...)):
  119. payload = auth_utils.decode_token(token)
  120. if not payload:
  121. print(f"DEBUG: Chat WS Auth failed for Order #{order_id}")
  122. await websocket.close(code=4001)
  123. return
  124. role = payload.get("role")
  125. user_id = payload.get("id")
  126. print(f"DEBUG: Chat WS connection attempt for Order #{order_id} from User #{user_id} (Role: {role})")
  127. if role != 'admin':
  128. user_info = db.execute_query("SELECT can_chat FROM users WHERE id = %s", (user_id,))
  129. if not user_info or not user_info[0]['can_chat']:
  130. print(f"DEBUG: Chat WS rejected: user cannot chat")
  131. await websocket.close(code=4003)
  132. return
  133. order = db.execute_query("SELECT user_id FROM orders WHERE id = %s", (order_id,))
  134. if not order:
  135. print(f"DEBUG: Chat WS rejected: order #{order_id} not found")
  136. await websocket.close(code=4004)
  137. return
  138. if role != 'admin' and order[0]['user_id'] != user_id:
  139. print(f"DEBUG: Chat WS rejected: not authorized for this order")
  140. await websocket.close(code=4003)
  141. return
  142. await manager.connect(websocket, order_id, role)
  143. try:
  144. while True:
  145. data = await websocket.receive_text()
  146. try:
  147. # Try to parse as JSON for new "true chat" protocol
  148. msg_data = json.loads(data)
  149. if msg_data.get("type") == "message":
  150. text = msg_data.get("text", "")
  151. await manager.handle_message(order_id, user_id, role, text)
  152. elif msg_data.get("type") == "read":
  153. await _handle_read_event(order_id, user_id, role)
  154. except json.JSONDecodeError:
  155. # Fallback for legacy plain text messages (like "read")
  156. if data == "read":
  157. await _handle_read_event(order_id, user_id, role)
  158. except WebSocketDisconnect:
  159. manager.disconnect(websocket, order_id)
  160. except Exception as e:
  161. print(f"ERROR in ws_chat loop: {e}")
  162. manager.disconnect(websocket, order_id)
  163. async def _handle_read_event(order_id: int, user_id: int, role: str):
  164. if role == 'admin':
  165. db.execute_commit("UPDATE order_messages SET is_read = TRUE WHERE order_id = %s AND is_from_admin = FALSE AND is_read = FALSE", (order_id,))
  166. await global_manager.notify_admins()
  167. await global_manager.notify_order_read(order_id)
  168. else:
  169. db.execute_commit("UPDATE order_messages SET is_read = TRUE WHERE order_id = %s AND is_from_admin = TRUE AND is_read = FALSE", (order_id,))
  170. await global_manager.notify_user(user_id)
  171. # Mount Static Files
  172. for sub in ["", "previews", "invoices", "reports"]:
  173. path = os.path.join("uploads", sub)
  174. if not os.path.exists(path):
  175. os.makedirs(path, exist_ok=True)
  176. # Mount static files for uploads and previews with caching
  177. app.mount("/uploads", StaticFiles(directory="uploads", html=False), name="uploads")
  178. @app.middleware("http")
  179. async def add_cache_control_header(request, call_next):
  180. response = await call_next(request)
  181. if request.url.path.startswith("/uploads"):
  182. response.headers["Cache-Control"] = "public, max-age=604800, immutable"
  183. return response
  184. if __name__ == "__main__":
  185. import uvicorn
  186. uvicorn.run(app, host="127.0.0.1", port=8000)