From 5b1e770948699ffe4118e4df099abcf5e5512412 Mon Sep 17 00:00:00 2001 From: Krikorios <99836218+Krikorios@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:58:21 +0300 Subject: [PATCH] perf: paginate docs list, lazy thumbnails, static cache headers --- database/schema.py | 10 ++ main.py | 35 ++++--- routers/auth.py | 24 ++--- routers/documents.py | 75 +++++++++++-- services/auth_service.py | 47 +++++++++ templates/duplicates.html | 214 ++++++++++++++++++++++++++++++-------- 6 files changed, 323 insertions(+), 82 deletions(-) diff --git a/database/schema.py b/database/schema.py index 52a4d57..e6bd738 100644 --- a/database/schema.py +++ b/database/schema.py @@ -46,6 +46,16 @@ def create_tables(): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + username TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); + CREATE TABLE IF NOT EXISTS persons ( id INTEGER PRIMARY KEY AUTOINCREMENT, first_name TEXT NOT NULL, diff --git a/main.py b/main.py index 4c4bf1b..3b29145 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,4 @@ +from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request @@ -8,6 +9,7 @@ from starlette.types import Scope from config import UPLOAD_DIR, ENVIRONMENT from database.schema import create_tables from routers import documents, review, search, upload, auth +from services.auth_service import get_session, cleanup_expired_sessions class CachedStaticFiles(StaticFiles): @@ -24,37 +26,40 @@ class CachedStaticFiles(StaticFiles): response.headers["Cache-Control"] = f"public, max-age={self._max_age}" return response + +@asynccontextmanager +async def lifespan(app: FastAPI): + Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True) + Path("data").mkdir(exist_ok=True) + create_tables() + try: + cleanup_expired_sessions() + except Exception: + pass + yield + + # Disable docs/openapi in production if ENVIRONMENT == "production": - app = FastAPI(title="Lebanese Real Estate Registry", docs_url=None, redoc_url=None, openapi_url=None) + app = FastAPI(title="Lebanese Real Estate Registry", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan) else: - app = FastAPI(title="Lebanese Real Estate Registry") + app = FastAPI(title="Lebanese Real Estate Registry", lifespan=lifespan) @app.middleware("http") async def check_authentication(request: Request, call_next): path = request.url.path allowed_paths = ["/auth/login", "/static"] is_allowed = any(path.startswith(p) for p in allowed_paths) - + if not is_allowed: session_id = request.cookies.get("session_id") - if not session_id or session_id not in auth.sessions: + if not session_id or not get_session(session_id): return RedirectResponse(url="/auth/login", status_code=303) - + response = await call_next(request) return response -@app.on_event("startup") -async def startup(): - Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True) - Path("data").mkdir(exist_ok=True) - create_tables() - - # Do NOT create default admin user automatically in production! - # A script should be used for initial setup. - - app.mount("/uploads", CachedStaticFiles(directory=UPLOAD_DIR, max_age=604800), name="uploads") app.mount("/static", CachedStaticFiles(directory="static", max_age=86400), name="static") diff --git a/routers/auth.py b/routers/auth.py index 6563d92..86d5ca4 100644 --- a/routers/auth.py +++ b/routers/auth.py @@ -1,22 +1,20 @@ from fastapi import APIRouter, Depends, Request, Form, HTTPException, status from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse from fastapi.templating import Jinja2Templates -from services.auth_service import verify_password, get_user_by_username, create_user, delete_user, get_all_users +from services.auth_service import ( + verify_password, get_user_by_username, create_user, delete_user, get_all_users, + create_session, get_session, delete_session, SESSION_TTL_SECONDS, +) from services.backup_service import create_backup -import secrets import os router = APIRouter() templates = Jinja2Templates(directory="templates") -# Simple memory store for sessions for this requirement. (In prod we use Redis/Cookie etc., but cookie + session dict is quickest without external deps like itsdangerous if not in requirements) -sessions = {} def get_current_user_from_request(request: Request): session_id = request.cookies.get("session_id") - if session_id and session_id in sessions: - return sessions[session_id] - return None + return get_session(session_id) if session_id else None def get_current_user(request: Request): user = get_current_user_from_request(request) @@ -37,10 +35,8 @@ async def login_post(request: Request, username: str = Form(...), password: str if not user or not verify_password(user["password_hash"], password): return templates.TemplateResponse(request=request, name="login.html", context={"error": "Invalid username or password"}) - - session_id = secrets.token_urlsafe(32) - sessions[session_id] = dict(user) - + session_id = create_session(user["id"], user["username"]) + from config import ENVIRONMENT # secure=True only when accessed via HTTPS (check X-Forwarded-Proto from nginx) is_https = ENVIRONMENT == "production" and request.headers.get("x-forwarded-proto") == "https" @@ -51,7 +47,7 @@ async def login_post(request: Request, username: str = Form(...), password: str httponly=True, secure=is_https, samesite="lax", - max_age=86400, # 24 hours + max_age=SESSION_TTL_SECONDS, ) return response @@ -59,8 +55,8 @@ async def login_post(request: Request, username: str = Form(...), password: str async def logout(request: Request): response = RedirectResponse(url="/auth/login") session_id = request.cookies.get("session_id") - if session_id in sessions: - del sessions[session_id] + if session_id: + delete_session(session_id) response.delete_cookie("session_id") return response diff --git a/routers/documents.py b/routers/documents.py index b99a1a1..2acba5b 100644 --- a/routers/documents.py +++ b/routers/documents.py @@ -130,6 +130,23 @@ async def delete_document(doc_id: int): if not doc: return JSONResponse({"error": "not found"}, status_code=404) + # If other documents were flagged as duplicates of this one, detach them + # (promote the earliest orphan to be the new keeper so they remain grouped). + orphans = conn.execute( + "SELECT id FROM documents WHERE duplicate_of=? ORDER BY id", + (doc_id,), + ).fetchall() + if orphans: + new_keeper = orphans[0]["id"] + conn.execute( + "UPDATE documents SET duplicate_of=NULL WHERE id=?", + (new_keeper,), + ) + conn.execute( + "UPDATE documents SET duplicate_of=? WHERE duplicate_of=? AND id != ?", + (new_keeper, doc_id, new_keeper), + ) + # Delete properties for this document conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,)) @@ -310,22 +327,49 @@ async def scan_duplicates(): @router.get("/documents/duplicates") async def duplicates_view(request: Request): - """List all documents flagged as duplicates alongside their originals.""" + """Group duplicates with their originals so the user can compare side-by-side.""" with get_db() as conn: - rows = conn.execute( - """SELECT d.*, p.first_name, p.family_name, - o.request_number AS orig_request_number, - o.created_at AS orig_created_at + # Fetch all docs that have been flagged as duplicates + their keepers + dup_rows = conn.execute( + """SELECT d.*, p.first_name, p.family_name FROM documents d LEFT JOIN persons p ON p.id = d.person_id - LEFT JOIN documents o ON o.id = d.duplicate_of WHERE d.duplicate_of IS NOT NULL ORDER BY d.duplicate_of, d.id""" ).fetchall() + + keeper_ids = sorted({r["duplicate_of"] for r in dup_rows if r["duplicate_of"]}) + keepers_by_id = {} + if keeper_ids: + placeholders = ",".join("?" * len(keeper_ids)) + keeper_rows = conn.execute( + f"""SELECT d.*, p.first_name, p.family_name + FROM documents d + LEFT JOIN persons p ON p.id = d.person_id + WHERE d.id IN ({placeholders})""", + keeper_ids, + ).fetchall() + keepers_by_id = {r["id"]: dict(r) for r in keeper_rows} + + groups = [] + seen_keepers = [] + dups_by_keeper: dict[int, list[dict]] = {} + for r in dup_rows: + k = r["duplicate_of"] + dups_by_keeper.setdefault(k, []).append(dict(r)) + if k not in seen_keepers: + seen_keepers.append(k) + + for k in seen_keepers: + keeper = keepers_by_id.get(k) + if not keeper: + continue + groups.append({"keeper": keeper, "duplicates": dups_by_keeper.get(k, [])}) + return templates.TemplateResponse( request, "duplicates.html", - {"documents": [dict(r) for r in rows]}, + {"groups": groups, "total_duplicates": len(dup_rows)}, ) @@ -347,3 +391,20 @@ async def delete_all_duplicates(): pass deleted += 1 return JSONResponse({"ok": True, "deleted": deleted}) + + +@router.post("/documents/{doc_id}/unflag-duplicate") +async def unflag_duplicate(doc_id: int): + """Mark a flagged-duplicate document as NOT a duplicate (clear duplicate_of).""" + with get_db() as conn: + row = conn.execute( + "SELECT id FROM documents WHERE id=? AND duplicate_of IS NOT NULL", + (doc_id,), + ).fetchone() + if not row: + return JSONResponse({"error": "not flagged"}, status_code=404) + conn.execute( + "UPDATE documents SET duplicate_of=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (doc_id,), + ) + return JSONResponse({"ok": True}) diff --git a/services/auth_service.py b/services/auth_service.py index 9a3bb2a..11c0c82 100644 --- a/services/auth_service.py +++ b/services/auth_service.py @@ -1,6 +1,9 @@ import hashlib import hmac import secrets +from datetime import datetime, timedelta + +SESSION_TTL_SECONDS = 86400 # 24h def hash_password(password: str) -> str: salt = secrets.token_hex(16) @@ -38,3 +41,47 @@ def get_all_users(): cursor = conn.execute("SELECT id, username, created_at FROM users ORDER BY created_at DESC") return cursor.fetchall() + +# ─── Sessions (DB-backed) ───────────────────────────────────────── + +def create_session(user_id: int, username: str, ttl_seconds: int = SESSION_TTL_SECONDS) -> str: + from database.connection import get_db + session_id = secrets.token_urlsafe(32) + expires_at = (datetime.utcnow() + timedelta(seconds=ttl_seconds)).strftime("%Y-%m-%d %H:%M:%S") + with get_db() as conn: + conn.execute( + "INSERT INTO sessions (id, user_id, username, expires_at) VALUES (?, ?, ?, ?)", + (session_id, user_id, username, expires_at), + ) + return session_id + + +def get_session(session_id: str): + """Return dict with user_id/username if session is valid and unexpired, else None.""" + if not session_id: + return None + from database.connection import get_db + now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + with get_db() as conn: + row = conn.execute( + "SELECT id, user_id, username, expires_at FROM sessions WHERE id = ? AND expires_at > ?", + (session_id, now), + ).fetchone() + return dict(row) if row else None + + +def delete_session(session_id: str) -> None: + if not session_id: + return + from database.connection import get_db + with get_db() as conn: + conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) + + +def cleanup_expired_sessions() -> int: + from database.connection import get_db + now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + with get_db() as conn: + cur = conn.execute("DELETE FROM sessions WHERE expires_at <= ?", (now,)) + return cur.rowcount or 0 + diff --git a/templates/duplicates.html b/templates/duplicates.html index 1b69c55..7183de9 100644 --- a/templates/duplicates.html +++ b/templates/duplicates.html @@ -1,72 +1,181 @@ {% extends "base.html" %} {% block title %}الوثائق المكررة — سجل العقارات{% endblock %} +{% block head %} + +{% endblock %} + {% block content %}