main.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813
  1. from fastapi import FastAPI, Request, Depends, Form, Response, HTTPException, status
  2. from fastapi.templating import Jinja2Templates
  3. from fastapi.staticfiles import StaticFiles
  4. from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
  5. from sqlalchemy.ext.asyncio import AsyncSession
  6. from sqlalchemy import select, func
  7. from contextlib import asynccontextmanager
  8. import json
  9. from datetime import datetime
  10. from typing import Optional
  11. import httpx
  12. import os
  13. from pathlib import Path
  14. from app.database import init_db, get_db
  15. from app.models import Book, ListeningSession, Recommendation, User, AppSettings
  16. from app.abs_client import get_abs_client
  17. from app.recommender import BookRecommender
  18. from app.config import get_settings
  19. from app.auth import (
  20. get_current_user,
  21. get_current_user_optional,
  22. get_current_admin,
  23. authenticate_user,
  24. create_user,
  25. set_session_cookie,
  26. clear_session_cookie
  27. )
  28. from app.services.stats import ReadingStatsService
  29. def extract_author_name(metadata: dict) -> str:
  30. """
  31. Extract author name from Audiobookshelf metadata.
  32. Tries multiple fields in order:
  33. 1. authorName (string field)
  34. 2. authors array (extract first author's name)
  35. 3. Falls back to "Unknown"
  36. """
  37. # Try authorName field first
  38. author_name = metadata.get("authorName")
  39. if author_name:
  40. return author_name
  41. # Try authors array
  42. authors = metadata.get("authors", [])
  43. if authors and len(authors) > 0:
  44. # Authors can be objects with 'name' field or just strings
  45. first_author = authors[0]
  46. if isinstance(first_author, dict):
  47. author_name = first_author.get("name")
  48. if author_name:
  49. return author_name
  50. elif isinstance(first_author, str):
  51. return first_author
  52. return "Unknown"
  53. @asynccontextmanager
  54. async def lifespan(app: FastAPI):
  55. """Initialize database on startup."""
  56. await init_db()
  57. yield
  58. # Initialize FastAPI app
  59. app = FastAPI(
  60. title="Dewy Oracle",
  61. description="AI-powered book recommendations based on your listening history",
  62. lifespan=lifespan
  63. )
  64. # Setup templates and static files
  65. templates = Jinja2Templates(directory="app/templates")
  66. app.mount("/static", StaticFiles(directory="app/static"), name="static")
  67. # Initialize recommender (shared across users)
  68. recommender = BookRecommender()
  69. @app.get("/", response_class=HTMLResponse)
  70. async def home(
  71. request: Request,
  72. db: AsyncSession = Depends(get_db),
  73. user: Optional[User] = Depends(get_current_user_optional)
  74. ):
  75. """Home page showing dashboard or landing page."""
  76. # If user not logged in, show landing page
  77. if not user:
  78. return templates.TemplateResponse(
  79. "index.html",
  80. {
  81. "request": request,
  82. "user": None,
  83. "books": [],
  84. "recommendations": []
  85. }
  86. )
  87. # Get user's recent books and recommendations
  88. recent_sessions = await db.execute(
  89. select(ListeningSession)
  90. .where(ListeningSession.user_id == user.id)
  91. .order_by(ListeningSession.last_update.desc())
  92. .limit(10)
  93. )
  94. sessions = recent_sessions.scalars().all()
  95. # Get book details for sessions
  96. books = []
  97. for session in sessions:
  98. book_result = await db.execute(
  99. select(Book).where(Book.id == session.book_id)
  100. )
  101. book = book_result.scalar_one_or_none()
  102. if book:
  103. books.append({
  104. "book": book,
  105. "session": session
  106. })
  107. # Get user's recent recommendations
  108. recs_result = await db.execute(
  109. select(Recommendation)
  110. .where(
  111. Recommendation.user_id == user.id,
  112. Recommendation.dismissed == False
  113. )
  114. .order_by(Recommendation.created_at.desc())
  115. .limit(5)
  116. )
  117. recommendations_raw = recs_result.scalars().all()
  118. # Parse JSON fields for template
  119. recommendations = []
  120. for rec in recommendations_raw:
  121. rec_dict = {
  122. "id": rec.id,
  123. "title": rec.title,
  124. "author": rec.author,
  125. "description": rec.description,
  126. "reason": rec.reason,
  127. "genres": json.loads(rec.genres) if rec.genres else []
  128. }
  129. recommendations.append(rec_dict)
  130. return templates.TemplateResponse(
  131. "index.html",
  132. {
  133. "request": request,
  134. "user": user,
  135. "books": books,
  136. "recommendations": recommendations
  137. }
  138. )
  139. # ==================== Authentication Routes ====================
  140. @app.get("/login", response_class=HTMLResponse)
  141. async def login_page(request: Request):
  142. """Login page."""
  143. return templates.TemplateResponse("login.html", {"request": request})
  144. @app.post("/api/auth/login")
  145. async def login(
  146. username: str = Form(...),
  147. password: str = Form(...),
  148. db: AsyncSession = Depends(get_db)
  149. ):
  150. """Authenticate user and create session."""
  151. user = await authenticate_user(db, username, password)
  152. if not user:
  153. raise HTTPException(
  154. status_code=status.HTTP_401_UNAUTHORIZED,
  155. detail="Incorrect username or password"
  156. )
  157. # Create redirect response and set session cookie
  158. redirect = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
  159. set_session_cookie(redirect, user.id)
  160. return redirect
  161. @app.get("/register", response_class=HTMLResponse)
  162. async def register_page(request: Request):
  163. """Registration page."""
  164. return templates.TemplateResponse("register.html", {"request": request})
  165. @app.post("/api/auth/register")
  166. async def register(
  167. username: str = Form(...),
  168. email: str = Form(...),
  169. password: str = Form(...),
  170. abs_url: str = Form(...),
  171. abs_api_token: str = Form(...),
  172. display_name: Optional[str] = Form(None),
  173. db: AsyncSession = Depends(get_db)
  174. ):
  175. """Register a new user."""
  176. try:
  177. # Check if registration is allowed
  178. result = await db.execute(
  179. select(AppSettings).where(AppSettings.key == "allow_registration")
  180. )
  181. allow_reg_setting = result.scalar_one_or_none()
  182. # Check if there are any existing users (first user is always allowed)
  183. result = await db.execute(select(func.count(User.id)))
  184. user_count = result.scalar()
  185. if user_count > 0 and allow_reg_setting and allow_reg_setting.value.lower() != 'true':
  186. raise HTTPException(
  187. status_code=status.HTTP_403_FORBIDDEN,
  188. detail="Registration is currently disabled"
  189. )
  190. user = await create_user(
  191. db=db,
  192. username=username,
  193. email=email,
  194. password=password,
  195. abs_url=abs_url,
  196. abs_api_token=abs_api_token,
  197. display_name=display_name
  198. )
  199. # Create redirect response and set session cookie
  200. redirect = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
  201. set_session_cookie(redirect, user.id)
  202. return redirect
  203. except HTTPException as e:
  204. raise e
  205. except Exception as e:
  206. raise HTTPException(
  207. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  208. detail=str(e)
  209. )
  210. @app.post("/api/auth/logout")
  211. async def logout(response: Response):
  212. """Logout user and clear session."""
  213. clear_session_cookie(response)
  214. return JSONResponse({
  215. "status": "success",
  216. "message": "Logged out successfully"
  217. })
  218. # ==================== API Routes ====================
  219. async def download_cover_image(abs_client, book_id: str) -> Optional[str]:
  220. """
  221. Download cover image from Audiobookshelf and save locally.
  222. Args:
  223. abs_client: Audiobookshelf client with authentication
  224. book_id: Library item ID
  225. Returns:
  226. Local path to saved cover (e.g., /static/covers/book_id.jpg) or None
  227. """
  228. if not book_id:
  229. return None
  230. # Create covers directory if it doesn't exist
  231. covers_dir = Path("app/static/covers")
  232. covers_dir.mkdir(parents=True, exist_ok=True)
  233. # Use Audiobookshelf API cover endpoint
  234. cover_url = f"{abs_client.base_url}/api/items/{book_id}/cover"
  235. async with httpx.AsyncClient() as client:
  236. try:
  237. response = await client.get(cover_url, headers=abs_client.headers, follow_redirects=True)
  238. response.raise_for_status()
  239. # Determine extension from content-type
  240. content_type = response.headers.get("content-type", "image/jpeg")
  241. ext = ".webp" if "webp" in content_type else ".jpg"
  242. local_filename = f"{book_id}{ext}"
  243. local_path = covers_dir / local_filename
  244. # Save to local file
  245. with open(local_path, "wb") as f:
  246. f.write(response.content)
  247. # Return path relative to static directory
  248. return f"/static/covers/{local_filename}"
  249. except Exception as e:
  250. print(f"Failed to download cover for {book_id}: {e}")
  251. return None
  252. @app.get("/api/sync")
  253. async def sync_with_audiobookshelf(
  254. db: AsyncSession = Depends(get_db),
  255. user: User = Depends(get_current_user)
  256. ):
  257. """Sync library and progress from Audiobookshelf."""
  258. try:
  259. # Create user-specific ABS client
  260. abs_client = get_abs_client(user)
  261. # Get user info which includes all media progress
  262. user_info = await abs_client.get_user_info()
  263. media_progress = user_info.get("mediaProgress", [])
  264. synced_count = 0
  265. for progress_item in media_progress:
  266. # Skip podcast episodes, only process books
  267. if progress_item.get("mediaItemType") != "book":
  268. continue
  269. library_item_id = progress_item.get("libraryItemId")
  270. if not library_item_id:
  271. continue
  272. # Fetch full library item details
  273. try:
  274. item = await abs_client.get_item_details(library_item_id)
  275. except:
  276. # Skip if item not found
  277. continue
  278. # Extract book info
  279. media = item.get("media", {})
  280. metadata = media.get("metadata", {})
  281. book_id = item.get("id")
  282. if not book_id:
  283. continue
  284. # Check if book exists in DB
  285. result = await db.execute(select(Book).where(Book.id == book_id))
  286. book = result.scalar_one_or_none()
  287. # Download cover image and get local path
  288. local_cover_url = await download_cover_image(abs_client, book_id)
  289. # Create or update book
  290. if not book:
  291. book = Book(
  292. id=book_id,
  293. title=metadata.get("title", "Unknown"),
  294. author=extract_author_name(metadata),
  295. narrator=metadata.get("narratorName"),
  296. description=metadata.get("description"),
  297. genres=json.dumps(metadata.get("genres", [])),
  298. tags=json.dumps(media.get("tags", [])),
  299. duration=media.get("duration", 0),
  300. cover_url=local_cover_url # Store local path
  301. )
  302. db.add(book)
  303. else:
  304. # Update existing book
  305. book.title = metadata.get("title", book.title)
  306. book.author = extract_author_name(metadata)
  307. if local_cover_url: # Only update if download succeeded
  308. book.cover_url = local_cover_url
  309. book.updated_at = datetime.now()
  310. # Update or create listening session
  311. progress_data = progress_item.get("progress", 0)
  312. current_time = progress_item.get("currentTime", 0)
  313. is_finished = progress_item.get("isFinished", False)
  314. started_at_ts = progress_item.get("startedAt")
  315. finished_at_ts = progress_item.get("finishedAt")
  316. session_result = await db.execute(
  317. select(ListeningSession)
  318. .where(
  319. ListeningSession.user_id == user.id,
  320. ListeningSession.book_id == book_id
  321. )
  322. .order_by(ListeningSession.last_update.desc())
  323. .limit(1)
  324. )
  325. session = session_result.scalar_one_or_none()
  326. if not session:
  327. session = ListeningSession(
  328. user_id=user.id,
  329. book_id=book_id,
  330. progress=progress_data,
  331. current_time=current_time,
  332. is_finished=is_finished,
  333. started_at=datetime.fromtimestamp(started_at_ts / 1000) if started_at_ts else datetime.now(),
  334. finished_at=datetime.fromtimestamp(finished_at_ts / 1000) if finished_at_ts else None
  335. )
  336. db.add(session)
  337. else:
  338. # Update existing session
  339. session.progress = progress_data
  340. session.current_time = current_time
  341. session.is_finished = is_finished
  342. if finished_at_ts and not session.finished_at:
  343. session.finished_at = datetime.fromtimestamp(finished_at_ts / 1000)
  344. synced_count += 1
  345. await db.commit()
  346. return JSONResponse({
  347. "status": "success",
  348. "synced": synced_count,
  349. "message": f"Synced {synced_count} books from Audiobookshelf"
  350. })
  351. except Exception as e:
  352. return JSONResponse(
  353. {"status": "error", "message": str(e)},
  354. status_code=500
  355. )
  356. @app.get("/api/recommendations/generate")
  357. async def generate_recommendations(
  358. db: AsyncSession = Depends(get_db),
  359. user: User = Depends(get_current_user)
  360. ):
  361. """Generate new AI recommendations based on reading history."""
  362. try:
  363. # Get finished books for context
  364. finished_result = await db.execute(
  365. select(ListeningSession, Book)
  366. .join(Book, ListeningSession.book_id == Book.id)
  367. .where(
  368. ListeningSession.user_id == user.id,
  369. ListeningSession.is_finished == True
  370. )
  371. .order_by(ListeningSession.finished_at.desc())
  372. .limit(20)
  373. )
  374. finished_items = finished_result.all()
  375. if not finished_items:
  376. return JSONResponse({
  377. "status": "error",
  378. "message": "No reading history found. Please sync with Audiobookshelf first."
  379. })
  380. # Format reading history
  381. reading_history = []
  382. for session, book in finished_items:
  383. reading_history.append({
  384. "title": book.title,
  385. "author": book.author,
  386. "genres": json.loads(book.genres) if book.genres else [],
  387. "progress": session.progress,
  388. "is_finished": session.is_finished
  389. })
  390. # Generate recommendations
  391. new_recs = await recommender.generate_recommendations(
  392. reading_history, num_recommendations=5
  393. )
  394. # Save to database
  395. for rec in new_recs:
  396. recommendation = Recommendation(
  397. user_id=user.id,
  398. title=rec.get("title"),
  399. author=rec.get("author"),
  400. description=rec.get("description"),
  401. reason=rec.get("reason"),
  402. genres=json.dumps(rec.get("genres", []))
  403. )
  404. db.add(recommendation)
  405. await db.commit()
  406. return JSONResponse({
  407. "status": "success",
  408. "recommendations": new_recs,
  409. "count": len(new_recs)
  410. })
  411. except Exception as e:
  412. return JSONResponse(
  413. {"status": "error", "message": str(e)},
  414. status_code=500
  415. )
  416. @app.get("/api/recommendations")
  417. async def get_recommendations(
  418. db: AsyncSession = Depends(get_db),
  419. user: User = Depends(get_current_user)
  420. ):
  421. """Get saved recommendations."""
  422. result = await db.execute(
  423. select(Recommendation)
  424. .where(
  425. Recommendation.user_id == user.id,
  426. Recommendation.dismissed == False
  427. )
  428. .order_by(Recommendation.created_at.desc())
  429. )
  430. recommendations = result.scalars().all()
  431. return JSONResponse({
  432. "recommendations": [
  433. {
  434. "id": rec.id,
  435. "title": rec.title,
  436. "author": rec.author,
  437. "description": rec.description,
  438. "reason": rec.reason,
  439. "genres": json.loads(rec.genres) if rec.genres else [],
  440. "created_at": rec.created_at.isoformat()
  441. }
  442. for rec in recommendations
  443. ]
  444. })
  445. @app.get("/api/history")
  446. async def get_listening_history(
  447. db: AsyncSession = Depends(get_db),
  448. user: User = Depends(get_current_user)
  449. ):
  450. """Get listening history."""
  451. result = await db.execute(
  452. select(ListeningSession, Book)
  453. .join(Book, ListeningSession.book_id == Book.id)
  454. .where(ListeningSession.user_id == user.id)
  455. .order_by(ListeningSession.last_update.desc())
  456. )
  457. items = result.all()
  458. return JSONResponse({
  459. "history": [
  460. {
  461. "book": {
  462. "id": book.id,
  463. "title": book.title,
  464. "author": book.author,
  465. "cover_url": book.cover_url,
  466. },
  467. "session": {
  468. "progress": session.progress,
  469. "is_finished": session.is_finished,
  470. "started_at": session.started_at.isoformat() if session.started_at else None,
  471. "finished_at": session.finished_at.isoformat() if session.finished_at else None,
  472. }
  473. }
  474. for session, book in items
  475. ]
  476. })
  477. # ==================== Reading Log Routes ====================
  478. @app.get("/reading-log", response_class=HTMLResponse)
  479. async def reading_log_page(
  480. request: Request,
  481. user: User = Depends(get_current_user)
  482. ):
  483. """Reading log page with stats and filters."""
  484. return templates.TemplateResponse(
  485. "reading_log.html",
  486. {
  487. "request": request,
  488. "user": user
  489. }
  490. )
  491. @app.get("/api/reading-log/stats")
  492. async def get_reading_stats(
  493. db: AsyncSession = Depends(get_db),
  494. user: User = Depends(get_current_user),
  495. start_date: Optional[str] = None,
  496. end_date: Optional[str] = None
  497. ):
  498. """Get reading statistics for the user."""
  499. try:
  500. # Parse dates if provided
  501. start_dt = datetime.fromisoformat(start_date) if start_date else None
  502. end_dt = datetime.fromisoformat(end_date) if end_date else None
  503. # Calculate stats
  504. stats_service = ReadingStatsService(db, user.id, user.abs_url)
  505. stats = await stats_service.calculate_stats(start_dt, end_dt)
  506. return JSONResponse(stats)
  507. except Exception as e:
  508. return JSONResponse(
  509. {"status": "error", "message": str(e)},
  510. status_code=500
  511. )
  512. @app.put("/api/sessions/{session_id}/rating")
  513. async def update_session_rating(
  514. session_id: int,
  515. rating: int = Form(...),
  516. db: AsyncSession = Depends(get_db),
  517. user: User = Depends(get_current_user)
  518. ):
  519. """Update the rating for a listening session."""
  520. try:
  521. # Validate rating
  522. if rating < 1 or rating > 5:
  523. raise HTTPException(
  524. status_code=status.HTTP_400_BAD_REQUEST,
  525. detail="Rating must be between 1 and 5"
  526. )
  527. # Get session and verify ownership
  528. result = await db.execute(
  529. select(ListeningSession).where(
  530. ListeningSession.id == session_id,
  531. ListeningSession.user_id == user.id
  532. )
  533. )
  534. session = result.scalar_one_or_none()
  535. if not session:
  536. raise HTTPException(
  537. status_code=status.HTTP_404_NOT_FOUND,
  538. detail="Session not found"
  539. )
  540. # Update rating
  541. session.rating = rating
  542. await db.commit()
  543. return JSONResponse({
  544. "status": "success",
  545. "message": "Rating updated successfully",
  546. "rating": rating
  547. })
  548. except HTTPException as e:
  549. raise e
  550. except Exception as e:
  551. return JSONResponse(
  552. {"status": "error", "message": str(e)},
  553. status_code=500
  554. )
  555. # ==================== Admin Routes ====================
  556. @app.get("/admin", response_class=HTMLResponse)
  557. async def admin_page(
  558. request: Request,
  559. user: User = Depends(get_current_admin)
  560. ):
  561. """Admin panel page."""
  562. return templates.TemplateResponse(
  563. "admin.html",
  564. {
  565. "request": request,
  566. "user": user
  567. }
  568. )
  569. @app.get("/api/admin/settings")
  570. async def get_admin_settings(
  571. db: AsyncSession = Depends(get_db),
  572. admin: User = Depends(get_current_admin)
  573. ):
  574. """Get application settings."""
  575. result = await db.execute(select(AppSettings))
  576. settings = result.scalars().all()
  577. settings_dict = {s.key: s.value for s in settings}
  578. return JSONResponse(settings_dict)
  579. @app.put("/api/admin/settings/{key}")
  580. async def update_setting(
  581. key: str,
  582. value: str = Form(...),
  583. db: AsyncSession = Depends(get_db),
  584. admin: User = Depends(get_current_admin)
  585. ):
  586. """Update an application setting."""
  587. result = await db.execute(
  588. select(AppSettings).where(AppSettings.key == key)
  589. )
  590. setting = result.scalar_one_or_none()
  591. if not setting:
  592. # Create new setting
  593. setting = AppSettings(key=key, value=value)
  594. db.add(setting)
  595. else:
  596. # Update existing
  597. setting.value = value
  598. await db.commit()
  599. return JSONResponse({
  600. "status": "success",
  601. "message": f"Setting {key} updated"
  602. })
  603. @app.get("/api/admin/users")
  604. async def get_users(
  605. db: AsyncSession = Depends(get_db),
  606. admin: User = Depends(get_current_admin)
  607. ):
  608. """Get all users."""
  609. result = await db.execute(select(User).order_by(User.created_at.desc()))
  610. users = result.scalars().all()
  611. users_list = [
  612. {
  613. "id": user.id,
  614. "username": user.username,
  615. "email": user.email,
  616. "display_name": user.display_name,
  617. "is_admin": user.is_admin,
  618. "is_active": user.is_active,
  619. "created_at": user.created_at.isoformat() if user.created_at else None,
  620. "last_login": user.last_login.isoformat() if user.last_login else None,
  621. "is_current": user.id == admin.id
  622. }
  623. for user in users
  624. ]
  625. return JSONResponse({"users": users_list})
  626. @app.put("/api/admin/users/{user_id}/admin")
  627. async def toggle_user_admin(
  628. user_id: int,
  629. is_admin: str = Form(...),
  630. db: AsyncSession = Depends(get_db),
  631. admin: User = Depends(get_current_admin)
  632. ):
  633. """Toggle admin status for a user."""
  634. # Prevent admin from removing their own admin status
  635. if user_id == admin.id:
  636. return JSONResponse(
  637. {"status": "error", "message": "Cannot modify your own admin status"},
  638. status_code=400
  639. )
  640. result = await db.execute(select(User).where(User.id == user_id))
  641. user = result.scalar_one_or_none()
  642. if not user:
  643. raise HTTPException(
  644. status_code=status.HTTP_404_NOT_FOUND,
  645. detail="User not found"
  646. )
  647. user.is_admin = is_admin.lower() == 'true'
  648. await db.commit()
  649. return JSONResponse({
  650. "status": "success",
  651. "message": "User admin status updated"
  652. })
  653. @app.delete("/api/admin/users/{user_id}")
  654. async def delete_user(
  655. user_id: int,
  656. db: AsyncSession = Depends(get_db),
  657. admin: User = Depends(get_current_admin)
  658. ):
  659. """Delete a user."""
  660. # Prevent admin from deleting themselves
  661. if user_id == admin.id:
  662. return JSONResponse(
  663. {"status": "error", "message": "Cannot delete your own account"},
  664. status_code=400
  665. )
  666. result = await db.execute(select(User).where(User.id == user_id))
  667. user = result.scalar_one_or_none()
  668. if not user:
  669. raise HTTPException(
  670. status_code=status.HTTP_404_NOT_FOUND,
  671. detail="User not found"
  672. )
  673. await db.delete(user)
  674. await db.commit()
  675. return JSONResponse({
  676. "status": "success",
  677. "message": "User deleted successfully"
  678. })
  679. @app.get("/health")
  680. async def health_check():
  681. """Health check endpoint."""
  682. return {"status": "healthy"}