Compare commits

...
10 Commits
22 changed files with 887 additions and 247 deletions
+2
View File
@@ -7,6 +7,7 @@ backups/
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/ .venv/
.venv312/
venv/ venv/
*.egg-info/ *.egg-info/
.claude/ .claude/
@@ -14,3 +15,4 @@ venv/
*.jpeg *.jpeg
*.jpg *.jpg
*.png *.png
tmp_samples/
+4 -2
View File
@@ -20,8 +20,10 @@ SECRET_KEY = os.environ.get("SECRET_KEY", "")
DATABASE_PATH = os.environ.get("DB_PATH", str(BASE_DIR / "data" / "realestate.db")) DATABASE_PATH = os.environ.get("DB_PATH", str(BASE_DIR / "data" / "realestate.db"))
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", str(BASE_DIR / "uploads")) UPLOAD_DIR = os.environ.get("UPLOAD_DIR", str(BASE_DIR / "uploads"))
MAX_CONCURRENT_EXTRACTIONS = int(os.environ.get("MAX_CONCURRENT", "3")) MAX_CONCURRENT_EXTRACTIONS = int(os.environ.get("MAX_CONCURRENT", "3"))
CLAUDE_MODEL = "claude-sonnet-4-6" CLAUDE_MODEL = os.environ.get("CLAUDE_MODEL", "claude-sonnet-4-6")
GEMINI_MODEL = "gemini-2.5-flash" # Gemini 2.5 Pro is more reliable than Flash for Arabic OCR/reasoning
# (Flash occasionally returns refusals / "absolute" style hedges). Override via env if needed.
GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-pro")
# Production mode — set to "production" on VPS # Production mode — set to "production" on VPS
ENVIRONMENT = os.environ.get("ENVIRONMENT", "development") ENVIRONMENT = os.environ.get("ENVIRONMENT", "development")
+21
View File
@@ -3,6 +3,16 @@ from database.connection import get_db
def _migrate(conn): def _migrate(conn):
"""Add columns that may not exist in older databases.""" """Add columns that may not exist in older databases."""
# users.role migration
cursor = conn.execute("PRAGMA table_info(users)")
user_cols = {row[1] for row in cursor.fetchall()}
if "role" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'")
# Promote the first-created user to admin so the app remains usable.
first = conn.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
if first:
conn.execute("UPDATE users SET role='admin' WHERE id=?", (first[0],))
cursor = conn.execute("PRAGMA table_info(documents)") cursor = conn.execute("PRAGMA table_info(documents)")
existing = {row[1] for row in cursor.fetchall()} existing = {row[1] for row in cursor.fetchall()}
migrations = [ migrations = [
@@ -44,9 +54,20 @@ def create_tables():
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
ip TEXT,
success INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_username ON login_attempts(username, created_at);
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip, created_at);
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+40
View File
@@ -0,0 +1,40 @@
import sqlite3
conn = sqlite3.connect('data/realestate.db')
conn.row_factory = sqlite3.Row
# All documents
rows = conn.execute('''
SELECT d.id, d.person_id, d.request_number, d.search_scope, d.page_info,
d.page_number, d.pdf_group_id, d.status,
p.first_name, p.family_name
FROM documents d
LEFT JOIN persons p ON p.id = d.person_id
ORDER BY d.id
''').fetchall()
print("=== ALL documents ===")
for r in rows:
d = dict(r)
print("id=%s status=%s person_id=%s req=%r scope=%r page_info=%r pdf=%r name=%s %s" % (
d['id'], d['status'], d['person_id'], d['request_number'],
d['search_scope'], d['page_info'], d['pdf_group_id'],
d['first_name'], d['family_name']))
# Persons
print("\n=== Persons ===")
persons = conn.execute('SELECT id, first_name, father_name, family_name FROM persons ORDER BY id').fetchall()
for p in persons:
d = dict(p)
print("id=%s %s %s %s" % (d['id'], d['first_name'], d['father_name'], d['family_name']))
# Potential duplicate persons
print("\n=== Potential duplicate persons ===")
dups = conn.execute('''
SELECT p1.id AS pid1, p2.id AS pid2, p1.first_name, p1.family_name
FROM persons p1
JOIN persons p2 ON p1.id < p2.id
AND p1.first_name = p2.first_name
AND COALESCE(p1.family_name,'') = COALESCE(p2.family_name,'')
''').fetchall()
for d in dups:
print(dict(d))
+26 -3
View File
@@ -12,6 +12,10 @@ from routers import documents, review, search, upload, auth
from services.auth_service import get_session, cleanup_expired_sessions from services.auth_service import get_session, cleanup_expired_sessions
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
Path("data").mkdir(exist_ok=True)
class CachedStaticFiles(StaticFiles): class CachedStaticFiles(StaticFiles):
"""StaticFiles that sets a long Cache-Control header so browsers """StaticFiles that sets a long Cache-Control header so browsers
don't re-download the same image on every refresh.""" don't re-download the same image on every refresh."""
@@ -29,13 +33,29 @@ class CachedStaticFiles(StaticFiles):
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
Path("data").mkdir(exist_ok=True)
create_tables() create_tables()
try: try:
cleanup_expired_sessions() cleanup_expired_sessions()
except Exception: except Exception:
pass pass
# Resume any documents that were left in 'pending' state from a previous run
# (e.g. server restart killed the background extraction task).
try:
import asyncio
from database.connection import get_db
from services.extractor import get_default_provider
from routers.upload import _extract_and_save
default_provider = get_default_provider()
with get_db() as conn:
stuck = conn.execute(
"SELECT id, image_path, provider FROM documents WHERE status='pending'"
).fetchall()
for row in stuck:
provider = row["provider"] or default_provider
asyncio.create_task(_extract_and_save(row["id"], row["image_path"], provider))
except Exception:
pass
yield yield
@@ -51,10 +71,13 @@ async def check_authentication(request: Request, call_next):
allowed_paths = ["/auth/login", "/static"] allowed_paths = ["/auth/login", "/static"]
is_allowed = any(path.startswith(p) for p in allowed_paths) is_allowed = any(path.startswith(p) for p in allowed_paths)
request.state.user = None
if not is_allowed: if not is_allowed:
session_id = request.cookies.get("session_id") session_id = request.cookies.get("session_id")
if not session_id or not get_session(session_id): user = get_session(session_id) if session_id else None
if not user:
return RedirectResponse(url="/auth/login", status_code=303) return RedirectResponse(url="/auth/login", status_code=303)
request.state.user = user
response = await call_next(request) response = await call_next(request)
return response return response
+1
View File
@@ -5,6 +5,7 @@ google-genai>=1.70.0
easyocr>=1.7.0 easyocr>=1.7.0
pymupdf>=1.24.0 pymupdf>=1.24.0
jinja2>=3.1.4 jinja2>=3.1.4
python-dotenv>=1.0.1
python-multipart>=0.0.9 python-multipart>=0.0.9
aiofiles>=23.0.0 aiofiles>=23.0.0
pillow>=10.0.0 pillow>=10.0.0
+132 -15
View File
@@ -1,9 +1,12 @@
from fastapi import APIRouter, Depends, Request, Form, HTTPException, status from fastapi import APIRouter, Depends, Request, Form, HTTPException, status
from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from services.template_utils import render_template
from services.auth_service import ( from services.auth_service import (
verify_password, get_user_by_username, create_user, delete_user, get_all_users, verify_password, get_user_by_username, create_user, delete_user, get_all_users,
create_session, get_session, delete_session, SESSION_TTL_SECONDS, create_session, get_session, delete_session, SESSION_TTL_SECONDS,
needs_rehash, update_user_password, set_user_role, count_admins,
record_login_attempt, is_login_blocked,
) )
from services.backup_service import create_backup from services.backup_service import create_backup
import os import os
@@ -12,10 +15,19 @@ router = APIRouter()
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
def _client_ip(request: Request) -> str:
# Trust X-Forwarded-For when behind a reverse proxy
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def get_current_user_from_request(request: Request): def get_current_user_from_request(request: Request):
session_id = request.cookies.get("session_id") session_id = request.cookies.get("session_id")
return get_session(session_id) if session_id else None return get_session(session_id) if session_id else None
def get_current_user(request: Request): def get_current_user(request: Request):
user = get_current_user_from_request(request) user = get_current_user_from_request(request)
if not user: if not user:
@@ -25,20 +37,55 @@ def get_current_user(request: Request):
) )
return user return user
def require_admin(request: Request):
user = get_current_user(request)
if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
@router.get("/login", response_class=HTMLResponse) @router.get("/login", response_class=HTMLResponse)
async def login_get(request: Request): async def login_get(request: Request):
return templates.TemplateResponse(request=request, name="login.html", context={"error": None}) return render_template(templates, request, "login.html", {"error": None})
@router.post("/login", response_class=HTMLResponse) @router.post("/login", response_class=HTMLResponse)
async def login_post(request: Request, username: str = Form(...), password: str = Form(...)): async def login_post(request: Request, username: str = Form(...), password: str = Form(...)):
username = (username or "").strip()
ip = _client_ip(request)
if is_login_blocked(username, ip):
return render_template(
templates,
request,
"login.html",
{"error": "تم حجب محاولات تسجيل الدخول مؤقتاً. حاول بعد 15 دقيقة."},
status_code=429,
)
user = get_user_by_username(username) user = get_user_by_username(username)
if not user or not verify_password(user["password_hash"], password): 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"}) record_login_attempt(username, ip, success=False)
return render_template(
templates,
request,
"login.html",
{"error": "Invalid username or password"},
status_code=401,
)
session_id = create_session(user["id"], user["username"]) record_login_attempt(username, ip, success=True)
# Opportunistically upgrade the password hash if it's using weaker params.
try:
if needs_rehash(user["password_hash"]):
update_user_password(user["id"], password)
except Exception:
pass
role = user["role"] if "role" in user.keys() else "user"
session_id = create_session(user["id"], user["username"], role=role)
from config import ENVIRONMENT 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" is_https = ENVIRONMENT == "production" and request.headers.get("x-forwarded-proto") == "https"
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie( response.set_cookie(
@@ -48,6 +95,7 @@ async def login_post(request: Request, username: str = Form(...), password: str
secure=is_https, secure=is_https,
samesite="lax", samesite="lax",
max_age=SESSION_TTL_SECONDS, max_age=SESSION_TTL_SECONDS,
path="/",
) )
return response return response
@@ -61,34 +109,103 @@ async def logout(request: Request):
return response return response
@router.get("/users", response_class=HTMLResponse) @router.get("/users", response_class=HTMLResponse)
async def users_list(request: Request, _=Depends(get_current_user)): async def users_list(request: Request, current=Depends(require_admin)):
users = get_all_users() users = get_all_users()
return templates.TemplateResponse(request=request, name="users.html", context={"users": users}) return render_template(templates, request, "users.html", {"users": users, "current_user": current})
@router.post("/users/create") @router.post("/users/create")
async def add_user(request: Request, username: str = Form(...), password: str = Form(...), _=Depends(get_current_user)): async def add_user(
request: Request,
username: str = Form(...),
password: str = Form(...),
role: str = Form("user"),
current=Depends(require_admin),
):
username = (username or "").strip()
if role not in {"admin", "user"}:
role = "user"
if len(password) < 8: if len(password) < 8:
users = get_all_users() users = get_all_users()
return templates.TemplateResponse(request=request, name="users.html", context={"users": users, "error": "Password must be at least 8 characters."}) return render_template(
templates,
request,
"users.html",
{"users": users, "current_user": current, "error": "Password must be at least 8 characters."},
)
if get_user_by_username(username): if get_user_by_username(username):
users = get_all_users() users = get_all_users()
return templates.TemplateResponse(request=request, name="users.html", context={"users": users, "error": f"User '{username}' already exists."}) return render_template(
create_user(username, password) templates,
request,
"users.html",
{"users": users, "current_user": current, "error": f"User '{username}' already exists."},
)
create_user(username, password, role=role)
return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/users/delete/{user_id}") @router.post("/users/delete/{user_id}")
async def remove_user(user_id: int, _=Depends(get_current_user)): async def remove_user(request: Request, user_id: int, current=Depends(require_admin)):
if user_id == current["user_id"]:
users = get_all_users()
return render_template(
templates,
request,
"users.html",
{"users": users, "current_user": current, "error": "لا يمكنك حذف حسابك الخاص."},
status_code=400,
)
# Prevent removing the last admin
target = next((u for u in get_all_users() if u["id"] == user_id), None)
if target and target["role"] == "admin" and count_admins() <= 1:
users = get_all_users()
return render_template(
templates,
request,
"users.html",
{"users": users, "current_user": current, "error": "لا يمكن حذف آخر مسؤول في النظام."},
status_code=400,
)
delete_user(user_id) delete_user(user_id)
return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/users/{user_id}/role")
async def change_role(
request: Request,
user_id: int,
role: str = Form(...),
current=Depends(require_admin),
):
if role not in {"admin", "user"}:
raise HTTPException(status_code=400, detail="invalid role")
# Prevent demoting the last admin
target = next((u for u in get_all_users() if u["id"] == user_id), None)
if target and target["role"] == "admin" and role != "admin" and count_admins() <= 1:
users = get_all_users()
return render_template(
templates,
request,
"users.html",
{"users": users, "current_user": current, "error": "لا يمكن تخفيض رتبة آخر مسؤول."},
status_code=400,
)
set_user_role(user_id, role)
return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/backup") @router.get("/backup")
async def backup_db(request: Request, _=Depends(get_current_user)): async def backup_db(request: Request, current=Depends(require_admin)):
try: try:
backup_path = create_backup() backup_path = create_backup()
return FileResponse(backup_path, media_type="application/octet-stream", filename=os.path.basename(backup_path)) return FileResponse(backup_path, media_type="application/octet-stream", filename=os.path.basename(backup_path))
except Exception as e: except Exception as e:
msg = f"Backup failed: {str(e)}" msg = f"Backup failed: {str(e)}"
return templates.TemplateResponse(request=request, name="users.html", context={ return render_template(
templates,
request,
"users.html",
{
"users": get_all_users(), "users": get_all_users(),
"backup_msg": msg "current_user": current,
}) "backup_msg": msg,
},
)
+26 -2
View File
@@ -7,6 +7,7 @@ from fastapi.templating import Jinja2Templates
from config import UPLOAD_DIR from config import UPLOAD_DIR
from database.connection import get_db from database.connection import get_db
from services.template_utils import render_template
from services.thumbnails import get_or_create_thumbnail from services.thumbnails import get_or_create_thumbnail
router = APIRouter() router = APIRouter()
@@ -103,7 +104,8 @@ async def document_queue(
total_pages = max(1, (total_filtered + PAGE_SIZE - 1) // PAGE_SIZE) total_pages = max(1, (total_filtered + PAGE_SIZE - 1) // PAGE_SIZE)
return templates.TemplateResponse( return render_template(
templates,
request, request,
"documents.html", "documents.html",
{ {
@@ -231,6 +233,27 @@ async def retry_all_errors():
return JSONResponse({"ok": True, "retried": len(rows)}) return JSONResponse({"ok": True, "retried": len(rows)})
@router.post("/documents/resume-pending")
async def resume_pending():
"""Re-fire background extraction for any docs stuck in 'pending'.
Useful when a previous server restart killed in-flight extraction tasks."""
import asyncio
from services.extractor import get_default_provider
from routers.upload import _extract_and_save
default_provider = get_default_provider()
with get_db() as conn:
rows = conn.execute(
"SELECT id, image_path, provider FROM documents WHERE status='pending'"
).fetchall()
for row in rows:
provider = row["provider"] or default_provider
asyncio.create_task(_extract_and_save(row["id"], row["image_path"], provider))
return JSONResponse({"ok": True, "resumed": len(rows)})
@router.post("/documents/scan-duplicates") @router.post("/documents/scan-duplicates")
async def scan_duplicates(): async def scan_duplicates():
""" """
@@ -368,7 +391,8 @@ async def duplicates_view(request: Request):
continue continue
groups.append({"keeper": keeper, "duplicates": dups_by_keeper.get(k, [])}) groups.append({"keeper": keeper, "duplicates": dups_by_keeper.get(k, [])})
return templates.TemplateResponse( return render_template(
templates,
request, request,
"duplicates.html", "duplicates.html",
{"groups": groups, "total_duplicates": len(dup_rows)}, {"groups": groups, "total_duplicates": len(dup_rows)},
+31 -2
View File
@@ -12,9 +12,11 @@ from services.extractor import (
extract_document, extract_document,
get_available_providers, get_available_providers,
get_default_provider, get_default_provider,
provider_supports_ai_verification,
verify_page_correlation, verify_page_correlation,
) )
from services.search_service import normalize_arabic, _normalize_scope from services.search_service import normalize_arabic, _normalize_scope
from services.template_utils import render_template
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -70,6 +72,16 @@ def _find_page1_candidate(conn, doc: dict) -> tuple[bool, dict | None]:
LIMIT 1""", LIMIT 1""",
(req_num, doc_scope, doc["id"]), (req_num, doc_scope, doc["id"]),
).fetchone() ).fetchone()
else:
sibling_doc = conn.execute(
"""SELECT * FROM documents
WHERE request_number=? AND (search_scope IS NULL OR TRIM(search_scope)='') AND id != ?
ORDER BY CASE WHEN page_number=1 THEN 0 ELSE 1 END,
CASE WHEN person_id IS NOT NULL THEN 0 ELSE 1 END,
id
LIMIT 1""",
(req_num, doc["id"]),
).fetchone()
return is_subsequent_page, dict(sibling_doc) if sibling_doc else None return is_subsequent_page, dict(sibling_doc) if sibling_doc else None
@@ -317,7 +329,8 @@ async def review_document(request: Request, doc_id: int, wait: int = 0):
</body></html>""" </body></html>"""
) )
return templates.TemplateResponse( return render_template(
templates,
request, request,
"review.html", "review.html",
{ {
@@ -326,7 +339,8 @@ async def review_document(request: Request, doc_id: int, wait: int = 0):
"providers": get_available_providers(), "providers": get_available_providers(),
"current_provider": doc.get("provider") or get_default_provider(), "current_provider": doc.get("provider") or get_default_provider(),
"ai_verification_available": any( "ai_verification_available": any(
provider["id"] in {"claude", "gemini"} for provider in get_available_providers() provider_supports_ai_verification(provider["id"])
for provider in get_available_providers()
), ),
}, },
) )
@@ -727,6 +741,11 @@ async def retrigger_extraction(doc_id: int, provider: str = ""):
use_provider = provider or doc["provider"] or get_default_provider() use_provider = provider or doc["provider"] or get_default_provider()
with get_db() as conn: with get_db() as conn:
old_person_id = conn.execute(
"SELECT person_id FROM documents WHERE id=?", (doc_id,)
).fetchone()
old_person_id = old_person_id["person_id"] if old_person_id else None
conn.execute( conn.execute(
"""UPDATE documents """UPDATE documents
SET status='pending', SET status='pending',
@@ -750,6 +769,16 @@ async def retrigger_extraction(doc_id: int, provider: str = ""):
) )
conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,)) conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,))
# Clean up orphaned person if this was their only document
if old_person_id:
remaining = conn.execute(
"SELECT COUNT(*) AS n FROM documents WHERE person_id=?",
(old_person_id,),
).fetchone()["n"]
if remaining == 0:
conn.execute("DELETE FROM properties WHERE person_id=?", (old_person_id,))
conn.execute("DELETE FROM persons WHERE id=?", (old_person_id,))
from routers.upload import _extract_and_save from routers.upload import _extract_and_save
asyncio.create_task(_extract_and_save(doc_id, doc["image_path"], use_provider)) asyncio.create_task(_extract_and_save(doc_id, doc["image_path"], use_provider))
return JSONResponse({"ok": True, "message": "Extraction started"}) return JSONResponse({"ok": True, "message": "Extraction started"})
+18 -13
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import Response from fastapi.responses import Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from services.template_utils import render_template
from services.search_service import ( from services.search_service import (
get_person_with_properties, get_person_with_properties,
@@ -30,14 +31,12 @@ async def search(
if property_number or district or block: if property_number or district or block:
properties = search_properties(property_number, district, block) properties = search_properties(property_number, district, block)
# If exactly one person found, preload their full details # If exactly one person found, preload their full details (across all scopes)
if len(persons) == 1 and not properties: if len(persons) == 1 and not properties:
selected_person = get_person_with_properties( selected_person = get_person_with_properties(persons[0]["id"], None)
persons[0]["id"],
persons[0].get("search_scope"),
)
return templates.TemplateResponse( return render_template(
templates,
request, request,
"search.html", "search.html",
{ {
@@ -56,17 +55,23 @@ async def search(
async def person_detail(request: Request, person_id: int, search_scope: str = ""): async def person_detail(request: Request, person_id: int, search_scope: str = ""):
data = get_person_with_properties(person_id, search_scope.strip() or None) data = get_person_with_properties(person_id, search_scope.strip() or None)
if not data: if not data:
return templates.TemplateResponse( return render_template(
templates,
request, request,
"search.html", "search.html",
{"error": "Person not found", "q": "", "persons": [], "properties": [], "selected_person": None, "property_number": "", "district": "", "block": ""}, {
"error": "Person not found",
"q": "",
"persons": [],
"properties": [],
"selected_person": None,
"property_number": "",
"district": "",
"block": "",
},
status_code=404, status_code=404,
) )
return templates.TemplateResponse( return render_template(templates, request, "person_detail.html", data)
request,
"person_detail.html",
data,
)
@router.get("/persons/{person_id}/export") @router.get("/persons/{person_id}/export")
+83 -7
View File
@@ -5,22 +5,62 @@ import uuid
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, File, Form, Request, UploadFile from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from config import UPLOAD_DIR from config import UPLOAD_DIR, MAX_CONCURRENT_EXTRACTIONS
from database.connection import get_db from database.connection import get_db
from services.extractor import extract_document, get_available_providers, get_default_provider from services.extractor import extract_document, get_available_providers, get_default_provider
from services.pdf_handler import pdf_to_images from services.pdf_handler import pdf_to_images
from services.template_utils import render_template
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
# Semaphore: limits simultaneous AI API calls to avoid quota exhaustion on bulk uploads
_extraction_semaphore: asyncio.Semaphore | None = None
def _get_semaphore() -> asyncio.Semaphore:
global _extraction_semaphore
if _extraction_semaphore is None:
_extraction_semaphore = asyncio.Semaphore(MAX_CONCURRENT_EXTRACTIONS)
return _extraction_semaphore
ALLOWED_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp"} ALLOWED_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
ALLOWED_PDF_EXTS = {".pdf"} ALLOWED_PDF_EXTS = {".pdf"}
ALLOWED_EXTENSIONS = ALLOWED_IMAGE_EXTS | ALLOWED_PDF_EXTS ALLOWED_EXTENSIONS = ALLOWED_IMAGE_EXTS | ALLOWED_PDF_EXTS
MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB per file
MAX_TOTAL_BYTES = 100 * 1024 * 1024 # 100 MB per request
def _check_request_size(request: Request) -> None:
"""Reject oversize uploads early via Content-Length header."""
cl = request.headers.get("content-length")
if cl and cl.isdigit() and int(cl) > MAX_TOTAL_BYTES:
raise HTTPException(
status_code=413,
detail=f"حجم الطلب يتجاوز الحد الأقصى ({MAX_TOTAL_BYTES // (1024*1024)} ميغابايت).",
)
async def _read_capped(upload: UploadFile, running_total: int) -> bytes:
"""Read upload bytes, enforcing per-file and cumulative caps."""
data = await upload.read()
if len(data) > MAX_FILE_BYTES:
raise HTTPException(
status_code=413,
detail=f"الملف '{upload.filename}' يتجاوز {MAX_FILE_BYTES // (1024*1024)} ميغابايت.",
)
if running_total + len(data) > MAX_TOTAL_BYTES:
raise HTTPException(
status_code=413,
detail=f"إجمالي حجم الرفع يتجاوز {MAX_TOTAL_BYTES // (1024*1024)} ميغابايت.",
)
return data
def _hash_bytes(data: bytes) -> str: def _hash_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest() return hashlib.sha256(data).hexdigest()
@@ -62,6 +102,7 @@ def _save_image(file_bytes: bytes, original_name: str) -> str:
async def _extract_and_save(doc_id: int, image_path: str, provider: str = ""): async def _extract_and_save(doc_id: int, image_path: str, provider: str = ""):
"""Background task: call extractor, parse result, update DB.""" """Background task: call extractor, parse result, update DB."""
async with _get_semaphore():
try: try:
data = await extract_document(image_path, provider=provider) data = await extract_document(image_path, provider=provider)
raw_json = json.dumps(data, ensure_ascii=False) raw_json = json.dumps(data, ensure_ascii=False)
@@ -164,12 +205,37 @@ async def upload_page(request: Request):
SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) AS errors SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) AS errors
FROM documents WHERE status != 'staged'""" FROM documents WHERE status != 'staged'"""
).fetchone() ).fetchone()
return templates.TemplateResponse( staged_rows = conn.execute(
request, "index.html", { """SELECT id, image_path, page_number
FROM documents
WHERE status='staged'
ORDER BY id"""
).fetchall()
staged_documents = []
for row in staged_rows:
image_name = Path(row["image_path"]).name
page_number = row["page_number"]
if page_number:
image_name = f"{image_name} (p{page_number})"
staged_documents.append(
{
"id": row["id"],
"image_path": row["image_path"],
"name": image_name,
}
)
return render_template(
templates,
request,
"index.html",
{
"stats": dict(stats) if stats else {}, "stats": dict(stats) if stats else {},
"staged_documents": staged_documents,
"providers": get_available_providers(), "providers": get_available_providers(),
"default_provider": get_default_provider(), "default_provider": get_default_provider(),
} },
) )
@@ -179,18 +245,21 @@ async def upload_files(
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
provider: str = Form(""), provider: str = Form(""),
): ):
_check_request_size(request)
if not provider: if not provider:
provider = get_default_provider() provider = get_default_provider()
doc_ids = [] doc_ids = []
duplicates = [] duplicates = []
total_bytes = 0
for upload in files: for upload in files:
suffix = Path(upload.filename).suffix.lower() suffix = Path(upload.filename).suffix.lower()
if suffix not in ALLOWED_EXTENSIONS: if suffix not in ALLOWED_EXTENSIONS:
continue continue
file_bytes = await upload.read() file_bytes = await _read_capped(upload, total_bytes)
total_bytes += len(file_bytes)
if suffix in ALLOWED_PDF_EXTS: if suffix in ALLOWED_PDF_EXTS:
# PDF: split into per-page images; hash each rendered page # PDF: split into per-page images; hash each rendered page
@@ -268,14 +337,17 @@ async def stage_file(
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
): ):
"""Save uploaded images without triggering AI extraction.""" """Save uploaded images without triggering AI extraction."""
_check_request_size(request)
staged = [] staged = []
duplicates = [] duplicates = []
total_bytes = 0
for upload in files: for upload in files:
suffix = Path(upload.filename).suffix.lower() suffix = Path(upload.filename).suffix.lower()
if suffix not in ALLOWED_EXTENSIONS: if suffix not in ALLOWED_EXTENSIONS:
continue continue
file_bytes = await upload.read() file_bytes = await _read_capped(upload, total_bytes)
total_bytes += len(file_bytes)
if suffix in ALLOWED_PDF_EXTS: if suffix in ALLOWED_PDF_EXTS:
pages = pdf_to_images(file_bytes, upload.filename) pages = pdf_to_images(file_bytes, upload.filename)
@@ -347,12 +419,16 @@ async def process_staged(
provider = get_default_provider() provider = get_default_provider()
ids = [int(x) for x in doc_ids.split(",") if x.strip().isdigit()] ids = [int(x) for x in doc_ids.split(",") if x.strip().isdigit()]
if not ids:
return RedirectResponse("/?staged=0", status_code=303)
with get_db() as conn: with get_db() as conn:
rows = conn.execute( rows = conn.execute(
f"SELECT id, image_path FROM documents WHERE id IN ({','.join('?' * len(ids))}) AND status='staged'", f"SELECT id, image_path FROM documents WHERE id IN ({','.join('?' * len(ids))}) AND status='staged'",
ids, ids,
).fetchall() ).fetchall()
if not rows:
return RedirectResponse("/?staged=0", status_code=303)
for row in rows: for row in rows:
conn.execute( conn.execute(
"UPDATE documents SET status='pending', provider=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", "UPDATE documents SET status='pending', provider=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
+120 -12
View File
@@ -4,47 +4,148 @@ import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
SESSION_TTL_SECONDS = 86400 # 24h SESSION_TTL_SECONDS = 86400 # 24h
PBKDF2_ITERATIONS = 600_000 # OWASP 2023+ guidance for PBKDF2-HMAC-SHA256
def hash_password(password: str) -> str: # Login rate-limit tuning
LOGIN_WINDOW_SECONDS = 900 # 15 min sliding window
LOGIN_MAX_FAILS_USER = 5 # per username
LOGIN_MAX_FAILS_IP = 20 # per IP (higher because NATs)
def hash_password(password: str, iterations: int = PBKDF2_ITERATIONS) -> str:
salt = secrets.token_hex(16) salt = secrets.token_hex(16)
hashed = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt.encode('utf-8'), 100000).hex() hashed = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt.encode('utf-8'), iterations).hex()
return f"{salt}${hashed}" return f"pbkdf2${iterations}${salt}${hashed}"
def verify_password(stored_password: str, provided_password: str) -> bool: def verify_password(stored_password: str, provided_password: str) -> bool:
"""Supports both the legacy 'salt$hash' (100k iter) format and the new
'pbkdf2$<iter>$salt$hash' format."""
try: try:
salt, stored_hash = stored_password.split('$') parts = stored_password.split('$')
hashed = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt.encode('utf-8'), 100000).hex() if len(parts) == 4 and parts[0] == 'pbkdf2':
iterations = int(parts[1])
salt = parts[2]
stored_hash = parts[3]
elif len(parts) == 2:
iterations = 100_000 # legacy
salt, stored_hash = parts
else:
return False
hashed = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt.encode('utf-8'), iterations).hex()
return hmac.compare_digest(hashed, stored_hash) return hmac.compare_digest(hashed, stored_hash)
except Exception: except Exception:
return False return False
def needs_rehash(stored_password: str) -> bool:
"""Return True if the stored hash uses weaker params than current defaults."""
try:
parts = stored_password.split('$')
if len(parts) == 4 and parts[0] == 'pbkdf2':
return int(parts[1]) < PBKDF2_ITERATIONS
return True # legacy format → rehash
except Exception:
return True
def get_user_by_username(username: str): def get_user_by_username(username: str):
from database.connection import get_db from database.connection import get_db
with get_db() as conn: with get_db() as conn:
cursor = conn.execute("SELECT * FROM users WHERE username = ?", (username,)) cursor = conn.execute("SELECT * FROM users WHERE username = ?", (username,))
return cursor.fetchone() return cursor.fetchone()
def create_user(username: str, password: str): def create_user(username: str, password: str, role: str = "user"):
from database.connection import get_db from database.connection import get_db
if role not in {"admin", "user"}:
role = "user"
with get_db() as conn: with get_db() as conn:
hashed = hash_password(password) hashed = hash_password(password)
conn.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", (username, hashed)) conn.execute(
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
(username, hashed, role),
)
def delete_user(user_id: int): def delete_user(user_id: int):
from database.connection import get_db from database.connection import get_db
with get_db() as conn: with get_db() as conn:
conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
def update_user_password(user_id: int, new_password: str) -> None:
from database.connection import get_db
with get_db() as conn:
conn.execute(
"UPDATE users SET password_hash=? WHERE id=?",
(hash_password(new_password), user_id),
)
# Rotate any active sessions for this user.
conn.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
def set_user_role(user_id: int, role: str) -> None:
from database.connection import get_db
if role not in {"admin", "user"}:
raise ValueError("invalid role")
with get_db() as conn:
conn.execute("UPDATE users SET role=? WHERE id=?", (role, user_id))
def count_admins() -> int:
from database.connection import get_db
with get_db() as conn:
row = conn.execute("SELECT COUNT(*) AS n FROM users WHERE role='admin'").fetchone()
return int(row["n"]) if row else 0
def get_all_users(): def get_all_users():
from database.connection import get_db from database.connection import get_db
with get_db() as conn: with get_db() as conn:
cursor = conn.execute("SELECT id, username, created_at FROM users ORDER BY created_at DESC") cursor = conn.execute(
"SELECT id, username, role, created_at FROM users ORDER BY created_at DESC"
)
return cursor.fetchall() return cursor.fetchall()
# ─── Login rate-limiting ──────────────────────────────────────────
def record_login_attempt(username: str, ip: str, success: bool) -> None:
from database.connection import get_db
with get_db() as conn:
conn.execute(
"INSERT INTO login_attempts (username, ip, success) VALUES (?, ?, ?)",
(username, ip, 1 if success else 0),
)
def is_login_blocked(username: str, ip: str) -> bool:
"""Return True if too many recent failures for this username OR this IP."""
from database.connection import get_db
cutoff = (datetime.utcnow() - timedelta(seconds=LOGIN_WINDOW_SECONDS)).strftime("%Y-%m-%d %H:%M:%S")
with get_db() as conn:
by_user = conn.execute(
"SELECT COUNT(*) AS n FROM login_attempts WHERE username=? AND success=0 AND created_at > ?",
(username, cutoff),
).fetchone()["n"]
if by_user >= LOGIN_MAX_FAILS_USER:
return True
by_ip = conn.execute(
"SELECT COUNT(*) AS n FROM login_attempts WHERE ip=? AND success=0 AND created_at > ?",
(ip, cutoff),
).fetchone()["n"]
return by_ip >= LOGIN_MAX_FAILS_IP
def cleanup_old_login_attempts() -> int:
from database.connection import get_db
cutoff = (datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S")
with get_db() as conn:
cur = conn.execute("DELETE FROM login_attempts WHERE created_at < ?", (cutoff,))
return cur.rowcount or 0
# ─── Sessions (DB-backed) ───────────────────────────────────────── # ─── Sessions (DB-backed) ─────────────────────────────────────────
def create_session(user_id: int, username: str, ttl_seconds: int = SESSION_TTL_SECONDS) -> str: def create_session(user_id: int, username: str, role: str = "user", ttl_seconds: int = SESSION_TTL_SECONDS) -> str:
from database.connection import get_db from database.connection import get_db
session_id = secrets.token_urlsafe(32) session_id = secrets.token_urlsafe(32)
expires_at = (datetime.utcnow() + timedelta(seconds=ttl_seconds)).strftime("%Y-%m-%d %H:%M:%S") expires_at = (datetime.utcnow() + timedelta(seconds=ttl_seconds)).strftime("%Y-%m-%d %H:%M:%S")
@@ -57,17 +158,24 @@ def create_session(user_id: int, username: str, ttl_seconds: int = SESSION_TTL_S
def get_session(session_id: str): def get_session(session_id: str):
"""Return dict with user_id/username if session is valid and unexpired, else None.""" """Return dict with user_id/username/role if valid and unexpired, else None."""
if not session_id: if not session_id:
return None return None
from database.connection import get_db from database.connection import get_db
now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
with get_db() as conn: with get_db() as conn:
row = conn.execute( row = conn.execute(
"SELECT id, user_id, username, expires_at FROM sessions WHERE id = ? AND expires_at > ?", """SELECT s.id, s.user_id, s.username, s.expires_at, u.role
FROM sessions s
LEFT JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > ?""",
(session_id, now), (session_id, now),
).fetchone() ).fetchone()
return dict(row) if row else None if not row:
return None
d = dict(row)
d["role"] = d.get("role") or "user"
return d
def delete_session(session_id: str) -> None: def delete_session(session_id: str) -> None:
+50 -14
View File
@@ -1,4 +1,5 @@
import base64 import base64
import importlib.util
import json import json
import re import re
from pathlib import Path from pathlib import Path
@@ -61,7 +62,7 @@ Rules:
def _get_ai_verification_provider(preferred_provider: str = "") -> str: def _get_ai_verification_provider(preferred_provider: str = "") -> str:
"""Return a provider capable of vision reasoning for verification.""" """Return a provider capable of vision reasoning for verification."""
providers = [p["id"] for p in get_available_providers() if p["id"] in {"claude", "gemini"}] providers = [p["id"] for p in get_available_providers() if provider_supports_ai_verification(p["id"])]
if preferred_provider in providers: if preferred_provider in providers:
return preferred_provider return preferred_provider
if DEFAULT_PROVIDER in providers: if DEFAULT_PROVIDER in providers:
@@ -71,6 +72,10 @@ def _get_ai_verification_provider(preferred_provider: str = "") -> str:
raise ValueError("No AI verification provider configured") raise ValueError("No AI verification provider configured")
def provider_supports_ai_verification(provider_id: str) -> bool:
return provider_id == "claude" or provider_id == "gemini" or provider_id.startswith("gemini-")
def _build_correlation_user_prompt(current_context: dict, candidate_context: dict) -> str: def _build_correlation_user_prompt(current_context: dict, candidate_context: dict) -> str:
return ( return (
"Determine whether PAGE_A and PAGE_B belong to the same multi-page request/document for the same person. " "Determine whether PAGE_A and PAGE_B belong to the same multi-page request/document for the same person. "
@@ -249,15 +254,29 @@ def _parse_json_lenient(text: str) -> dict:
raise ValueError(f"Could not parse model response as JSON: {text[:200]}") raise ValueError(f"Could not parse model response as JSON: {text[:200]}")
def get_available_providers() -> list[dict]: # Individual Gemini models exposed to the UI
"""Return list of all available providers (EasyOCR is always available).""" _GEMINI_MODEL_OPTIONS = [
providers = [ ("gemini-2.5-pro", "Gemini 2.5 Pro (أفضل دقة)"),
{"id": "easyocr", "name": "EasyOCR (مجاني)", "model": "local"}, ("gemini-2.5-flash", "Gemini 2.5 Flash (سريع)"),
("gemini-2.0-flash", "Gemini 2.0 Flash"),
("gemini-2.0-flash-lite", "Gemini 2.0 Flash Lite (احتياطي)"),
] ]
def _easyocr_is_available() -> bool:
return importlib.util.find_spec("easyocr") is not None
def get_available_providers() -> list[dict]:
"""Return only providers that are actually usable in the current runtime."""
providers = []
if _easyocr_is_available():
providers.append({"id": "easyocr", "name": "EasyOCR (مجاني)", "model": "local"})
if ANTHROPIC_API_KEY: if ANTHROPIC_API_KEY:
providers.append({"id": "claude", "name": "Claude (Anthropic)", "model": CLAUDE_MODEL}) providers.append({"id": "claude", "name": "Claude (Anthropic)", "model": CLAUDE_MODEL})
if GEMINI_API_KEY: if GEMINI_API_KEY:
providers.append({"id": "gemini", "name": "Gemini (Google)", "model": GEMINI_MODEL}) for model_id, label in _GEMINI_MODEL_OPTIONS:
providers.append({"id": model_id, "name": label, "model": model_id})
return providers return providers
@@ -265,8 +284,12 @@ def get_default_provider() -> str:
"""Return the default provider, falling back to whichever is available.""" """Return the default provider, falling back to whichever is available."""
providers = get_available_providers() providers = get_available_providers()
if not providers: if not providers:
return "easyocr" return GEMINI_MODEL if DEFAULT_PROVIDER == "gemini" else DEFAULT_PROVIDER
ids = [p["id"] for p in providers] ids = [p["id"] for p in providers]
# Map legacy "gemini" default to the configured GEMINI_MODEL
resolved = GEMINI_MODEL if DEFAULT_PROVIDER == "gemini" else DEFAULT_PROVIDER
if resolved in ids:
return resolved
if DEFAULT_PROVIDER in ids: if DEFAULT_PROVIDER in ids:
return DEFAULT_PROVIDER return DEFAULT_PROVIDER
return ids[0] return ids[0]
@@ -329,7 +352,7 @@ async def _extract_with_claude(image_path: str) -> dict:
# ─── Gemini extraction ─────────────────────────────────────────── # ─── Gemini extraction ───────────────────────────────────────────
async def _extract_with_gemini(image_path: str) -> dict: async def _extract_with_gemini(image_path: str, model: str = "") -> dict:
import asyncio import asyncio
from google import genai from google import genai
from google.genai import types from google.genai import types
@@ -345,8 +368,17 @@ async def _extract_with_gemini(image_path: str) -> dict:
".png": "image/png", ".webp": "image/webp"} ".png": "image/png", ".webp": "image/webp"}
mime_type = mime_map.get(suffix, "image/jpeg") mime_type = mime_map.get(suffix, "image/jpeg")
# Try primary model first; only fall back on 503 (overloaded), NOT on 429 (quota) # Full fallback chain: start from requested model, cascade through cheaper/available ones
models_to_try = [GEMINI_MODEL, "gemini-2.0-flash-lite"] _ALL_GEMINI_FALLBACK = [
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
]
# If a specific model was requested, start from it; otherwise start from the configured default
start_model = model if model else GEMINI_MODEL
# Build ordered list: requested model first, then remaining fallbacks in order
models_to_try = [start_model] + [m for m in _ALL_GEMINI_FALLBACK if m != start_model]
def _call(model_name: str): def _call(model_name: str):
response = client.models.generate_content( response = client.models.generate_content(
@@ -731,6 +763,8 @@ async def extract_document(image_path: str, provider: str = "") -> dict:
""" """
Extract structured data from a document image using the specified provider. Extract structured data from a document image using the specified provider.
Falls back to the default provider if none specified. Falls back to the default provider if none specified.
provider can be: "claude", "easyocr", "gemini" (uses GEMINI_MODEL),
or a specific Gemini model ID like "gemini-2.5-flash".
""" """
if not provider: if not provider:
provider = get_default_provider() provider = get_default_provider()
@@ -739,11 +773,13 @@ async def extract_document(image_path: str, provider: str = "") -> dict:
if not ANTHROPIC_API_KEY: if not ANTHROPIC_API_KEY:
raise ValueError("ANTHROPIC_API_KEY not set") raise ValueError("ANTHROPIC_API_KEY not set")
return await _extract_with_claude(image_path) return await _extract_with_claude(image_path)
elif provider == "gemini":
if not GEMINI_API_KEY:
raise ValueError("GEMINI_API_KEY not set")
return await _extract_with_gemini(image_path)
elif provider == "easyocr": elif provider == "easyocr":
return await _extract_with_easyocr(image_path) return await _extract_with_easyocr(image_path)
elif provider == "gemini" or provider.startswith("gemini-"):
if not GEMINI_API_KEY:
raise ValueError("GEMINI_API_KEY not set")
# Pass the specific model if the provider ID encodes one
model_override = provider if provider.startswith("gemini-") else ""
return await _extract_with_gemini(image_path, model=model_override)
else: else:
raise ValueError(f"Unknown provider: {provider}") raise ValueError(f"Unknown provider: {provider}")
+15 -4
View File
@@ -6,25 +6,35 @@ from pathlib import Path
from config import UPLOAD_DIR from config import UPLOAD_DIR
# Maximum pages we will extract from a single PDF upload.
# Beyond this limit pages are silently dropped to prevent runaway queue growth.
MAX_PDF_PAGES = 200
def pdf_to_images(pdf_bytes: bytes, original_name: str) -> list[dict]: def pdf_to_images(pdf_bytes: bytes, original_name: str) -> list[dict]:
""" """
Convert a PDF to individual page images. Convert a PDF to individual page images.
Returns list of dicts: [{"image_path": "relative/path.png", "page_number": 1}, ...]
Each page is rendered at 250 DPI — sufficient for Arabic OCR while keeping
file sizes manageable. For scanned/image-only PDFs PyMuPDF simply renders
the embedded raster; no additional image extraction step is needed.
Returns list of dicts: [{"image_path": "relative/path.png", "page_number": 1, ...}, ...]
""" """
import fitz # PyMuPDF import fitz # PyMuPDF
doc = fitz.open(stream=pdf_bytes, filetype="pdf") doc = fitz.open(stream=pdf_bytes, filetype="pdf")
total_pages = len(doc)
group_id = uuid.uuid4().hex group_id = uuid.uuid4().hex
today = date.today().isoformat() today = date.today().isoformat()
dest_dir = Path(UPLOAD_DIR) / today dest_dir = Path(UPLOAD_DIR) / today
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
pages = [] pages = []
for page_num in range(len(doc)): for page_num in range(min(total_pages, MAX_PDF_PAGES)):
page = doc[page_num] page = doc[page_num]
# Render at 200 DPI for good OCR quality # 250 DPI gives sharper Arabic text while staying under ~2 MB per PNG
pix = page.get_pixmap(dpi=200) pix = page.get_pixmap(dpi=250)
filename = f"{group_id}_p{page_num + 1}.png" filename = f"{group_id}_p{page_num + 1}.png"
dest = dest_dir / filename dest = dest_dir / filename
@@ -34,6 +44,7 @@ def pdf_to_images(pdf_bytes: bytes, original_name: str) -> list[dict]:
"image_path": str(Path(today) / filename), "image_path": str(Path(today) / filename),
"page_number": page_num + 1, "page_number": page_num + 1,
"pdf_group_id": group_id, "pdf_group_id": group_id,
"total_pages": total_pages,
}) })
doc.close() doc.close()
+16 -7
View File
@@ -20,7 +20,7 @@ def _normalize_scope(text: str | None) -> str | None:
def search_persons(query: str) -> list[dict]: def search_persons(query: str) -> list[dict]:
"""Search persons by name, keeping separate result rows per search scope.""" """Search persons by name. One row per person, aggregating all their search scopes."""
norm = normalize_arabic(query.strip()) norm = normalize_arabic(query.strip())
pattern = f"%{norm}%" pattern = f"%{norm}%"
raw_pattern = f"%{query.strip()}%" raw_pattern = f"%{query.strip()}%"
@@ -30,9 +30,9 @@ def search_persons(query: str) -> list[dict]:
rows = conn.execute( rows = conn.execute(
f""" f"""
SELECT p.*, SELECT p.*,
{scope_expr} AS search_scope,
COUNT(DISTINCT pr.id) AS property_count, COUNT(DISTINCT pr.id) AS property_count,
COUNT(DISTINCT d.id) AS document_count COUNT(DISTINCT d.id) AS document_count,
GROUP_CONCAT(DISTINCT {scope_expr}) AS search_scopes_raw
FROM persons p FROM persons p
LEFT JOIN documents d ON d.person_id = p.id LEFT JOIN documents d ON d.person_id = p.id
LEFT JOIN properties pr ON pr.document_id = d.id LEFT JOIN properties pr ON pr.document_id = d.id
@@ -41,14 +41,13 @@ def search_persons(query: str) -> list[dict]:
OR p.father_name LIKE ? OR p.father_name LIKE ?
OR p.first_name LIKE ? OR p.first_name LIKE ?
OR p.family_name LIKE ? OR p.family_name LIKE ?
GROUP BY p.id, {scope_expr} GROUP BY p.id
ORDER BY ORDER BY
CASE WHEN p.first_name_norm = ? THEN 0 CASE WHEN p.first_name_norm = ? THEN 0
WHEN p.family_name_norm = ? THEN 0 WHEN p.family_name_norm = ? THEN 0
ELSE 1 END, ELSE 1 END,
p.first_name, p.first_name,
p.family_name, p.family_name
search_scope
""", """,
(pattern, pattern, raw_pattern, raw_pattern, raw_pattern, norm, norm), (pattern, pattern, raw_pattern, raw_pattern, raw_pattern, norm, norm),
).fetchall() ).fetchall()
@@ -56,7 +55,17 @@ def search_persons(query: str) -> list[dict]:
results = [] results = []
for row in rows: for row in rows:
person = dict(row) person = dict(row)
person["search_scope"] = _normalize_scope(person.get("search_scope")) raw = person.pop("search_scopes_raw", None) or ""
scopes = [s.strip() for s in raw.split(",") if s and s.strip()]
# Deduplicate while preserving order
seen = set()
unique_scopes = []
for s in scopes:
if s not in seen:
seen.add(s)
unique_scopes.append(s)
person["search_scopes"] = unique_scopes
person["search_scope"] = "، ".join(unique_scopes) if unique_scopes else None
results.append(person) results.append(person)
return results return results
+13
View File
@@ -0,0 +1,13 @@
import inspect
from collections.abc import Mapping
def render_template(templates, request, template_name: str, context: Mapping | None = None, **response_kwargs):
context_dict = dict(context or {})
parameters = list(inspect.signature(templates.TemplateResponse).parameters)
if parameters and parameters[0] == "request":
return templates.TemplateResponse(request, template_name, context_dict, **response_kwargs)
context_dict.setdefault("request", request)
return templates.TemplateResponse(template_name, context_dict, **response_kwargs)
+138 -79
View File
@@ -1285,13 +1285,31 @@ a.stat.active { border-color: rgba(15,118,110,.24); background: var(--primary-so
font-size: .95rem; font-size: .95rem;
} }
/* Stats */ /* Stats → horizontal compact pill strip on mobile */
.stats-bar { gap: .5rem; } .stats-bar {
.stat { min-width: 75px; padding: .5rem .65rem; } gap: .4rem;
.stat-num { font-size: 1.15rem; } flex-wrap: nowrap;
.stats-bar .stat { flex: 1 1 calc(50% - .5rem); } overflow-x: auto;
-webkit-overflow-scrolling: touch;
/* Quick links — single column */ margin-inline: -.75rem;
padding: .1rem .75rem .25rem;
scrollbar-width: none;
}
.stats-bar::-webkit-scrollbar { display: none; }
.stat {
flex: 0 0 auto;
min-width: 0;
flex-direction: row;
align-items: baseline;
gap: .35rem;
padding: .4rem .7rem;
border-radius: 999px;
box-shadow: none;
white-space: nowrap;
}
.stat-num { font-size: .95rem; font-weight: 800; }
.stat-label { font-size: .72rem; margin-top: 0; }
a.stat:hover { transform: none; box-shadow: none; } /* Quick links — single column */
.quick-links { grid-template-columns: 1fr; } .quick-links { grid-template-columns: 1fr; }
/* Upload card */ /* Upload card */
@@ -1380,79 +1398,8 @@ a.stat.active { border-color: rgba(15,118,110,.24); background: var(--primary-so
.doc-thumbnails { gap: .5rem; } .doc-thumbnails { gap: .5rem; }
.doc-thumb { width: 100px; } .doc-thumb { width: 100px; }
.doc-thumb img { height: 70px; } .doc-thumb img { height: 70px; }
.stats-bar .stat { flex-basis: 100%; } .stats-bar .stat { flex: 0 0 auto; }
.responsive-table {
border-collapse: separate;
border-spacing: 0;
background: transparent;
}
.responsive-table thead {
display: none;
}
.responsive-table tbody,
.responsive-table tr,
.responsive-table td {
display: block;
width: 100%;
}
.responsive-table tbody {
padding: .35rem .75rem .85rem;
}
.responsive-table tr {
margin-bottom: .75rem;
border: 1px solid rgba(23,37,44,.08);
border-radius: 18px;
background: rgba(255,255,255,.96);
box-shadow: 0 10px 24px rgba(23,37,44,.08);
overflow: hidden;
}
.responsive-table td {
display: grid;
grid-template-columns: minmax(92px, 110px) 1fr;
gap: .65rem;
align-items: center;
padding: .7rem .9rem;
text-align: right;
border-bottom: 1px solid rgba(23,37,44,.06);
}
.responsive-table td:last-child {
border-bottom: none;
}
.responsive-table td[data-label]::before {
display: block;
color: var(--text-muted);
font-size: .76rem;
font-weight: 700;
}
.responsive-table .doc-actions,
.responsive-table td[data-label="الإجراءات"] {
display: flex;
flex-wrap: wrap;
justify-content: stretch;
gap: .5rem;
}
.responsive-table td[data-label="الإجراءات"]::before {
width: 100%;
}
.responsive-table .doc-actions .btn,
.responsive-table td[data-label="الإجراءات"] .btn,
.responsive-table td[data-label="الإجراءات"] .inline-form,
.responsive-table td[data-label="الإجراءات"] .inline-form .btn {
flex: 1 1 100%;
width: 100%;
}
.responsive-table .doc-thumb-mini {
width: 72px;
height: 54px;
}
.ownership-alert td {
background: transparent;
}
.ownership-alert {
border-color: #fecaca;
background: #fff8f8;
}
.mobile-dock { .mobile-dock {
right: .5rem; right: .5rem;
left: .5rem; left: .5rem;
@@ -1465,6 +1412,118 @@ a.stat.active { border-color: rgba(15,118,110,.24); background: var(--primary-so
} }
} }
/* ============================
Responsive tables — keep desktop table layout on mobile,
just shrink padding/fonts and allow horizontal scroll
if content still overflows the viewport margins.
============================ */
@media (max-width: 640px) {
/* Table wrapper extends to viewport edges for maximum usable width */
.table-wrapper,
.table-wrapper-spaced {
margin-inline: -.75rem;
border-radius: 0;
border-left: none;
border-right: none;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.responsive-table {
width: 100%;
min-width: max-content; /* horizontal scroll kicks in only if needed */
border-collapse: collapse;
background: transparent;
font-size: .78rem;
}
/* Show the header (desktop look) */
.responsive-table thead { display: table-header-group; }
.responsive-table tbody { display: table-row-group; }
.responsive-table tr { display: table-row; }
.responsive-table td,
.responsive-table th {
display: table-cell;
padding: .4rem .45rem;
font-size: .78rem;
line-height: 1.3;
white-space: nowrap;
vertical-align: middle;
border-bottom: 1px solid rgba(23,37,44,.06);
}
/* Hide the ::before data-labels (desktop mode shows real headers) */
.responsive-table td[data-label]::before {
display: none;
content: none;
}
/* Row index column stays compact */
.responsive-table td[data-label="#"] {
color: var(--text-muted);
font-weight: 700;
width: 2rem;
text-align: center;
}
/* Property number highlighted, like a badge */
.responsive-table td[data-label="رقم العقار"],
.responsive-table td.prop-num {
font-weight: 800;
color: var(--primary-dark);
}
/* Ownership-alert rows keep their desktop visual flag */
.ownership-alert td {
background: #fff8f8;
}
/* Actions cell: keep buttons inline but smaller */
.responsive-table .doc-actions {
display: inline-flex;
gap: .25rem;
flex-wrap: nowrap;
}
.responsive-table .doc-actions .btn,
.responsive-table td[data-label="الإجراءات"] .btn {
padding: .3rem .5rem;
font-size: .72rem;
}
.responsive-table .doc-thumb-mini {
width: 44px;
height: 34px;
}
/* Persons grid → flat list on mobile */
.persons-grid {
display: flex;
flex-direction: column;
gap: 0;
margin-inline: -.75rem;
margin-top: .5rem;
border-top: 1px solid rgba(23,37,44,.08);
border-bottom: 1px solid rgba(23,37,44,.08);
background: #fff;
}
.person-card {
border: none;
border-bottom: 1px solid rgba(23,37,44,.06);
border-radius: 0;
padding: .7rem .9rem;
box-shadow: none;
background: transparent;
display: flex;
flex-direction: column;
gap: .15rem;
}
.person-card:last-child { border-bottom: none; }
.person-card:hover { transform: none; box-shadow: none; }
.person-card::before { display: none; }
.person-name { font-size: .95rem; font-weight: 700; margin: 0; }
.person-meta { font-size: .78rem; }
.person-reg { font-size: .72rem; margin-top: 0; }
}
/* ============================ /* ============================
Upload & Staging (mobile-first) Upload & Staging (mobile-first)
============================ */ ============================ */
+2
View File
@@ -50,10 +50,12 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
قائمة الوثائق قائمة الوثائق
</a> </a>
{% if request.state.user and request.state.user.role == 'admin' %}
<a href="/auth/users" class="nav-link"> <a href="/auth/users" class="nav-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15c-4 0-7 2-7 4v1h14v-1c0-2-3-4-7-4z"/><circle cx="12" cy="8" r="4"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15c-4 0-7 2-7 4v1h14v-1c0-2-3-4-7-4z"/><circle cx="12" cy="8" r="4"/></svg>
الإدارة الإدارة
</a> </a>
{% endif %}
<a href="/auth/logout" class="nav-link nav-link-logout"> <a href="/auth/logout" class="nav-link nav-link-logout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
خروج خروج
+22
View File
@@ -58,6 +58,12 @@
</div> </div>
{% endif %} {% endif %}
{% if stats.processing %}
<div class="alert-banner">
<button class="btn btn-sm btn-primary" onclick="resumePending(this)">استئناف معالجة الوثائق المعلّقة ({{ stats.processing }})</button>
</div>
{% endif %}
{% if stats.pending_review %} {% if stats.pending_review %}
<div class="alert-banner"> <div class="alert-banner">
<a href="/review/next">ابدأ مراجعة {{ stats.pending_review }} وثيقة →</a> <a href="/review/next">ابدأ مراجعة {{ stats.pending_review }} وثيقة →</a>
@@ -182,6 +188,22 @@ async function retryAllErrors(btn) {
} }
} }
async function resumePending(btn) {
btn.disabled = true;
const original = btn.textContent;
btn.textContent = 'جارٍ الاستئناف...';
const res = await fetch('/documents/resume-pending', { method: 'POST' });
if (res.ok) {
const data = await res.json();
alert(`تم استئناف ${data.resumed} وثيقة. قد تستغرق المعالجة بضع لحظات.`);
location.reload();
} else {
alert('فشل الاستئناف.');
btn.disabled = false;
btn.textContent = original;
}
}
async function scanDuplicates(btn) { async function scanDuplicates(btn) {
btn.disabled = true; btn.disabled = true;
const original = btn.textContent; const original = btn.textContent;
+12 -1
View File
@@ -124,7 +124,7 @@
{% block scripts %} {% block scripts %}
<script> <script>
// ─── Unified staging workflow ───────────────────────────────── // ─── Unified staging workflow ─────────────────────────────────
const stagedItems = []; // {id, image_path, name} const stagedItems = {{ staged_documents|tojson }}; // {id, image_path, name}
let uploading = false; let uploading = false;
const cameraInput = document.getElementById('cameraInput'); const cameraInput = document.getElementById('cameraInput');
@@ -209,6 +209,14 @@ function finishThumb(thumb, item) {
`; `;
} }
function renderExistingStaged() {
gallery.innerHTML = '';
for (const item of stagedItems) {
const thumb = addPlaceholder(item.name);
finishThumb(thumb, item);
}
}
async function removeStagedItem(id, btn) { async function removeStagedItem(id, btn) {
const thumb = btn.closest('.staging-thumb'); const thumb = btn.closest('.staging-thumb');
thumb.classList.add('removing'); thumb.classList.add('removing');
@@ -266,5 +274,8 @@ async function clearStaged() {
gallery.innerHTML = ''; gallery.innerHTML = '';
refreshUI(); refreshUI();
} }
renderExistingStaged();
refreshUI();
</script> </script>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -144,7 +144,7 @@
{% else %} {% else %}
<div class="persons-grid"> <div class="persons-grid">
{% for p in persons %} {% for p in persons %}
<a href="/persons/{{ p.id }}{% if p.search_scope %}?search_scope={{ p.search_scope|urlencode }}{% endif %}" class="person-card"> <a href="/persons/{{ p.id }}" class="person-card">
<div class="person-name">{{ p.first_name }} {{ p.father_name or '' }} {{ p.family_name or '' }}</div> <div class="person-name">{{ p.first_name }} {{ p.father_name or '' }} {{ p.family_name or '' }}</div>
<div class="person-meta"> <div class="person-meta">
{% if p.family_origin %}{{ p.family_origin }} · {% endif %} {% if p.family_origin %}{{ p.family_origin }} · {% endif %}
+31 -2
View File
@@ -15,6 +15,9 @@
{% if backup_msg %} {% if backup_msg %}
<div class="success-banner">{{ backup_msg }}</div> <div class="success-banner">{{ backup_msg }}</div>
{% endif %} {% endif %}
{% if error %}
<div class="error-banner" style="background:#fee2e2;color:#991b1b;padding:.75rem 1rem;border-radius:8px;margin:.75rem 0">{{ error }}</div>
{% endif %}
<div class="admin-section"> <div class="admin-section">
<h3> <h3>
@@ -37,7 +40,14 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">كلمة المرور</label> <label for="password">كلمة المرور</label>
<input type="password" id="password" name="password" required> <input type="password" id="password" name="password" required minlength="8">
</div>
<div class="form-group">
<label for="role">الصلاحية</label>
<select id="role" name="role">
<option value="user">مستخدم</option>
<option value="admin">مسؤول</option>
</select>
</div> </div>
<button type="submit" class="btn btn-primary">إضافة</button> <button type="submit" class="btn btn-primary">إضافة</button>
</form> </form>
@@ -54,6 +64,7 @@
<tr> <tr>
<th>الرقم</th> <th>الرقم</th>
<th>اسم المستخدم</th> <th>اسم المستخدم</th>
<th>الصلاحية</th>
<th>تاريخ الإنشاء</th> <th>تاريخ الإنشاء</th>
<th></th> <th></th>
</tr> </tr>
@@ -62,12 +73,30 @@
{% for user in users %} {% for user in users %}
<tr> <tr>
<td data-label="الرقم">{{ user.id }}</td> <td data-label="الرقم">{{ user.id }}</td>
<td data-label="اسم المستخدم">{{ user.username }}</td> <td data-label="اسم المستخدم">
{{ user.username }}
{% if current_user and user.id == current_user.user_id %}<small style="color:#64748b">(أنت)</small>{% endif %}
</td>
<td data-label="الصلاحية">
<form method="post" action="/auth/users/{{ user.id }}/role" class="inline-form" style="display:flex;gap:.35rem;align-items:center">
<select name="role" {% if current_user and user.id == current_user.user_id %}disabled{% endif %}>
<option value="user" {% if user.role == 'user' %}selected{% endif %}>مستخدم</option>
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>مسؤول</option>
</select>
{% if not (current_user and user.id == current_user.user_id) %}
<button type="submit" class="btn btn-sm btn-secondary">تغيير</button>
{% endif %}
</form>
</td>
<td data-label="تاريخ الإنشاء">{{ user.created_at }}</td> <td data-label="تاريخ الإنشاء">{{ user.created_at }}</td>
<td data-label="الإجراءات"> <td data-label="الإجراءات">
{% if current_user and user.id == current_user.user_id %}
<span style="color:#64748b;font-size:.85rem"></span>
{% else %}
<form method="post" action="/auth/users/delete/{{ user.id }}" onsubmit="return confirm('هل أنت متأكد من حذف هذا المستخدم؟');" class="inline-form"> <form method="post" action="/auth/users/delete/{{ user.id }}" onsubmit="return confirm('هل أنت متأكد من حذف هذا المستخدم؟');" class="inline-form">
<button type="submit" class="btn btn-sm btn-danger">حذف</button> <button type="submit" class="btn btn-sm btn-danger">حذف</button>
</form> </form>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}