Initial commit: Real Estate Registry app
This commit is contained in:
@@ -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
|
||||
})
|
||||
@@ -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})
|
||||
@@ -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"})
|
||||
@@ -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}"}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user