Initial commit: Real Estate Registry app

This commit is contained in:
Georges Haddad
2026-04-10 15:24:22 +03:00
commit 7305e54f66
32 changed files with 3612 additions and 0 deletions
View File
+93
View File
@@ -0,0 +1,93 @@
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.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
def get_current_user(request: Request):
user = get_current_user_from_request(request)
if not user:
raise HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
headers={"Location": "/auth/login"},
)
return user
@router.get("/login", response_class=HTMLResponse)
async def login_get(request: Request):
return templates.TemplateResponse(request=request, name="login.html", context={"error": None})
@router.post("/login", response_class=HTMLResponse)
async def login_post(request: Request, username: str = Form(...), password: str = Form(...)):
user = get_user_by_username(username)
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)
from config import ENVIRONMENT
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
key="session_id",
value=session_id,
httponly=True,
secure=ENVIRONMENT == "production",
samesite="lax",
max_age=86400, # 24 hours
)
return response
@router.get("/logout")
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]
response.delete_cookie("session_id")
return response
@router.get("/users", response_class=HTMLResponse)
async def users_list(request: Request, _=Depends(get_current_user)):
users = get_all_users()
return templates.TemplateResponse(request=request, name="users.html", context={"users": users})
@router.post("/users/create")
async def add_user(username: str = Form(...), password: str = Form(...), _=Depends(get_current_user)):
try:
create_user(username, password)
except Exception:
pass # Probably duplicate
return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/users/delete/{user_id}")
async def remove_user(user_id: int, _=Depends(get_current_user)):
delete_user(user_id)
return RedirectResponse(url="/auth/users", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/backup")
async def backup_db(request: Request, _=Depends(get_current_user)):
try:
backup_path = create_backup()
return FileResponse(backup_path, media_type="application/octet-stream", filename=os.path.basename(backup_path))
except Exception as e:
msg = f"Backup failed: {str(e)}"
return templates.TemplateResponse(request=request, name="users.html", context={
"users": get_all_users(),
"backup_msg": msg
})
+90
View File
@@ -0,0 +1,90 @@
import os
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from fastapi.templating import Jinja2Templates
from config import UPLOAD_DIR
from database.connection import get_db
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/documents")
async def document_queue(request: Request, status: str = "", uploaded: int = 0):
with get_db() as conn:
if status:
rows = conn.execute(
"""SELECT d.*, p.first_name, p.family_name
FROM documents d
LEFT JOIN persons p ON p.id = d.person_id
WHERE d.status=?
ORDER BY d.id DESC""",
(status,),
).fetchall()
else:
rows = conn.execute(
"""SELECT d.*, p.first_name, p.family_name
FROM documents d
LEFT JOIN persons p ON p.id = d.person_id
ORDER BY d.id DESC"""
).fetchall()
stats = conn.execute(
"""SELECT
COUNT(*) AS total,
SUM(CASE WHEN status='confirmed' THEN 1 ELSE 0 END) AS confirmed,
SUM(CASE WHEN status='extracted' THEN 1 ELSE 0 END) AS pending_review,
SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) AS processing,
SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) AS errors
FROM documents"""
).fetchone()
return templates.TemplateResponse(
request,
"documents.html",
{
"documents": [dict(r) for r in rows],
"stats": dict(stats) if stats else {},
"current_status": status,
"uploaded": uploaded,
},
)
@router.delete("/documents/{doc_id}")
async def delete_document(doc_id: int):
"""Delete a document and its properties. Also removes the image file from disk."""
with get_db() as conn:
doc = conn.execute(
"SELECT image_path, person_id FROM documents WHERE id=?", (doc_id,)
).fetchone()
if not doc:
return JSONResponse({"error": "not found"}, status_code=404)
# Delete properties for this document
conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,))
# Delete the document record
conn.execute("DELETE FROM documents WHERE id=?", (doc_id,))
# If the person has no remaining documents, remove them too
if doc["person_id"]:
remaining = conn.execute(
"SELECT COUNT(*) AS n FROM documents WHERE person_id=?",
(doc["person_id"],),
).fetchone()["n"]
if remaining == 0:
conn.execute("DELETE FROM properties WHERE person_id=?", (doc["person_id"],))
conn.execute("DELETE FROM persons WHERE id=?", (doc["person_id"],))
# Remove the image file from disk
image_file = Path(UPLOAD_DIR) / doc["image_path"]
try:
image_file.unlink(missing_ok=True)
except OSError:
pass
return JSONResponse({"ok": True})
+321
View File
@@ -0,0 +1,321 @@
import json
import asyncio
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from config import UPLOAD_DIR
from database.connection import get_db
from services.extractor import extract_document
from services.search_service import normalize_arabic
router = APIRouter()
templates = Jinja2Templates(directory="templates")
def _get_document(doc_id: int) -> dict | None:
with get_db() as conn:
row = conn.execute("SELECT * FROM documents WHERE id=?", (doc_id,)).fetchone()
if not row:
return None
doc = dict(row)
props = conn.execute(
"SELECT * FROM properties WHERE document_id=? ORDER BY row_order",
(doc_id,),
).fetchall()
doc["properties"] = [dict(p) for p in props]
if doc["person_id"]:
person = conn.execute(
"SELECT * FROM persons WHERE id=?", (doc["person_id"],)
).fetchone()
doc["person"] = dict(person) if person else {}
elif doc["raw_extraction_json"]:
try:
extracted = json.loads(doc["raw_extraction_json"])
doc["person"] = extracted.get("person", {})
except Exception:
doc["person"] = {}
else:
doc["person"] = {}
return doc
@router.get("/review/next")
async def review_next():
with get_db() as conn:
row = conn.execute(
"SELECT id FROM documents WHERE status='extracted' ORDER BY id LIMIT 1"
).fetchone()
if row:
return RedirectResponse(f"/review/{row['id']}", status_code=302)
with get_db() as conn:
pending = conn.execute(
"SELECT COUNT(*) AS n FROM documents WHERE status='pending'"
).fetchone()["n"]
if pending:
return HTMLResponse(
"<html><body style='font-family:sans-serif;text-align:center;padding:3rem'>"
"<h2>جارٍ معالجة الوثائق...</h2>"
"<p>أعد التحميل بعد قليل أو اذهب إلى <a href='/documents'>قائمة الوثائق</a>.</p>"
"</body></html>"
)
return HTMLResponse(
"<html><body style='font-family:sans-serif;text-align:center;padding:3rem'>"
"<h2>تمت مراجعة جميع الوثائق!</h2>"
"<p><a href='/search'>البحث في قاعدة البيانات</a> | <a href='/'>رفع المزيد</a></p>"
"</body></html>"
)
@router.get("/review/{doc_id}")
async def review_document(request: Request, doc_id: int, wait: int = 0):
doc = _get_document(doc_id)
if not doc:
return HTMLResponse("Document not found", status_code=404)
if doc["status"] == "pending" and wait:
return HTMLResponse(
f"""<html><head><meta http-equiv="refresh" content="3;url=/review/{doc_id}?wait=1">
<title>Processing...</title></head>
<body style='font-family:sans-serif;text-align:center;padding:3rem'>
<h2>جارٍ قراءة الوثيقة...</h2>
<p>ستتحدث هذه الصفحة تلقائياً.</p>
<p><a href="/review/{doc_id}">تحديث الآن</a></p>
</body></html>"""
)
return templates.TemplateResponse(
request, "review.html", {"doc": doc, "upload_dir": "/uploads"}
)
@router.get("/api/check-duplicate")
async def check_duplicate(
first_name: str = "",
father_name: str = "",
family_name: str = "",
):
"""Check if a person with similar name already exists. Called via AJAX from review page."""
if not first_name:
return JSONResponse({"matches": []})
first_norm = normalize_arabic(first_name.strip())
family_norm = normalize_arabic(family_name.strip()) if family_name else ""
with get_db() as conn:
if family_norm:
rows = conn.execute(
"""SELECT p.*, COUNT(DISTINCT pr.id) AS property_count
FROM persons p
LEFT JOIN properties pr ON pr.person_id = p.id
WHERE p.first_name_norm = ? AND p.family_name_norm = ?
GROUP BY p.id""",
(first_norm, family_norm),
).fetchall()
else:
rows = conn.execute(
"""SELECT p.*, COUNT(DISTINCT pr.id) AS property_count
FROM persons p
LEFT JOIN properties pr ON pr.person_id = p.id
WHERE p.first_name_norm = ?
GROUP BY p.id""",
(first_norm,),
).fetchall()
# Further filter by father_name if provided
matches = []
for r in rows:
d = dict(r)
if father_name and d.get("father_name"):
if normalize_arabic(father_name.strip()) != normalize_arabic(d["father_name"]):
continue
matches.append({
"id": d["id"],
"first_name": d["first_name"],
"father_name": d.get("father_name"),
"family_name": d.get("family_name"),
"family_origin": d.get("family_origin"),
"property_count": d["property_count"],
})
return JSONResponse({"matches": matches})
@router.post("/confirm/{doc_id}")
async def confirm_document(doc_id: int, request: Request):
body = await request.json()
person_data = body.get("person", {})
properties_data = body.get("properties", [])
merge_person_id = body.get("merge_person_id") # If user chose to merge
first_name = (person_data.get("first_name") or "").strip()
registry_number = (person_data.get("registry_number") or "").strip() or None
# We should update document fields as well since user might have edited them
request_number = (body.get("request_number") or "").strip() or None
request_date = (body.get("request_date") or "").strip() or None
page_info = (body.get("page_info") or "").strip() or None
search_scope = (body.get("search_scope") or "").strip() or None
request_purpose = (body.get("request_purpose") or "").strip() or None
data_valid_until = (body.get("data_valid_until") or "").strip() or None
registry_office = (body.get("registry_office") or "").strip() or None
owns_properties = body.get("owns_properties")
if owns_properties is not None:
owns_properties = bool(owns_properties)
declared_property_count = body.get("declared_property_count")
if declared_property_count is not None:
try:
declared_property_count = int(declared_property_count)
except ValueError:
declared_property_count = None
with get_db() as conn:
person_id = None
# Option 1: User explicitly chose to merge with an existing person
if merge_person_id:
person_id = int(merge_person_id)
# Update person info with latest data
conn.execute(
"""UPDATE persons SET
first_name=?, father_name=?, mother_name=?,
family_name=?, family_origin=?, nationality=?,
birth_date=?, registry_place=?,
registry_number=COALESCE(registry_number, ?),
first_name_norm=?, family_name_norm=?,
updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(
first_name,
person_data.get("father_name"),
person_data.get("mother_name"),
person_data.get("family_name"),
person_data.get("family_origin"),
person_data.get("nationality"),
person_data.get("birth_date"),
person_data.get("registry_place"),
registry_number,
normalize_arabic(first_name),
normalize_arabic(person_data.get("family_name") or ""),
person_id,
),
)
# Option 2: Auto-merge by registry_number
if not person_id and registry_number:
existing = conn.execute(
"SELECT id FROM persons WHERE registry_number=?", (registry_number,)
).fetchone()
if existing:
person_id = existing["id"]
conn.execute(
"""UPDATE persons SET
first_name=?, father_name=?, mother_name=?,
family_name=?, family_origin=?, nationality=?,
birth_date=?, registry_place=?,
first_name_norm=?, family_name_norm=?,
updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(
first_name,
person_data.get("father_name"),
person_data.get("mother_name"),
person_data.get("family_name"),
person_data.get("family_origin"),
person_data.get("nationality"),
person_data.get("birth_date"),
person_data.get("registry_place"),
normalize_arabic(first_name),
normalize_arabic(person_data.get("family_name") or ""),
person_id,
),
)
# Option 3: Create new person
if not person_id:
cursor = conn.execute(
"""INSERT INTO persons
(first_name, father_name, mother_name, family_name, family_origin,
nationality, birth_date, registry_number, registry_place,
first_name_norm, family_name_norm)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(
first_name,
person_data.get("father_name"),
person_data.get("mother_name"),
person_data.get("family_name"),
person_data.get("family_origin"),
person_data.get("nationality"),
person_data.get("birth_date"),
registry_number,
person_data.get("registry_place"),
normalize_arabic(first_name),
normalize_arabic(person_data.get("family_name") or ""),
),
)
person_id = cursor.lastrowid
# Replace properties for this document
conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,))
for i, prop in enumerate(properties_data):
conn.execute(
"""INSERT INTO properties
(document_id, person_id, row_order, party_name, property_number,
section, block, real_estate_district, qaza, num_shares, ownership_type)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(
doc_id, person_id, i,
prop.get("party_name"),
prop.get("property_number"),
prop.get("section"),
prop.get("block"),
prop.get("real_estate_district"),
prop.get("qaza"),
prop.get("num_shares"),
prop.get("ownership_type"),
),
)
conn.execute(
"""UPDATE documents SET status='confirmed', person_id=?,
request_number=?, request_date=?, page_info=?, search_scope=?,
request_purpose=?, data_valid_until=?, registry_office=?,
owns_properties=?, declared_property_count=?,
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
(person_id, request_number, request_date, page_info, search_scope,
request_purpose, data_valid_until, registry_office,
owns_properties, declared_property_count, doc_id),
)
return JSONResponse({"ok": True, "person_id": person_id, "next": "/review/next"})
@router.post("/extract/{doc_id}")
async def retrigger_extraction(doc_id: int, provider: str = ""):
"""Re-run extraction for a document (retry after error)."""
with get_db() as conn:
doc = conn.execute(
"SELECT image_path, provider FROM documents WHERE id=?", (doc_id,)
).fetchone()
if not doc:
return JSONResponse({"error": "not found"}, status_code=404)
with get_db() as conn:
conn.execute(
"UPDATE documents SET status='pending', extraction_error=NULL WHERE id=?",
(doc_id,),
)
conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,))
from routers.upload import _extract_and_save
use_provider = provider or doc["provider"] or ""
asyncio.create_task(_extract_and_save(doc_id, doc["image_path"], use_provider))
return JSONResponse({"ok": True, "message": "Extraction started"})
+149
View File
@@ -0,0 +1,149 @@
from fastapi import APIRouter, Request
from fastapi.responses import Response
from fastapi.templating import Jinja2Templates
from services.search_service import (
get_person_with_properties,
search_persons,
search_properties,
)
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/search")
async def search(
request: Request,
q: str = "",
property_number: str = "",
district: str = "",
block: str = "",
):
persons = []
properties = []
selected_person = None
if q:
persons = search_persons(q)
if property_number or district or block:
properties = search_properties(property_number, district, block)
# If exactly one person found, preload their full details
if len(persons) == 1 and not properties:
selected_person = get_person_with_properties(persons[0]["id"])
return templates.TemplateResponse(
request,
"search.html",
{
"q": q,
"property_number": property_number,
"district": district,
"block": block,
"persons": persons,
"properties": properties,
"selected_person": selected_person,
},
)
@router.get("/persons/{person_id}")
async def person_detail(request: Request, person_id: int):
data = get_person_with_properties(person_id)
if not data:
return templates.TemplateResponse(
request,
"search.html",
{"error": "Person not found", "q": "", "persons": [], "properties": [], "selected_person": None, "property_number": "", "district": "", "block": ""},
status_code=404,
)
return templates.TemplateResponse(
request,
"person_detail.html",
data,
)
@router.get("/persons/{person_id}/export")
async def person_export_csv(person_id: int, qaza: str = ""):
import io
import csv
data = get_person_with_properties(person_id)
if not data:
return Response("Person not found", status_code=404)
person = data["person"]
properties = data["properties"]
if qaza:
properties = [p for p in properties if (p.get("search_scope") or p.get("qaza") or "").strip() == qaza.strip()]
output = io.StringIO()
# Write BOM for Excel to open Arabic UTF-8 correctly
output.write('\ufeff')
writer = csv.writer(output)
fullname_parts = [person.get("first_name", ""), person.get("father_name", ""), person.get("family_name", "")]
fullname = " ".join(f for f in fullname_parts if f)
writer.writerow(["بيانات الشخص"])
writer.writerow(["الاسم كامل", fullname])
writer.writerow(["رقم السجل", person.get("registry_number")])
writer.writerow(["مكان السجل", person.get("registry_place")])
writer.writerow(["تاريخ الولادة", person.get("birth_date")])
writer.writerow(["الهوا / المنشأ", person.get("family_origin")])
writer.writerow([])
# Document-level info (search scope, request numbers)
docs = data.get("documents", [])
if docs:
writer.writerow(["بيانات الوثائق"])
for d in docs:
parts = []
if d.get("request_number"):
parts.append(f"رقم الطلب: {d['request_number']}")
if d.get("request_date"):
parts.append(f"تاريخ: {d['request_date']}")
if d.get("search_scope"):
parts.append(f"القضاء: {d['search_scope']}")
if d.get("page_info"):
parts.append(f"صفحة: {d['page_info']}")
if parts:
writer.writerow(parts)
writer.writerow([])
writer.writerow(["العقارات المملوكة"])
writer.writerow(["اسم الفريق", "رقم العقار", "القسم", "البلوك", "المنطقة العقارية", "القضاء", "عدد الأسهم", "نوع الملكية"])
for p in properties:
writer.writerow([
p.get("party_name") or "",
p.get("property_number") or "",
p.get("section") or "",
p.get("block") or "",
p.get("real_estate_district") or "",
p.get("qaza") or "",
p.get("num_shares") or "",
p.get("ownership_type") or ""
])
content = output.getvalue()
# Safe filename use RFC 5987 encoding for Arabic characters
from urllib.parse import quote
safename = fullname.replace(" ", "_") or "person"
if qaza:
safe_qaza = qaza.replace(" ", "_").replace(":", "").replace("-", "")
safename += f"_{safe_qaza}"
encoded_name = quote(f"report_{safename}.csv")
return Response(
content=content,
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=report.csv; filename*=UTF-8''{encoded_name}"}
)
+173
View File
@@ -0,0 +1,173 @@
import asyncio
import json
import uuid
from datetime import date
from pathlib import Path
from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from config import UPLOAD_DIR
from database.connection import get_db
from services.extractor import extract_document, get_available_providers, get_default_provider
from services.pdf_handler import pdf_to_images
router = APIRouter()
templates = Jinja2Templates(directory="templates")
ALLOWED_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
ALLOWED_PDF_EXTS = {".pdf"}
ALLOWED_EXTENSIONS = ALLOWED_IMAGE_EXTS | ALLOWED_PDF_EXTS
def _save_image(file_bytes: bytes, original_name: str) -> str:
"""Save image to uploads/{date}/{uuid}_{name} and return relative path."""
today = date.today().isoformat()
dest_dir = Path(UPLOAD_DIR) / today
dest_dir.mkdir(parents=True, exist_ok=True)
suffix = Path(original_name).suffix.lower() or ".jpg"
filename = f"{uuid.uuid4().hex}{suffix}"
dest = dest_dir / filename
dest.write_bytes(file_bytes)
return str(Path(today) / filename)
async def _extract_and_save(doc_id: int, image_path: str, provider: str = ""):
"""Background task: call extractor, parse result, update DB."""
try:
data = await extract_document(image_path, provider=provider)
raw_json = json.dumps(data, ensure_ascii=False)
with get_db() as conn:
conn.execute(
"""UPDATE documents SET status='extracted',
raw_extraction_json=?,
request_number=?,
request_date=?,
applicant_name_raw=?,
request_purpose=?,
data_valid_until=?,
registry_office=?,
owns_properties=?,
declared_property_count=?,
page_info=?,
search_scope=?,
updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(
raw_json,
data.get("request_number"),
data.get("request_date"),
data.get("applicant_name_raw"),
data.get("request_purpose"),
data.get("data_valid_until"),
data.get("registry_office"),
data.get("owns_properties"),
data.get("declared_property_count"),
data.get("page_info"),
data.get("search_scope"),
doc_id,
),
)
for i, prop in enumerate(data.get("properties", [])):
conn.execute(
"""INSERT INTO properties
(document_id, row_order, party_name, property_number,
section, block, real_estate_district, qaza, num_shares, ownership_type)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(
doc_id, i,
prop.get("party_name"),
prop.get("property_number"),
prop.get("section"),
prop.get("block"),
prop.get("real_estate_district"),
prop.get("qaza"),
prop.get("num_shares"),
prop.get("ownership_type"),
),
)
except Exception as e:
with get_db() as conn:
conn.execute(
"""UPDATE documents SET status='error', extraction_error=?,
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
(str(e), doc_id),
)
@router.get("/")
async def upload_page(request: Request):
with get_db() as conn:
stats = conn.execute(
"""SELECT
COUNT(*) AS total,
SUM(CASE WHEN status='confirmed' THEN 1 ELSE 0 END) AS confirmed,
SUM(CASE WHEN status='extracted' THEN 1 ELSE 0 END) AS pending_review,
SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) AS errors
FROM documents"""
).fetchone()
return templates.TemplateResponse(
request, "index.html", {
"stats": dict(stats) if stats else {},
"providers": get_available_providers(),
"default_provider": get_default_provider(),
}
)
@router.post("/upload")
async def upload_files(
request: Request,
files: list[UploadFile] = File(...),
provider: str = Form(""),
):
if not provider:
provider = get_default_provider()
doc_ids = []
for upload in files:
suffix = Path(upload.filename).suffix.lower()
if suffix not in ALLOWED_EXTENSIONS:
continue
file_bytes = await upload.read()
if suffix in ALLOWED_PDF_EXTS:
# PDF: split into per-page images
pages = pdf_to_images(file_bytes, upload.filename)
for page_info in pages:
with get_db() as conn:
cursor = conn.execute(
"""INSERT INTO documents
(image_path, status, provider, pdf_group_id, page_number)
VALUES (?, 'pending', ?, ?, ?)""",
(
page_info["image_path"],
provider,
page_info["pdf_group_id"],
page_info["page_number"],
),
)
doc_ids.append((cursor.lastrowid, page_info["image_path"]))
else:
# Image file
rel_path = _save_image(file_bytes, upload.filename)
with get_db() as conn:
cursor = conn.execute(
"INSERT INTO documents (image_path, status, provider) VALUES (?, 'pending', ?)",
(rel_path, provider),
)
doc_ids.append((cursor.lastrowid, rel_path))
# Fire background extractions
for doc_id, rel_path in doc_ids:
asyncio.create_task(_extract_and_save(doc_id, rel_path, provider))
if len(doc_ids) == 1:
return RedirectResponse(f"/review/{doc_ids[0][0]}?wait=1", status_code=303)
return RedirectResponse("/documents?uploaded=1", status_code=303)