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
+13
View File
@@ -0,0 +1,13 @@
# Required
SECRET_KEY=generate-a-long-random-string-here
ENVIRONMENT=production
# AI provider keys (at least one required for OCR extraction)
GEMINI_API_KEY=
ANTHROPIC_API_KEY=
# Optional overrides
# DB_PATH=/path/to/realestate.db
# UPLOAD_DIR=/path/to/uploads
# MAX_CONCURRENT=3
# DEFAULT_PROVIDER=gemini
+16
View File
@@ -0,0 +1,16 @@
.env
data/*.db
data/*.db-wal
data/*.db-shm
uploads/
backups/
__pycache__/
*.pyc
.venv/
venv/
*.egg-info/
.claude/
.vscode/
*.jpeg
*.jpg
*.png
+27
View File
@@ -0,0 +1,27 @@
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).parent
# Load environment variables from .env file and override any existing terminal variables
load_dotenv(BASE_DIR / ".env", override=True)
# API Keys — set whichever provider(s) you want to use
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "").strip().strip('"\'')
# Default provider: "claude", "gemini", or "easyocr"
DEFAULT_PROVIDER = os.environ.get("DEFAULT_PROVIDER", "gemini")
# Secret key for session signing — MUST be set in .env for production
SECRET_KEY = os.environ.get("SECRET_KEY", "")
DATABASE_PATH = os.environ.get("DB_PATH", str(BASE_DIR / "data" / "realestate.db"))
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", str(BASE_DIR / "uploads"))
MAX_CONCURRENT_EXTRACTIONS = int(os.environ.get("MAX_CONCURRENT", "3"))
CLAUDE_MODEL = "claude-sonnet-4-6"
GEMINI_MODEL = "gemini-2.5-flash"
# Production mode — set to "production" on VPS
ENVIRONMENT = os.environ.get("ENVIRONMENT", "development")
+34
View File
@@ -0,0 +1,34 @@
"""One-time script to create the initial admin user."""
import sys
import getpass
from database.schema import create_tables
from services.auth_service import create_user, get_user_by_username
def main():
create_tables()
username = input("Enter admin username: ").strip()
if not username:
print("Username cannot be empty.")
sys.exit(1)
if get_user_by_username(username):
print(f"User '{username}' already exists.")
sys.exit(1)
password = getpass.getpass("Enter password: ")
confirm = getpass.getpass("Confirm password: ")
if password != confirm:
print("Passwords do not match.")
sys.exit(1)
if len(password) < 8:
print("Password must be at least 8 characters.")
sys.exit(1)
create_user(username, password)
print(f"Admin user '{username}' created successfully.")
if __name__ == "__main__":
main()
View File
+26
View File
@@ -0,0 +1,26 @@
import sqlite3
from contextlib import contextmanager
from config import DATABASE_PATH
from pathlib import Path
def get_connection() -> sqlite3.Connection:
Path(DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DATABASE_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
@contextmanager
def get_db():
conn = get_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
+113
View File
@@ -0,0 +1,113 @@
from database.connection import get_db
def _migrate(conn):
"""Add columns that may not exist in older databases."""
cursor = conn.execute("PRAGMA table_info(documents)")
existing = {row[1] for row in cursor.fetchall()}
migrations = [
("provider", "TEXT"),
("pdf_group_id", "TEXT"),
("page_number", "INTEGER"),
("page_info", "TEXT"),
("search_scope", "TEXT"),
("request_purpose", "TEXT"),
("data_valid_until", "TEXT"),
("registry_office", "TEXT"),
("owns_properties", "BOOLEAN"),
("declared_property_count", "INTEGER"),
]
for col, col_type in migrations:
if col not in existing:
conn.execute(f"ALTER TABLE documents ADD COLUMN {col} {col_type}")
cursor = conn.execute("PRAGMA table_info(properties)")
existing_prop = {row[1] for row in cursor.fetchall()}
migrations_prop = [
("party_name", "TEXT"),
("qaza", "TEXT"),
]
for col, col_type in migrations_prop:
if col not in existing_prop:
conn.execute(f"ALTER TABLE properties ADD COLUMN {col} {col_type}")
def create_tables():
with get_db() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS persons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
father_name TEXT,
mother_name TEXT,
family_name TEXT,
family_origin TEXT,
nationality TEXT,
birth_date TEXT,
registry_number TEXT UNIQUE,
registry_place TEXT,
first_name_norm TEXT,
family_name_norm TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
person_id INTEGER REFERENCES persons(id),
image_path TEXT NOT NULL,
request_number TEXT,
request_date TEXT,
applicant_name_raw TEXT,
request_purpose TEXT,
data_valid_until TEXT,
registry_office TEXT,
owns_properties BOOLEAN,
declared_property_count INTEGER,
provider TEXT,
pdf_group_id TEXT,
page_number INTEGER,
page_info TEXT,
search_scope TEXT,
status TEXT NOT NULL DEFAULT 'pending',
raw_extraction_json TEXT,
extraction_error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS properties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL REFERENCES documents(id),
person_id INTEGER REFERENCES persons(id),
row_order INTEGER DEFAULT 0,
area_name TEXT,
party_name TEXT,
property_number TEXT,
section TEXT,
block TEXT,
real_estate_district TEXT,
space TEXT,
qaza TEXT,
num_shares TEXT,
ownership_type TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_persons_first_name_norm ON persons(first_name_norm);
CREATE INDEX IF NOT EXISTS idx_persons_family_name_norm ON persons(family_name_norm);
CREATE INDEX IF NOT EXISTS idx_persons_registry_number ON persons(registry_number);
CREATE INDEX IF NOT EXISTS idx_documents_person_id ON documents(person_id);
CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status);
CREATE INDEX IF NOT EXISTS idx_properties_person_id ON properties(person_id);
CREATE INDEX IF NOT EXISTS idx_properties_property_number ON properties(property_number);
CREATE INDEX IF NOT EXISTS idx_properties_district ON properties(real_estate_district);
CREATE INDEX IF NOT EXISTS idx_properties_block ON properties(block);
""")
_migrate(conn)
+49
View File
@@ -0,0 +1,49 @@
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from config import UPLOAD_DIR, ENVIRONMENT
from database.schema import create_tables
from routers import documents, review, search, upload, auth
# Disable docs/openapi in production
if ENVIRONMENT == "production":
app = FastAPI(title="Lebanese Real Estate Registry", docs_url=None, redoc_url=None, openapi_url=None)
else:
app = FastAPI(title="Lebanese Real Estate Registry")
@app.middleware("http")
async def check_authentication(request: Request, call_next):
path = request.url.path
allowed_paths = ["/auth/login", "/static"]
is_allowed = any(path.startswith(p) for p in allowed_paths)
if not is_allowed:
session_id = request.cookies.get("session_id")
if not session_id or session_id not in auth.sessions:
return RedirectResponse(url="/auth/login", status_code=303)
response = await call_next(request)
return response
@app.on_event("startup")
async def startup():
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
Path("data").mkdir(exist_ok=True)
create_tables()
# Do NOT create default admin user automatically in production!
# A script should be used for initial setup.
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
app.mount("/static", StaticFiles(directory="static"), name="static")
app.include_router(upload.router)
app.include_router(review.router)
app.include_router(search.router)
app.include_router(documents.router)
app.include_router(auth.router, prefix="/auth")
+10
View File
@@ -0,0 +1,10 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
anthropic>=0.49.0
google-genai>=1.70.0
easyocr>=1.7.0
pymupdf>=1.24.0
jinja2>=3.1.4
python-multipart>=0.0.9
aiofiles>=23.0.0
pillow>=10.0.0
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)
View File
+40
View File
@@ -0,0 +1,40 @@
import sqlite3
import hashlib
import secrets
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
hashed = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt.encode('utf-8'), 100000).hex()
return f"{salt}${hashed}"
def verify_password(stored_password: str, provided_password: str) -> bool:
try:
salt, stored_hash = stored_password.split('$')
hashed = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt.encode('utf-8'), 100000).hex()
return hashed == stored_hash
except Exception:
return False
def get_user_by_username(username: str):
from database.connection import get_db
with get_db() as conn:
cursor = conn.execute("SELECT * FROM users WHERE username = ?", (username,))
return cursor.fetchone()
def create_user(username: str, password: str):
from database.connection import get_db
with get_db() as conn:
hashed = hash_password(password)
conn.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", (username, hashed))
def delete_user(user_id: int):
from database.connection import get_db
with get_db() as conn:
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
def get_all_users():
from database.connection import get_db
with get_db() as conn:
cursor = conn.execute("SELECT id, username, created_at FROM users ORDER BY created_at DESC")
return cursor.fetchall()
+24
View File
@@ -0,0 +1,24 @@
import os
import sqlite3
from datetime import datetime
from config import DATABASE_PATH
from database.connection import get_db
def create_backup() -> str:
"""Create a backup of the database to a backup file."""
db_file = DATABASE_PATH
if not os.path.exists(db_file):
raise FileNotFoundError("Database file does not exist.")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = f"{db_file}.{timestamp}.bak"
# Safe SQLite backup
with get_db() as conn:
bck = sqlite3.connect(backup_file)
with bck:
conn.backup(bck)
bck.close()
return backup_file
+551
View File
@@ -0,0 +1,551 @@
import base64
import json
import re
from pathlib import Path
from config import (
ANTHROPIC_API_KEY, GEMINI_API_KEY,
CLAUDE_MODEL, GEMINI_MODEL, DEFAULT_PROVIDER, UPLOAD_DIR,
)
SYSTEM_PROMPT = """You are an expert OCR assistant specializing in Lebanese government real estate documents written in Arabic (right-to-left). Your task is to extract structured data from scanned document images of "بطاقة معلومات عن الملكية العقارية" (Real Estate Property Information Cards) issued by the Lebanese Ministry of Real Estate Affairs.
CRITICAL INSTRUCTIONS:
1. Extract ALL text exactly as written in Arabic — do not translate or transliterate.
2. For dates, preserve the original format as printed (e.g., ٢٩-٣-٢٠٢٦ or 29/03/2026).
3. For the properties table, extract EVERY row — documents often have 30+ rows. If the document prominently states "لا يملك", set owns_properties to false and return an empty array [] for properties.
4. If a field is illegible or absent, use null.
5. Numbers may be in Arabic-Indic digits (٠١٢٣٤٥٦٧٨٩) or Western digits — preserve exactly as-is.
6. The header and footer sections contain these labeled fields:
- الاسم (first_name), اسم الأب (father_name), اسم الأم (mother_name)
- الشهرة (family_name / family_origin), الجنسية (nationality)
- تاريخ الولادة (birth_date), رقم السجل (registry_number), مكان السجل (registry_place)
- رقم الطلب (request_number), تاريخ الطلب (request_date)
- اسم المستدعي/الوكيل (applicant_name_raw)
- الغاية من الطلب (request_purpose)
- نطاق البحث contains القضاء or المحافظات (qaza/district/governorate) — extract as search_scope
- in the bottom footer, find the page fraction (e.g. "صفحة 1 من 3" or "1 / 3") and extract as page_info.
- معلومات محولة لغاية (data_valid_until) found near the bottom.
- أمانة السجل (registry_office) found at the bottom right.
- عدد العقارات: (declared_property_count) located right under the properties table.
7. The properties table has 8 columns in order from RIGHT to LEFT as they appear on the page:
col1(rightmost)=اسم الفريق, col2=رقم العقار, col3=القسم, col4=البلوك,
col5=المنطقة العقارية, col6=القضاء, col7=عدد الأسهم, col8(leftmost)=نوع الملكية
Map these to JSON keys: party_name, property_number, section, block, real_estate_district, qaza, num_shares, ownership_type
8. Return ONLY valid JSON matching the schema. No markdown, no explanation."""
USER_PROMPT = (
"Extract all data from this Lebanese real estate property card. "
"Return a single JSON object with these keys: "
"request_number, request_date, applicant_name_raw, request_purpose, data_valid_until, "
"registry_office, page_info, search_scope, owns_properties, declared_property_count, "
"person (object with: first_name, father_name, mother_name, family_name, "
"family_origin, nationality, birth_date, registry_number, registry_place), "
"properties (array of objects with: party_name, property_number, section, block, "
"real_estate_district, qaza, num_shares, ownership_type), "
"extraction_notes. "
"Use null for missing fields. Include every property row from the table (or empty array if none)."
)
def _resolve_path(image_path: str) -> str:
if Path(image_path).is_absolute():
return image_path
return str(Path(UPLOAD_DIR) / image_path)
def _encode_image(image_path: str) -> tuple[str, str]:
path = Path(image_path)
suffix = path.suffix.lower()
media_type_map = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
}
media_type = media_type_map.get(suffix, "image/jpeg")
with open(image_path, "rb") as f:
data = base64.standard_b64encode(f.read()).decode("utf-8")
return data, media_type
def _strip_code_fences(text: str) -> str:
text = text.strip()
if text.startswith("```"):
lines = text.split("\n")
text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
return text.strip()
def get_available_providers() -> list[dict]:
"""Return list of all available providers (EasyOCR is always available)."""
providers = [
{"id": "easyocr", "name": "EasyOCR (مجاني)", "model": "local"},
]
if ANTHROPIC_API_KEY:
providers.append({"id": "claude", "name": "Claude (Anthropic)", "model": CLAUDE_MODEL})
if GEMINI_API_KEY:
providers.append({"id": "gemini", "name": "Gemini (Google)", "model": GEMINI_MODEL})
return providers
def get_default_provider() -> str:
"""Return the default provider, falling back to whichever is available."""
providers = get_available_providers()
if not providers:
return "easyocr"
ids = [p["id"] for p in providers]
if DEFAULT_PROVIDER in ids:
return DEFAULT_PROVIDER
return ids[0]
# ─── Claude extraction ───────────────────────────────────────────
async def _extract_with_claude(image_path: str) -> dict:
import anthropic
client = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
full_path = _resolve_path(image_path)
img_data, media_type = _encode_image(full_path)
message = await client.messages.create(
model=CLAUDE_MODEL,
max_tokens=4096,
system=[
{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
}
],
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": img_data,
},
},
{"type": "text", "text": USER_PROMPT},
],
}
],
)
raw_text = _strip_code_fences(message.content[0].text)
return json.loads(raw_text)
# ─── Gemini extraction ───────────────────────────────────────────
async def _extract_with_gemini(image_path: str) -> dict:
import asyncio
from google import genai
from google.genai import types
client = genai.Client(api_key=GEMINI_API_KEY)
full_path = _resolve_path(image_path)
with open(full_path, "rb") as f:
image_bytes = f.read()
suffix = Path(full_path).suffix.lower()
mime_map = {".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".webp": "image/webp"}
mime_type = mime_map.get(suffix, "image/jpeg")
# Try primary model first; only fall back on 503 (overloaded), NOT on 429 (quota)
models_to_try = [GEMINI_MODEL, "gemini-2.0-flash-lite"]
def _call(model_name: str):
response = client.models.generate_content(
model=model_name,
contents=[
types.Content(parts=[
types.Part(text=SYSTEM_PROMPT + "\n\n" + USER_PROMPT),
types.Part(inline_data=types.Blob(mime_type=mime_type, data=image_bytes)),
])
],
config=types.GenerateContentConfig(
temperature=0.1,
max_output_tokens=8192,
),
)
return response.text
last_error = None
for model_name in models_to_try:
for attempt in range(2):
try:
raw_text = await asyncio.get_event_loop().run_in_executor(None, lambda m=model_name: _call(m))
raw_text = _strip_code_fences(raw_text)
return json.loads(raw_text)
except Exception as e:
last_error = e
err_str = str(e)
# On quota exhaustion, skip to next model immediately (don't waste retries)
if "429" in err_str or "RESOURCE_EXHAUSTED" in err_str:
break
# On temporary overload, wait and retry same model
if "503" in err_str or "UNAVAILABLE" in err_str:
await asyncio.sleep(3 * (attempt + 1))
continue
raise
raise last_error
# ─── EasyOCR extraction (free, local) ────────────────────────────
# Cache EasyOCR reader (heavy to initialize)
_easyocr_reader = None
def _get_easyocr_reader():
global _easyocr_reader
if _easyocr_reader is None:
import easyocr
_easyocr_reader = easyocr.Reader(["ar", "en"], gpu=False)
return _easyocr_reader
# Known label strings for header fields
_LABEL_MAP = {
"الاسم": "first_name",
"اسم الأب": "father_name",
"اسم الأم": "mother_name",
"الشهرة": "family_origin",
"الجنسية": "nationality",
"تاريخ الولادة": "birth_date",
"رقم السجل": "registry_number",
"مكان السجل": "registry_place",
}
# Table header labels that mark the start of the properties table
_TABLE_HEADERS = {"اسم الفريق", "رقم العقار", "القسم", "البلوك",
"المنطقة العقارية", "القضاء", "عدد الأسهم", "نوع الملكية"}
# Column-header text → JSON key (right→left order on page)
_COL_KEYS = [
("اسم الفريق", "party_name"),
("رقم العقار", "property_number"),
("القسم", "section"),
("البلوك", "block"),
("المنطقة العقارية", "real_estate_district"),
("القضاء", "qaza"),
("عدد الأسهم", "num_shares"),
("نوع الملكية", "ownership_type"),
]
def _parse_easyocr_results(results: list) -> dict:
"""
Parse EasyOCR bounding-box results into structured document data.
Each result is (bbox, text, confidence).
bbox = [[x1,y1],[x2,y2],[x3,y3],[x4,y4]]
Strategy:
1. Build a list of items with (text, x_center, y_center, x_left, x_right).
2. Detect the table-header row → derive column X-boundary ranges.
3. Header section (above table): find label items, then take the nearest
right-adjacent item as the value (RTL: label is in the centre/left,
value is to the right of it on the same row).
4. Table body rows: assign each cell to a column by matching its X-center
to the column boundary derived from the header row.
"""
if not results:
return _empty_result("No text detected")
items = []
for bbox, text, conf in results:
xs = [pt[0] for pt in bbox]
ys = [pt[1] for pt in bbox]
items.append({
"text": text.strip(),
"x": (min(xs) + max(xs)) / 2,
"y": (min(ys) + max(ys)) / 2,
"x_left": min(xs),
"x_right": max(xs),
})
items.sort(key=lambda t: t["y"])
# ── 1. Locate the table header row ──────────────────────────────────────
header_row_items = []
header_y = None
# Simple heuristic to find where the table starts:
# First, locate the word "العقارات" or "المملوكة" or "نطاق البحث"
for item in items:
if "العقارات" in item["text"] or "المملوكة" in item["text"]:
header_y = item["y"]
break
if header_y is None:
for item in items:
if item["text"] in _TABLE_HEADERS:
header_y = item["y"]
break
for item in items:
if header_y is not None and 0 < item["y"] - header_y < 80:
# The table headers are right below "العقارات المملوكة"
# Let's collect anything that looks like a header column label
if any(h in item["text"] for h in ["العقار", "الفريق", "القسم", "البلوك", "المنطقة", "قضاء", "أسهم", "نوع"]):
header_row_items.append(item)
if header_row_items:
# Sort header cells right→left (descending x)
header_row_items.sort(key=lambda t: -t["x"])
# ── 2. Build column X-boundaries from header row ─────────────────────────
col_boundaries = [] # list of (x_left, x_right, json_key)
# Define a fuzzy matching logic or regex to assign headers
key_keywords = {
"party_name": ["فريق"],
"property_number": ["رقم", "عقار"],
"section": ["قسم"],
"block": ["بلوك"],
"real_estate_district": ["منطقة", "عقارية"],
"qaza": ["قضاء"],
"num_shares": ["أسهم", "سهم"],
"ownership_type": ["نوع", "ملكية"],
}
if header_row_items:
for it in header_row_items:
assigned_key = None
for key, keywords in key_keywords.items():
if any(kw in it["text"] for kw in keywords):
assigned_key = key
break
if assigned_key:
# Add large horizontal padding to ensure columns grab cells correctly
col_boundaries.append((it["x_left"] - 40, it["x_right"] + 40, assigned_key))
table_start_y = header_row_items[0]["y"] if header_row_items else (header_y if header_y is not None else 600)
# ── 3. Extract header/person fields (above the table) ────────────────────
header_items = [it for it in items if it["y"] < (header_y if header_y is not None else table_start_y) - 5]
# Group header items into rows
header_rows: list[list[dict]] = []
if header_items:
current = [header_items[0]]
for it in header_items[1:]:
if abs(it["y"] - current[0]["y"]) < 15:
current.append(it)
else:
header_rows.append(sorted(current, key=lambda t: -t["x"])) # right→left
current = [it]
header_rows.append(sorted(current, key=lambda t: -t["x"]))
person: dict = {}
request_number = None
request_date = None
applicant_name = None
for row in header_rows:
# In RTL layout the label is printed first (rightmost), value follows to its left.
# We scan each item; if it matches a known label we take the next item as the value.
i = 0
while i < len(row):
txt = row[i]["text"]
# Strip trailing colon if present
clean = txt.rstrip(":").strip()
if clean in _LABEL_MAP and i + 1 < len(row):
value = row[i + 1]["text"].rstrip(":").strip()
# Skip if the value looks like another label
if value not in _LABEL_MAP:
person[_LABEL_MAP[clean]] = value
i += 2
continue
# رقم الطلب
if "رقم الطلب" in txt and i + 1 < len(row):
request_number = row[i + 1]["text"].strip()
i += 2
continue
# تاريخ الطلب
if "تاريخ الطلب" in txt and i + 1 < len(row):
request_date = row[i + 1]["text"].strip()
i += 2
continue
# اسم المستدعي / الوكيل
if "المستدعي" in txt or "الوكيل" in txt:
if i + 1 < len(row):
applicant_name = row[i + 1]["text"].strip()
i += 2
continue
i += 1
# Also try full-text regex as fallback for request fields
full_text = " ".join(it["text"] for it in items)
if not request_number:
m = re.search(r"رقم الطلب[:\s]+([\d٠-٩]+)", full_text)
if m:
request_number = m.group(1)
if not request_date:
m = re.search(r"تاريخ الطلب[:\s]+([\d٠\-/]+)", full_text)
if m:
request_date = m.group(1)
page_info = None
m_page = re.search(r"(صفحة\s*[\d٠-٩]+\s*من\s*[\d٠-٩]+|[\d٠-٩]+\s*/\s*[\d٠-٩]+)", full_text)
if m_page:
page_info = m_page.group(1).strip()
# Extract search scope (نطاق البحث / القضاء)
search_scope = None
for row in header_rows:
for j, item in enumerate(row):
if "القضاء" in item["text"] or "نطاق" in item["text"]:
# Value is the next item in the row
if j + 1 < len(row):
val = row[j + 1]["text"].rstrip(":").strip()
if val and "نطاق" not in val and "القضاء" not in val:
search_scope = val
break
if search_scope:
break
# Regex fallback
if not search_scope:
m_scope = re.search(r"القضاء[:\s]+([^\d٠\-/]+?)(?:\s|$)", full_text)
if m_scope:
search_scope = m_scope.group(1).strip().rstrip(":")
# ── 4. Extract table body rows ────────────────────────────────────────────
table_items = [it for it in items if it["y"] > table_start_y + 15]
properties = _group_into_table_rows(table_items, col_boundaries)
return {
"request_number": request_number,
"request_date": request_date,
"applicant_name_raw": applicant_name,
"page_info": page_info,
"search_scope": search_scope,
"person": person if person else {"first_name": None},
"properties": properties,
"extraction_notes": "Extracted with EasyOCR (local). Please review carefully.",
}
def _group_into_table_rows(items: list[dict], col_boundaries: list) -> list[dict]:
"""
Group text items into table rows by Y-proximity, then assign each cell to
the correct column using col_boundaries (list of (x_left, x_right, key)).
Falls back to positional ordering when no boundaries are available.
"""
if not items:
return []
items_sorted = sorted(items, key=lambda t: t["y"])
rows: list[list[dict]] = []
current_row = [items_sorted[0]]
for item in items_sorted[1:]:
if abs(item["y"] - current_row[0]["y"]) < 14:
current_row.append(item)
else:
rows.append(current_row)
current_row = [item]
if current_row:
rows.append(current_row)
properties = []
key_order = [k for _, k in _COL_KEYS]
for row_items in rows:
texts = [c["text"] for c in row_items]
joined = " ".join(texts)
# Skip header row and single-word rows
if any(h in joined for h in _TABLE_HEADERS):
continue
if len(row_items) < 2:
continue
if col_boundaries:
# Assign each cell to a column by X-overlap with column boundaries
prop: dict = {k: None for _, k in _COL_KEYS}
for cell in row_items:
best_key = None
best_overlap = 0
for x_left, x_right, key in col_boundaries:
# Compute horizontal overlap between cell and column boundary
overlap = min(cell["x_right"], x_right) - max(cell["x_left"], x_left)
if overlap > best_overlap:
best_overlap = overlap
best_key = key
if best_key and best_overlap > 0:
# Concatenate if multiple cells map to same column
existing = prop.get(best_key)
prop[best_key] = (existing + " " + cell["text"]) if existing else cell["text"]
else:
# Fallback: positional right→left assignment
cells = sorted(row_items, key=lambda t: -t["x"])
prop = {key_order[i]: cells[i]["text"] if i < len(cells) else None
for i in range(len(key_order))}
properties.append(prop)
return properties
def _empty_result(note: str) -> dict:
return {
"request_number": None,
"request_date": None,
"applicant_name_raw": None,
"page_info": None,
"search_scope": None,
"person": {"first_name": None},
"properties": [],
"extraction_notes": note,
}
async def _extract_with_easyocr(image_path: str) -> dict:
import asyncio
full_path = _resolve_path(image_path)
def _call():
reader = _get_easyocr_reader()
results = reader.readtext(full_path)
return _parse_easyocr_results(results)
return await asyncio.get_event_loop().run_in_executor(None, _call)
# ─── Public API ───────────────────────────────────────────────────
async def extract_document(image_path: str, provider: str = "") -> dict:
"""
Extract structured data from a document image using the specified provider.
Falls back to the default provider if none specified.
"""
if not provider:
provider = get_default_provider()
if provider == "claude":
if not ANTHROPIC_API_KEY:
raise ValueError("ANTHROPIC_API_KEY not set")
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":
return await _extract_with_easyocr(image_path)
else:
raise ValueError(f"Unknown provider: {provider}")
+40
View File
@@ -0,0 +1,40 @@
"""Convert PDF files to per-page images using PyMuPDF (no poppler dependency)."""
import uuid
from datetime import date
from pathlib import Path
from config import UPLOAD_DIR
def pdf_to_images(pdf_bytes: bytes, original_name: str) -> list[dict]:
"""
Convert a PDF to individual page images.
Returns list of dicts: [{"image_path": "relative/path.png", "page_number": 1}, ...]
"""
import fitz # PyMuPDF
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
group_id = uuid.uuid4().hex
today = date.today().isoformat()
dest_dir = Path(UPLOAD_DIR) / today
dest_dir.mkdir(parents=True, exist_ok=True)
pages = []
for page_num in range(len(doc)):
page = doc[page_num]
# Render at 200 DPI for good OCR quality
pix = page.get_pixmap(dpi=200)
filename = f"{group_id}_p{page_num + 1}.png"
dest = dest_dir / filename
pix.save(str(dest))
pages.append({
"image_path": str(Path(today) / filename),
"page_number": page_num + 1,
"pdf_group_id": group_id,
})
doc.close()
return pages
+152
View File
@@ -0,0 +1,152 @@
from database.connection import get_db
def normalize_arabic(text: str) -> str:
"""Normalize Arabic text for search: collapse alef variants, ta marbuta, alef maqsura."""
if not text:
return text
for ch in "أإآ":
text = text.replace(ch, "ا")
text = text.replace("ة", "ه")
text = text.replace("ى", "ي")
return text
def search_persons(query: str) -> list[dict]:
"""Search persons by name (first, family, or father name)."""
norm = normalize_arabic(query.strip())
pattern = f"%{norm}%"
raw_pattern = f"%{query.strip()}%"
with get_db() as conn:
rows = conn.execute(
"""
SELECT p.*,
COUNT(DISTINCT pr.id) AS property_count,
COUNT(DISTINCT d.id) AS document_count,
GROUP_CONCAT(DISTINCT d.search_scope) AS search_scopes
FROM persons p
LEFT JOIN properties pr ON pr.person_id = p.id
LEFT JOIN documents d ON d.person_id = p.id
WHERE p.first_name_norm LIKE ?
OR p.family_name_norm LIKE ?
OR p.father_name LIKE ?
OR p.first_name LIKE ?
OR p.family_name LIKE ?
GROUP BY p.id
ORDER BY
CASE WHEN p.first_name_norm = ? THEN 0
WHEN p.family_name_norm = ? THEN 0
ELSE 1 END,
p.first_name
""",
(pattern, pattern, raw_pattern, raw_pattern, raw_pattern, norm, norm),
).fetchall()
return [dict(r) for r in rows]
def search_properties(
property_number: str = "",
district: str = "",
block: str = "",
) -> list[dict]:
"""Search properties by number, district, or block."""
conditions = []
params = []
if property_number:
conditions.append("pr.property_number LIKE ?")
params.append(f"{property_number}%")
if district:
conditions.append("pr.real_estate_district LIKE ?")
params.append(f"%{district}%")
if block:
conditions.append("pr.block LIKE ?")
params.append(f"%{block}%")
if not conditions:
return []
where = " AND ".join(conditions)
with get_db() as conn:
rows = conn.execute(
f"""
SELECT pr.*,
p.first_name, p.father_name, p.family_name, p.family_origin,
d.search_scope
FROM properties pr
LEFT JOIN persons p ON p.id = pr.person_id
LEFT JOIN documents d ON d.id = pr.document_id
WHERE {where}
ORDER BY pr.real_estate_district, pr.property_number
""",
params,
).fetchall()
results = []
for r in rows:
row_dict = dict(r)
if not row_dict.get("qaza") and row_dict.get("search_scope"):
row_dict["qaza"] = row_dict["search_scope"]
results.append(row_dict)
return results
def get_person_with_properties(person_id: int) -> dict | None:
"""Fetch a person and all their properties."""
with get_db() as conn:
person = conn.execute(
"SELECT * FROM persons WHERE id = ?", (person_id,)
).fetchone()
if not person:
return None
props = conn.execute(
"""
SELECT pr.*, d.image_path, d.id AS document_id, d.search_scope
FROM properties pr
LEFT JOIN documents d ON d.id = pr.document_id
WHERE pr.person_id = ?
ORDER BY pr.real_estate_district, pr.row_order
""",
(person_id,),
).fetchall()
docs = conn.execute(
"SELECT id, image_path, request_number, request_date, status, page_info, search_scope FROM documents WHERE person_id = ?",
(person_id,),
).fetchall()
properties_list = []
doc_ids_with_props = set()
for p in props:
p_dict = dict(p)
if not p_dict.get("qaza") and p_dict.get("search_scope"):
p_dict["qaza"] = p_dict["search_scope"]
properties_list.append(p_dict)
doc_ids_with_props.add(p_dict.get("document_id"))
# Add a "no properties" dummy row for documents that yielded no properties
# so the search request is still recorded in the properties table
docs_list = [dict(d) for d in docs]
for d in docs_list:
if d["id"] not in doc_ids_with_props:
properties_list.append({
"document_id": d["id"],
"party_name": "-",
"property_number": "-",
"section": "-",
"block": "-",
"real_estate_district": "-",
"qaza": d.get("search_scope") or "غير محدد",
"num_shares": "-",
"ownership_type": "لا يملك"
})
return {
"person": dict(person),
"properties": properties_list,
"documents": docs_list,
}
+543
View File
@@ -0,0 +1,543 @@
/* ============================
Base & Reset
============================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #1a56db;
--primary-dark: #1145b8;
--success: #057a55;
--warning: #c27803;
--danger: #c81e1e;
--bg: #f9fafb;
--surface: #ffffff;
--border: #e5e7eb;
--text: #111827;
--text-muted: #6b7280;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,.1), 0 1px 2px rgba(0,0,0,.06);
--shadow-md: 0 4px 6px rgba(0,0,0,.07), 0 2px 4px rgba(0,0,0,.05);
}
html { font-size: 15px; }
body {
font-family: 'Segoe UI', 'Tahoma', 'Arial', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
direction: rtl;
}
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ============================
Navbar
============================ */
.navbar {
background: var(--primary);
color: white;
padding: .75rem 1.5rem;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow-md);
}
.nav-container {
max-width: 1300px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 2rem;
}
.nav-brand {
color: white;
font-size: 1.1rem;
font-weight: 700;
white-space: nowrap;
}
.nav-brand:hover { text-decoration: none; opacity: .9; }
.nav-links { display: flex; gap: 1rem; }
.nav-link { color: rgba(255,255,255,.88); font-size: .9rem; padding: .25rem .5rem; border-radius: 4px; }
.nav-link:hover { color: white; background: rgba(255,255,255,.15); text-decoration: none; }
/* ============================
Layout
============================ */
.main-content {
max-width: 1300px;
margin: 0 auto;
padding: 1.5rem;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
text-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
}
.page-header h1 { font-size: 1.5rem; color: var(--text); }
.subtitle { color: var(--text-muted); margin-top: .25rem; font-size: .95rem; }
.back-link { display: block; font-size: .875rem; color: var(--text-muted); margin-bottom: .35rem; }
/* ============================
Buttons
============================ */
.btn {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .5rem 1rem;
border-radius: var(--radius);
border: 1px solid transparent;
font-size: .9rem;
font-weight: 500;
cursor: pointer;
transition: background .15s, opacity .15s;
white-space: nowrap;
text-decoration: none;
}
.btn:hover { text-decoration: none; opacity: .9; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--primary); color: white; }
.btn-secondary { background: white; color: var(--text); border-color: var(--border); }
.btn-success { background: var(--success); color: white; }
.btn-warning { background: var(--warning); color: white; }
.btn-danger { background: var(--danger); color: white; }
.btn-large { padding: .7rem 1.5rem; font-size: 1rem; }
.btn-sm { padding: .3rem .65rem; font-size: .8rem; }
/* ============================
Stats Bar
============================ */
.stats-bar {
display: flex;
gap: .75rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.stat {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: .6rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
min-width: 90px;
box-shadow: var(--shadow);
cursor: default;
text-decoration: none;
color: var(--text);
}
a.stat:hover { border-color: var(--primary); text-decoration: none; }
a.stat.active { border-color: var(--primary); background: #eff6ff; }
.stat-num { font-size: 1.4rem; font-weight: 700; line-height: 1; }
.stat-label { font-size: .75rem; color: var(--text-muted); margin-top: .15rem; }
.stat.confirmed .stat-num { color: var(--success); }
.stat.pending .stat-num { color: var(--warning); }
.stat.error .stat-num { color: var(--danger); }
/* ============================
Alerts / Banners
============================ */
.alert-banner {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: var(--radius);
padding: .75rem 1rem;
margin-bottom: 1rem;
font-weight: 500;
}
.alert-banner a { color: var(--primary); }
.error-banner {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: var(--radius);
padding: .75rem 1rem;
margin-bottom: 1rem;
color: var(--danger);
display: flex;
align-items: center;
gap: .75rem;
}
.success-banner {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: var(--radius);
padding: .75rem 1rem;
margin-bottom: 1rem;
color: var(--success);
}
/* ============================
Upload Card
============================ */
.upload-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
margin-bottom: 1.5rem;
}
.drop-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 2.5rem 1rem;
text-align: center;
cursor: pointer;
transition: border-color .2s, background .2s;
position: relative;
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: var(--primary);
background: #eff6ff;
}
.drop-icon { font-size: 2.5rem; margin-bottom: .5rem; }
.drop-text { font-size: 1rem; font-weight: 500; margin-bottom: .3rem; }
.drop-hint { font-size: .85rem; color: var(--text-muted); }
.file-input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
.file-list { margin-top: 1rem; display: flex; flex-direction: column; gap: .35rem; }
.file-item {
background: #f9fafb;
border: 1px solid var(--border);
border-radius: 4px;
padding: .4rem .75rem;
font-size: .875rem;
display: flex;
justify-content: space-between;
}
.file-size { color: var(--text-muted); }
.upload-actions { display: flex; gap: .75rem; margin-top: 1rem; }
.hidden { display: none !important; }
/* Provider selector */
.provider-section { margin-top: 1rem; }
.provider-label { font-weight: 600; font-size: .9rem; display: block; margin-bottom: .5rem; }
.provider-options { display: flex; gap: .5rem; flex-wrap: wrap; }
.provider-option { cursor: pointer; }
.provider-option input { display: none; }
.provider-card {
display: flex;
flex-direction: column;
padding: .5rem 1rem;
border: 2px solid var(--border);
border-radius: var(--radius);
transition: border-color .15s, background .15s;
min-width: 140px;
}
.provider-option input:checked + .provider-card { border-color: var(--primary); background: #eff6ff; }
.provider-option:hover .provider-card { border-color: var(--primary); }
.provider-name { font-size: .9rem; font-weight: 600; }
.provider-model { font-size: .75rem; color: var(--text-muted); }
.provider-free .provider-name::after { content: ' ✓'; color: var(--success); }
/* Merge suggestion */
.merge-banner {
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: var(--radius);
padding: .75rem 1rem;
margin-top: .75rem;
}
.merge-banner h4 { font-size: .9rem; margin-bottom: .5rem; color: #92400e; }
.merge-person {
background: white;
border: 1px solid #fcd34d;
border-radius: 6px;
padding: .5rem .75rem;
margin-bottom: .5rem;
display: flex;
align-items: center;
gap: .75rem;
font-size: .875rem;
}
.merge-person .mp-name { font-weight: 600; }
.merge-person .mp-count { color: var(--text-muted); font-size: .8rem; }
.merge-actions { display: flex; gap: .5rem; margin-top: .5rem; }
.merge-actions label { cursor: pointer; font-size: .85rem; display: flex; align-items: center; gap: .3rem; }
/* ============================
Quick Links
============================ */
.quick-links {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: .5rem;
}
.quick-link-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
display: flex;
align-items: center;
gap: .75rem;
font-weight: 500;
color: var(--text);
box-shadow: var(--shadow);
transition: box-shadow .15s, border-color .15s;
}
.quick-link-card:hover { border-color: var(--primary); box-shadow: var(--shadow-md); text-decoration: none; }
.ql-icon { font-size: 1.4rem; }
/* ============================
Forms
============================ */
.form-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin-bottom: 1rem;
box-shadow: var(--shadow);
}
.form-section h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
padding-bottom: .5rem;
border-bottom: 1px solid var(--border);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: .75rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: .75rem;
}
.form-group { display: flex; flex-direction: column; gap: .25rem; }
.form-group label { font-size: .8rem; font-weight: 500; color: var(--text-muted); }
.form-group input, .form-group select {
border: 1px solid var(--border);
border-radius: 5px;
padding: .45rem .6rem;
font-size: .9rem;
font-family: inherit;
direction: rtl;
width: 100%;
background: white;
}
.form-group input:focus, .form-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(26,86,219,.12);
}
.form-actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 1rem;
padding: 1rem;
background: var(--surface);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.keyboard-hint { font-size: .8rem; color: var(--text-muted); }
/* ============================
Tables
============================ */
.table-wrapper { overflow-x: auto; border-radius: var(--radius); border: 1px solid var(--border); }
.results-table, .props-table {
width: 100%;
border-collapse: collapse;
font-size: .875rem;
background: var(--surface);
}
.results-table th, .props-table th {
background: #f3f4f6;
padding: .6rem .75rem;
text-align: right;
font-weight: 600;
font-size: .8rem;
white-space: nowrap;
border-bottom: 1px solid var(--border);
}
.results-table td, .props-table td {
padding: .5rem .75rem;
border-bottom: 1px solid #f3f4f6;
vertical-align: middle;
}
.results-table tr:last-child td, .props-table tr:last-child td { border-bottom: none; }
.results-table tr:hover td, .props-table tr:hover td { background: #fafafa; }
.prop-num { font-family: monospace; font-weight: 600; }
.row-num { color: var(--text-muted); font-size: .8rem; }
.props-table input {
border: 1px solid transparent;
border-radius: 4px;
padding: .3rem .4rem;
font-size: .85rem;
font-family: inherit;
direction: rtl;
width: 100%;
min-width: 80px;
background: transparent;
}
.props-table input:focus {
border-color: var(--primary);
background: white;
outline: none;
box-shadow: 0 0 0 2px rgba(26,86,219,.1);
}
.btn-del {
background: none;
border: none;
color: var(--danger);
cursor: pointer;
font-size: .9rem;
padding: .2rem .4rem;
border-radius: 4px;
}
.btn-del:hover { background: #fef2f2; }
/* ============================
Status Badges
============================ */
.status-badge {
display: inline-block;
padding: .2rem .55rem;
border-radius: 99px;
font-size: .75rem;
font-weight: 600;
}
.status-confirmed { background: #d1fae5; color: #065f46; }
.status-extracted { background: #fef3c7; color: #92400e; }
.status-pending { background: #e0f2fe; color: #075985; }
.status-error { background: #fee2e2; color: #991b1b; }
/* ============================
Person Cards
============================ */
.persons-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
margin-top: .75rem;
}
.person-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
box-shadow: var(--shadow);
display: block;
color: var(--text);
transition: border-color .15s, box-shadow .15s;
}
.person-card:hover { border-color: var(--primary); box-shadow: var(--shadow-md); text-decoration: none; }
.person-name { font-size: 1rem; font-weight: 600; margin-bottom: .3rem; }
.person-meta { font-size: .85rem; color: var(--text-muted); }
.person-reg { font-size: .8rem; color: var(--text-muted); margin-top: .2rem; font-family: monospace; }
/* ============================
Person Detail
============================ */
.person-info-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin-bottom: 1.25rem;
box-shadow: var(--shadow);
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: .75rem;
}
.info-item { display: flex; flex-direction: column; gap: .15rem; }
.info-label { font-size: .75rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .03em; }
.docs-section { margin-top: 1.5rem; }
.docs-section h3 { font-size: 1rem; font-weight: 600; margin-bottom: .75rem; }
.doc-thumbnails { display: flex; flex-wrap: wrap; gap: .75rem; }
.doc-thumb {
display: block;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
width: 120px;
text-align: center;
color: var(--text);
font-size: .75rem;
transition: box-shadow .15s;
}
.doc-thumb:hover { box-shadow: var(--shadow-md); text-decoration: none; }
.doc-thumb img { width: 100%; height: 90px; object-fit: cover; display: block; }
.doc-thumb-info { padding: .3rem; background: #f9fafb; line-height: 1.3; }
.doc-thumb-mini { width: 48px; height: 36px; object-fit: cover; border-radius: 4px; display: block; }
.doc-page-info { font-size: .7rem; color: var(--primary); font-weight: 500; }
/* ============================
Search
============================ */
.search-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow);
margin-bottom: 1.5rem;
}
.search-tabs { display: flex; gap: .5rem; margin-bottom: 1rem; }
.tab-btn {
padding: .4rem .9rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: white;
cursor: pointer;
font-size: .875rem;
font-family: inherit;
color: var(--text);
transition: background .15s, border-color .15s;
}
.tab-btn:hover { border-color: var(--primary); }
.tab-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
.search-row { display: flex; gap: .75rem; }
.search-input {
flex: 1;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: .55rem .85rem;
font-size: 1rem;
font-family: inherit;
direction: rtl;
}
.search-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(26,86,219,.12); }
.search-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: .75rem;
margin-bottom: .75rem;
}
.results-section { margin-bottom: 2rem; }
.results-section h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: .75rem; }
.no-results { text-align: center; padding: 2rem; color: var(--text-muted); }
/* ============================
Review page header
============================ */
.review-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: .75rem;
}
.review-title { display: flex; align-items: center; gap: .75rem; }
.review-title h2 { font-size: 1.25rem; }
.review-nav { display: flex; gap: .5rem; }
+44
View File
@@ -0,0 +1,44 @@
/* Review page — two-column sticky layout */
.review-layout {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 1.25rem;
align-items: start;
}
/* Image panel */
.image-panel {
position: sticky;
top: 70px;
background: #1f2937;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,.2);
}
.image-toolbar {
display: flex;
gap: .4rem;
padding: .5rem .75rem;
background: #111827;
}
.image-container {
overflow: auto;
max-height: calc(100vh - 140px);
display: flex;
align-items: flex-start;
justify-content: center;
padding: .5rem;
}
.doc-image {
max-width: 100%;
height: auto;
display: block;
transform-origin: top center;
transition: transform .2s;
border-radius: 4px;
}
/* Form panel */
.form-panel {
min-width: 0;
}
+43
View File
@@ -0,0 +1,43 @@
// Image zoom controls
let zoomLevel = 1;
function zoomIn() {
zoomLevel = Math.min(zoomLevel + 0.2, 3);
applyZoom();
}
function zoomOut() {
zoomLevel = Math.max(zoomLevel - 0.2, 0.4);
applyZoom();
}
function resetZoom() {
zoomLevel = 1;
applyZoom();
}
function applyZoom() {
const img = document.getElementById('docImage');
if (img) img.style.transform = `scale(${zoomLevel})`;
}
// Keyboard navigation in properties table
document.addEventListener('keydown', function(e) {
if (e.target.tagName !== 'INPUT') return;
const row = e.target.closest('tr');
if (!row || !row.closest('#propertiesTbody')) return;
const inputs = Array.from(row.querySelectorAll('input'));
const idx = inputs.indexOf(e.target);
if (e.key === 'Tab' && !e.shiftKey && idx === inputs.length - 1) {
e.preventDefault();
const nextRow = row.nextElementSibling;
if (nextRow) {
nextRow.querySelector('input')?.focus();
} else {
// Add a new row at end of table
if (typeof addRow === 'function') addRow();
}
}
});
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}سجل العقارات اللبناني{% endblock %}</title>
<link rel="stylesheet" href="/static/css/main.css">
{% block head %}{% endblock %}
</head>
<body>
{% block navbar %}
<nav class="navbar">
<div class="nav-container">
<a class="nav-brand" href="/">🏠 سجل العقارات</a>
<div class="nav-links">
<a href="/" class="nav-link">رفع وثائق</a>
<a href="/review/next" class="nav-link">مراجعة</a>
<a href="/search" class="nav-link">بحث</a>
<a href="/documents" class="nav-link">قائمة الوثائق</a>
<a href="/auth/users" class="nav-link">الإدارة</a>
<a href="/auth/logout" class="nav-link">تسجيل خروج</a>
</div>
</div>
</nav>
{% endblock %}
<main class="main-content">
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>
+105
View File
@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}قائمة الوثائق — سجل العقارات{% endblock %}
{% block content %}
<div class="page-header">
<h1>قائمة الوثائق</h1>
<a href="/" class="btn btn-primary">+ رفع وثائق جديدة</a>
</div>
{% if uploaded %}
<div class="success-banner">تم رفع الملفات بنجاح وبدأ الاستخراج. قد يستغرق بعض الوقت.</div>
{% endif %}
<div class="stats-bar">
<a href="/documents" class="stat {{ 'active' if not current_status }}">
<span class="stat-num">{{ stats.total or 0 }}</span><span class="stat-label">الكل</span>
</a>
<a href="/documents?status=extracted" class="stat pending {{ 'active' if current_status=='extracted' }}">
<span class="stat-num">{{ stats.pending_review or 0 }}</span><span class="stat-label">بانتظار المراجعة</span>
</a>
<a href="/documents?status=confirmed" class="stat confirmed {{ 'active' if current_status=='confirmed' }}">
<span class="stat-num">{{ stats.confirmed or 0 }}</span><span class="stat-label">مؤكّدة</span>
</a>
<a href="/documents?status=pending" class="stat {{ 'active' if current_status=='pending' }}">
<span class="stat-num">{{ stats.processing or 0 }}</span><span class="stat-label">جارٍ المعالجة</span>
</a>
{% if stats.errors %}
<a href="/documents?status=error" class="stat error {{ 'active' if current_status=='error' }}">
<span class="stat-num">{{ stats.errors }}</span><span class="stat-label">أخطاء</span>
</a>
{% endif %}
</div>
{% if stats.pending_review %}
<div class="alert-banner">
<a href="/review/next">▶ ابدأ مراجعة {{ stats.pending_review }} وثيقة</a>
</div>
{% endif %}
<div class="table-wrapper">
<table class="results-table">
<thead>
<tr>
<th>#</th>
<th>الصورة</th>
<th>الشخص</th>
<th>رقم الطلب</th>
<th>الحالة</th>
<th>التاريخ</th>
<th></th>
</tr>
</thead>
<tbody>
{% for d in documents %}
<tr>
<td>{{ d.id }}</td>
<td>
<a href="/review/{{ d.id }}">
<img src="/uploads/{{ d.image_path }}" class="doc-thumb-mini" alt="">
</a>
</td>
<td>
{% if d.person_id %}
<a href="/persons/{{ d.person_id }}">{{ d.first_name or '' }} {{ d.family_name or '' }}</a>
{% else %}—{% endif %}
</td>
<td>{{ d.request_number or '—' }}</td>
<td><span class="status-badge status-{{ d.status }}">{{ d.status }}</span></td>
<td>{{ d.created_at[:10] if d.created_at else '' }}</td>
<td class="doc-actions">
<a href="/review/{{ d.id }}" class="btn btn-sm btn-secondary">
{% if d.status == 'confirmed' %}عرض{% else %}مراجعة{% endif %}
</a>
<button class="btn btn-sm btn-danger" onclick="deleteDoc({{ d.id }}, this)">حذف</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not documents %}
<div class="no-results">
<p>لا توجد وثائق. <a href="/">ارفع وثائق جديدة</a></p>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
async function deleteDoc(docId, btn) {
if (!confirm('حذف هذه الوثيقة نهائياً؟ لا يمكن التراجع.')) return;
btn.disabled = true;
btn.textContent = '...';
const res = await fetch(`/documents/${docId}`, { method: 'DELETE' });
if (res.ok) {
btn.closest('tr').remove();
} else {
alert('حدث خطأ أثناء الحذف.');
btn.disabled = false;
btn.textContent = 'حذف';
}
}
</script>
{% endblock %}
+119
View File
@@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block title %}رفع وثائق — سجل العقارات{% endblock %}
{% block content %}
<div class="page-header">
<h1>بطاقات معلومات الملكية العقارية</h1>
<p class="subtitle">ارفع صور أو ملفات PDF للوثائق — اختر محرك الاستخراج (مجاني أو ذكاء اصطناعي)</p>
</div>
{% if stats and stats.total %}
<div class="stats-bar">
<div class="stat"><span class="stat-num">{{ stats.total }}</span><span class="stat-label">إجمالي الوثائق</span></div>
<div class="stat confirmed"><span class="stat-num">{{ stats.confirmed or 0 }}</span><span class="stat-label">مؤكّدة</span></div>
<div class="stat pending"><span class="stat-num">{{ stats.pending_review or 0 }}</span><span class="stat-label">بانتظار المراجعة</span></div>
{% if stats.errors %}<div class="stat error"><span class="stat-num">{{ stats.errors }}</span><span class="stat-label">أخطاء</span></div>{% endif %}
</div>
{% if stats.pending_review %}
<div class="alert-banner">
<a href="/review/next">▶ ابدأ مراجعة {{ stats.pending_review }} وثيقة</a>
</div>
{% endif %}
{% endif %}
<div class="upload-card">
<form id="uploadForm" action="/upload" method="post" enctype="multipart/form-data">
<div class="drop-zone" id="dropZone">
<div class="drop-icon">📄</div>
<p class="drop-text">اسحب الملفات هنا أو انقر للاختيار</p>
<p class="drop-hint">JPG، PNG، PDF — يمكن رفع عدة ملفات دفعة واحدة</p>
<input type="file" name="files" id="fileInput" multiple accept=".jpg,.jpeg,.png,.webp,.pdf" class="file-input">
</div>
<div id="fileList" class="file-list hidden"></div>
<!-- Provider selector -->
<div id="providerSection" class="provider-section hidden">
<label class="provider-label">محرك الاستخراج:</label>
<div class="provider-options">
{% for p in providers %}
<label class="provider-option">
<input type="radio" name="provider" value="{{ p.id }}"
{{ 'checked' if p.id == default_provider else '' }}>
<span class="provider-card {{ 'provider-free' if p.id == 'easyocr' else 'provider-ai' }}">
<span class="provider-name">{{ p.name }}</span>
<span class="provider-model">{{ p.model }}</span>
</span>
</label>
{% endfor %}
</div>
</div>
<div id="uploadActions" class="upload-actions hidden">
<button type="submit" class="btn btn-primary btn-large">
⬆ رفع ومعالجة
</button>
<button type="button" class="btn btn-secondary" onclick="clearFiles()">إلغاء</button>
</div>
</form>
</div>
<div class="quick-links">
<a href="/search" class="quick-link-card">
<span class="ql-icon">🔍</span>
<span>البحث في قاعدة البيانات</span>
</a>
<a href="/documents" class="quick-link-card">
<span class="ql-icon">📋</span>
<span>قائمة جميع الوثائق</span>
</a>
</div>
{% endblock %}
{% block scripts %}
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
const uploadActions = document.getElementById('uploadActions');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
fileInput.files = e.dataTransfer.files;
showFiles(fileInput.files);
});
fileInput.addEventListener('change', () => showFiles(fileInput.files));
function showFiles(files) {
if (!files.length) return;
fileList.innerHTML = Array.from(files).map(f =>
`<div class="file-item">${f.name.endsWith('.pdf')?'📕':'📄'} ${f.name} <span class="file-size">${(f.size/1024).toFixed(0)} KB</span></div>`
).join('');
fileList.classList.remove('hidden');
document.getElementById('providerSection').classList.remove('hidden');
uploadActions.classList.remove('hidden');
}
function clearFiles() {
fileInput.value = '';
fileList.innerHTML = '';
fileList.classList.add('hidden');
document.getElementById('providerSection').classList.add('hidden');
uploadActions.classList.add('hidden');
}
document.getElementById('uploadForm').addEventListener('submit', function() {
document.querySelector('.btn-primary').textContent = '⏳ جارٍ الرفع...';
document.querySelector('.btn-primary').disabled = true;
});
</script>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}تسجيل الدخول - سجل العقارات اللبناني{% endblock %}
{% block navbar %}{% endblock %}
{% block content %}
<div class="login-container" style="max-width: 400px; margin: 50px auto; padding: 20px; border: 1px solid #ccc; border-radius: 8px;">
<h2>تسجيل الدخول</h2>
{% if error %}
<div style="color: red; margin-bottom: 10px;">{{ error }}</div>
{% endif %}
<form method="post" action="/auth/login">
<div style="margin-bottom: 15px;">
<label for="username" style="display: block; margin-bottom: 5px;">اسم المستخدم:</label>
<input type="text" id="username" name="username" required style="width: 100%; padding: 8px;">
</div>
<div style="margin-bottom: 15px;">
<label for="password" style="display: block; margin-bottom: 5px;">كلمة المرور:</label>
<input type="password" id="password" name="password" required style="width: 100%; padding: 8px;">
</div>
<button type="submit" style="width: 100%; padding: 10px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">
دخول
</button>
</form>
</div>
{% endblock %}
+119
View File
@@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block title %}{{ person.first_name }} {{ person.family_name or '' }} — سجل العقارات{% endblock %}
{% block content %}
<div class="page-header">
<div>
<a href="/search" class="back-link">← العودة للبحث</a>
<h1>{{ person.first_name }} {{ person.father_name or '' }} {{ person.family_name or '' }}</h1>
</div>
<div>
{% set scopes = documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %}
<div class="export-dropdown" style="position: relative; display: inline-block;">
<button class="btn btn-primary" onclick="this.nextElementSibling.classList.toggle('show')">📥 تصدير كملف إكسل ▾</button>
<div class="export-menu" style="display:none; position:absolute; left:0; top:100%; background:#fff; border:1px solid #ddd; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,.15); z-index:100; min-width:220px; margin-top:4px;">
<a href="/persons/{{ person.id }}/export" class="export-option" download style="display:block; padding:10px 16px; text-decoration:none; color:#333; border-bottom:1px solid #eee;">📥 تصدير الكل</a>
{% for scope in scopes %}
{% if scope %}
<a href="/persons/{{ person.id }}/export?qaza={{ scope|urlencode }}" class="export-option" download style="display:block; padding:10px 16px; text-decoration:none; color:#333;">📥 {{ scope }}</a>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
<div class="person-info-card">
<div class="info-grid">
<div class="info-item"><span class="info-label">الاسم الكامل</span><span>{{ person.first_name }} {{ person.father_name or '' }} {{ person.family_name or '' }}</span></div>
{% if person.mother_name %}<div class="info-item"><span class="info-label">اسم الأم</span><span>{{ person.mother_name }}</span></div>{% endif %}
{% if person.family_origin %}<div class="info-item"><span class="info-label">الهوا</span><span>{{ person.family_origin }}</span></div>{% endif %}
{% if person.nationality %}<div class="info-item"><span class="info-label">الجنسية</span><span>{{ person.nationality }}</span></div>{% endif %}
{% if person.birth_date %}<div class="info-item"><span class="info-label">تاريخ الولادة</span><span>{{ person.birth_date }}</span></div>{% endif %}
{% if person.registry_number %}<div class="info-item"><span class="info-label">رقم السجل</span><span>{{ person.registry_number }}</span></div>{% endif %}
{% if person.registry_place %}<div class="info-item"><span class="info-label">مكان السجل</span><span>{{ person.registry_place }}</span></div>{% endif %}
{% set scopes = documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %}
{% if scopes %}<div class="info-item"><span class="info-label">القضاء</span><span>{{ scopes | join('، ') }}</span></div>{% endif %}
</div>
</div>
<div class="section-header">
<h2>العقارات المملوكة ({{ properties|length }})</h2>
</div>
{% if properties %}
<div class="table-wrapper">
<table class="results-table props-full">
<thead>
<tr>
<th>#</th>
<th>اسم الفريق</th>
<th>رقم العقار</th>
<th>القسم</th>
<th>البلوك</th>
<th>المنطقة العقارية</th>
<th>القضاء</th>
<th>عدد الأسهم</th>
<th>نوع الملكية</th>
</tr>
</thead>
<tbody>
{% for pr in properties %}
<tr {% if pr.ownership_type == 'لا يملك' %}style="background-color: #fff3f3; color: #dc3545; font-weight: bold;"{% endif %}>
<td class="row-num">{{ loop.index }}</td>
<td>{{ pr.party_name or '' }}</td>
<td class="prop-num">{{ pr.property_number or '' }}</td>
<td>{{ pr.section or '' }}</td>
<td>{{ pr.block or '' }}</td>
<td>{{ pr.real_estate_district or '' }}</td>
<td>{{ pr.qaza or '' }}</td>
<td>{{ pr.num_shares or '' }}</td>
<td>{{ pr.ownership_type or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="margin: 1rem 0; padding: 15px; background-color: #fff3f3; border: 1px solid #ffcaca; border-radius: 8px; color: #dc3545; font-weight: bold; text-align: center;">
⚠️ نتيجة البحث: لا يملك أي عقار في نطاق البحث المذكور.
</div>
{% endif %}
{% if documents %}
<div class="docs-section">
<h3>الوثائق الأصلية</h3>
<div class="doc-thumbnails">
{% for d in documents %}
<a href="/review/{{ d.id }}" class="doc-thumb">
<img src="/uploads/{{ d.image_path }}" alt="وثيقة">
<div class="doc-thumb-info">
{% if d.request_number %}طلب {{ d.request_number }}{% endif %}
{% if d.request_date %}<br>{{ d.request_date }}{% endif %}
{% if d.search_scope %}<br>القضاء: {{ d.search_scope }}{% endif %}
{% if d.page_info %}<br><span class="doc-page-info">{{ d.page_info }}</span>{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// Export dropdown toggle
document.addEventListener('click', function(e) {
document.querySelectorAll('.export-menu').forEach(function(menu) {
if (!menu.parentElement.contains(e.target)) {
menu.classList.remove('show');
}
});
});
var style = document.createElement('style');
style.textContent = '.export-menu.show { display: block !important; } .export-option:hover { background-color: #f0f0f0; }';
document.head.appendChild(style);
</script>
{% endblock %}
+358
View File
@@ -0,0 +1,358 @@
{% extends "base.html" %}
{% block title %}مراجعة الوثيقة #{{ doc.id }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="/static/css/review.css">
{% endblock %}
{% block content %}
<div class="review-header">
<div class="review-title">
<h2>مراجعة الوثيقة #{{ doc.id }}</h2>
<span class="status-badge status-{{ doc.status }}">{{ doc.status }}</span>
</div>
<div class="review-nav">
<a href="/documents" class="btn btn-secondary btn-sm">← قائمة الوثائق</a>
<a href="/review/next" class="btn btn-secondary btn-sm">التالية ←</a>
<button class="btn btn-danger btn-sm" onclick="deleteDoc({{ doc.id }})">🗑 حذف الوثيقة</button>
</div>
</div>
{% if doc.status == 'error' %}
<div class="error-banner">
⚠ خطأ في الاستخراج: {{ doc.extraction_error }}
<button class="btn btn-sm btn-warning" onclick="retriggerExtraction({{ doc.id }})">إعادة المحاولة</button>
</div>
{% endif %}
<div class="review-layout">
<!-- Image Panel -->
<div class="image-panel">
<div class="image-toolbar">
<button onclick="zoomIn()" class="btn btn-sm">🔍+</button>
<button onclick="zoomOut()" class="btn btn-sm">🔍-</button>
<button onclick="resetZoom()" class="btn btn-sm"></button>
</div>
<div class="image-container" id="imageContainer">
<img id="docImage" src="/uploads/{{ doc.image_path }}" alt="وثيقة" class="doc-image">
</div>
</div>
<!-- Form Panel -->
<div class="form-panel">
<form id="reviewForm">
<!-- Person Info -->
<section class="form-section">
<h3>بيانات الشخص</h3>
<div class="form-grid">
<div class="form-group">
<label>الاسم *</label>
<input type="text" name="first_name" value="{{ doc.person.get('first_name') or '' }}" required>
</div>
<div class="form-group">
<label>اسم الأب</label>
<input type="text" name="father_name" value="{{ doc.person.get('father_name') or '' }}">
</div>
<div class="form-group">
<label>اسم الأم</label>
<input type="text" name="mother_name" value="{{ doc.person.get('mother_name') or '' }}">
</div>
<div class="form-group">
<label>اللقب</label>
<input type="text" name="family_name" value="{{ doc.person.get('family_name') or '' }}">
</div>
<div class="form-group">
<label>الهوا / المنشأ</label>
<input type="text" name="family_origin" value="{{ doc.person.get('family_origin') or '' }}">
</div>
<div class="form-group">
<label>الجنسية</label>
<input type="text" name="nationality" value="{{ doc.person.get('nationality') or '' }}">
</div>
<div class="form-group">
<label>تاريخ الولادة</label>
<input type="text" name="birth_date" value="{{ doc.person.get('birth_date') or '' }}">
</div>
<div class="form-group">
<label>رقم السجل</label>
<input type="text" name="registry_number" value="{{ doc.person.get('registry_number') or '' }}">
</div>
<div class="form-group">
<label>مكان السجل</label>
<input type="text" name="registry_place" value="{{ doc.person.get('registry_place') or '' }}">
</div>
</div>
<!-- Merge suggestion (populated by JS) -->
<div id="mergeSuggestion" class="merge-banner hidden">
<h4>شخص مشابه موجود في قاعدة البيانات:</h4>
<div id="mergeMatches"></div>
<div class="merge-actions">
<label><input type="radio" name="merge_action" value="merge" checked> دمج مع الشخص الموجود</label>
<label><input type="radio" name="merge_action" value="new"> إنشاء شخص جديد</label>
</div>
<input type="hidden" id="mergePersonId" value="">
</div>
</section>
<!-- Document Info -->
<section class="form-section">
<h3>بيانات الطلب</h3>
<div class="form-grid">
<div class="form-group">
<label>رقم الطلب</label>
<input type="text" name="request_number" value="{{ doc.request_number or '' }}">
</div>
<div class="form-group">
<label>تاريخ الطلب</label>
<input type="text" name="request_date" value="{{ doc.request_date or '' }}">
</div>
<div class="form-group">
<label>معلومات الصفحة</label>
<input type="text" name="page_info" value="{{ doc.page_info or '' }}" placeholder="مثال: 1 من 3">
</div>
<div class="form-group">
<label>نطاق البحث (القضاء)</label>
<input type="text" name="search_scope" value="{{ doc.search_scope or '' }}" placeholder="مثال: المتن">
</div>
</div>
</section>
<!-- Properties Table -->
<section class="form-section">
<div class="section-header">
<h3>العقارات المملوكة</h3>
<button type="button" class="btn btn-sm btn-success" onclick="addRow()">+ إضافة صف</button>
</div>
<div class="table-wrapper">
<table id="propertiesTable" class="props-table">
<thead>
<tr>
<th>اسم الفريق</th>
<th>رقم العقار</th>
<th>القسم</th>
<th>البلوك</th>
<th>المنطقة العقارية</th>
<th>القضاء</th>
<th>عدد الأسهم</th>
<th>نوع الملكية</th>
<th></th>
</tr>
</thead>
<tbody id="propertiesTbody">
{% for prop in doc.properties %}
<tr>
<td><input type="text" value="{{ prop.party_name or '' }}"></td>
<td><input type="text" value="{{ prop.property_number or '' }}"></td>
<td><input type="text" value="{{ prop.section or '' }}"></td>
<td><input type="text" value="{{ prop.block or '' }}"></td>
<td><input type="text" value="{{ prop.real_estate_district or '' }}"></td>
<td><input type="text" value="{{ prop.qaza or '' }}"></td>
<td><input type="text" value="{{ prop.num_shares or '' }}"></td>
<td><input type="text" value="{{ prop.ownership_type or '' }}"></td>
<td><button type="button" class="btn-del" onclick="removeRow(this)"></button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<!-- Actions -->
<div class="form-actions">
<button type="button" class="btn btn-primary btn-large" onclick="confirmDocument()" id="confirmBtn">
✓ تأكيد والانتقال للتالية
</button>
<span class="keyboard-hint">أو اضغط Ctrl+Enter</span>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/review.js"></script>
<script>
const DOC_ID = {{ doc.id }};
function addRow() {
const tbody = document.getElementById('propertiesTbody');
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input type="text" value=""></td>
<td><input type="text" value=""></td>
<td><input type="text" value=""></td>
<td><input type="text" value=""></td>
<td><input type="text" value=""></td>
<td><input type="text" value=""></td>
<td><input type="text" value=""></td>
<td><input type="text" value=""></td>
<td><button type="button" class="btn-del" onclick="removeRow(this)">✕</button></td>
`;
tbody.appendChild(tr);
tr.querySelector('input').focus();
}
function removeRow(btn) {
btn.closest('tr').remove();
}
function getFormData() {
const form = document.getElementById('reviewForm');
const person = {
first_name: form.querySelector('[name=first_name]').value,
father_name: form.querySelector('[name=father_name]').value,
mother_name: form.querySelector('[name=mother_name]').value,
family_name: form.querySelector('[name=family_name]').value,
family_origin: form.querySelector('[name=family_origin]').value,
nationality: form.querySelector('[name=nationality]').value,
birth_date: form.querySelector('[name=birth_date]').value,
registry_number: form.querySelector('[name=registry_number]').value,
registry_place: form.querySelector('[name=registry_place]').value,
};
const rows = document.querySelectorAll('#propertiesTbody tr');
const properties = Array.from(rows).map(row => {
const inputs = row.querySelectorAll('input');
return {
party_name: inputs[0].value,
property_number: inputs[1].value,
section: inputs[2].value,
block: inputs[3].value,
real_estate_district: inputs[4].value,
qaza: inputs[5].value,
num_shares: inputs[6].value,
ownership_type: inputs[7].value,
};
});
const reqNum = form.querySelector('[name=request_number]').value;
const reqDate = form.querySelector('[name=request_date]').value;
const pageInfo = form.querySelector('[name=page_info]').value;
const searchScope = form.querySelector('[name=search_scope]').value;
return {
person,
properties,
request_number: reqNum,
request_date: reqDate,
page_info: pageInfo,
search_scope: searchScope
};
}
// ─── Duplicate person check ────────────────────────────
let _dupTimer = null;
function checkDuplicate() {
clearTimeout(_dupTimer);
_dupTimer = setTimeout(async () => {
const form = document.getElementById('reviewForm');
const first = form.querySelector('[name=first_name]').value.trim();
const father = form.querySelector('[name=father_name]').value.trim();
const family = form.querySelector('[name=family_name]').value.trim();
if (!first) return;
const params = new URLSearchParams({first_name: first, father_name: father, family_name: family});
const res = await fetch('/api/check-duplicate?' + params);
const data = await res.json();
const banner = document.getElementById('mergeSuggestion');
const matchesDiv = document.getElementById('mergeMatches');
const mergeIdInput = document.getElementById('mergePersonId');
if (data.matches && data.matches.length > 0) {
matchesDiv.innerHTML = data.matches.map(m =>
`<div class="merge-person" data-person-id="${m.id}">
<span class="mp-name">${m.first_name} ${m.father_name||''} ${m.family_name||''}</span>
<span class="mp-count">${m.property_count} عقار</span>
${m.family_origin ? '<span class="mp-count">'+m.family_origin+'</span>' : ''}
</div>`
).join('');
mergeIdInput.value = data.matches[0].id;
banner.classList.remove('hidden');
// Click to select a match
matchesDiv.querySelectorAll('.merge-person').forEach(el => {
el.addEventListener('click', () => {
matchesDiv.querySelectorAll('.merge-person').forEach(e => e.style.outline = '');
el.style.outline = '2px solid var(--primary)';
mergeIdInput.value = el.dataset.personId;
form.querySelector('[name=merge_action][value=merge]').checked = true;
});
});
} else {
banner.classList.add('hidden');
mergeIdInput.value = '';
}
}, 500);
}
// Attach duplicate check to name fields
document.querySelectorAll('[name=first_name],[name=father_name],[name=family_name]').forEach(input => {
input.addEventListener('blur', checkDuplicate);
});
// Run once on load
checkDuplicate();
// ─── Confirm ───────────────────────────────────────────
async function confirmDocument() {
const btn = document.getElementById('confirmBtn');
btn.disabled = true;
btn.textContent = '⏳ جارٍ الحفظ...';
try {
const formData = getFormData();
// Check if user chose to merge
const mergeAction = document.querySelector('[name=merge_action]:checked');
const mergeId = document.getElementById('mergePersonId').value;
if (mergeAction && mergeAction.value === 'merge' && mergeId) {
formData.merge_person_id = parseInt(mergeId);
}
const res = await fetch(`/confirm/${DOC_ID}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = await res.json();
if (data.ok) {
window.location.href = data.next;
} else {
alert('حدث خطأ: ' + JSON.stringify(data));
btn.disabled = false;
btn.textContent = '✓ تأكيد والانتقال للتالية';
}
} catch (e) {
alert('خطأ في الشبكة: ' + e);
btn.disabled = false;
btn.textContent = '✓ تأكيد والانتقال للتالية';
}
}
async function retriggerExtraction(docId) {
if (!confirm('إعادة استخراج البيانات بالذكاء الاصطناعي؟')) return;
await fetch(`/extract/${docId}`, { method: 'POST' });
location.reload();
}
async function deleteDoc(docId) {
if (!confirm('حذف هذه الوثيقة نهائياً؟ لا يمكن التراجع.')) return;
const res = await fetch(`/documents/${docId}`, { method: 'DELETE' });
if (res.ok) {
window.location.href = '/review/next';
} else {
alert('حدث خطأ أثناء الحذف.');
}
}
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
confirmDocument();
}
});
</script>
{% endblock %}
+232
View File
@@ -0,0 +1,232 @@
{% extends "base.html" %}
{% block title %}بحث — سجل العقارات{% endblock %}
{% block content %}
<div class="page-header">
<h1>البحث في قاعدة البيانات</h1>
</div>
<div class="search-card">
<form method="get" action="/search" id="searchForm">
<div class="search-tabs">
<button type="button" class="tab-btn active" onclick="showTab('person')">بحث بالاسم</button>
<button type="button" class="tab-btn" onclick="showTab('property')">بحث بالعقار</button>
</div>
<div id="tab-person" class="tab-content">
<div class="search-row">
<input type="text" name="q" id="qInput" value="{{ q }}"
placeholder="اكتب اسم الشخص..." class="search-input" autocomplete="off">
<button type="submit" class="btn btn-primary">🔍 بحث</button>
</div>
</div>
<div id="tab-property" class="tab-content hidden">
<div class="search-grid">
<div class="form-group">
<label>رقم العقار</label>
<input type="text" name="property_number" value="{{ property_number }}" placeholder="مثال: 162">
</div>
<div class="form-group">
<label>المنطقة العقارية</label>
<input type="text" name="district" value="{{ district }}" placeholder="مثال: مراح الزيتات">
</div>
<div class="form-group">
<label>البلوك</label>
<input type="text" name="block" value="{{ block }}" placeholder="رقم البلوك">
</div>
</div>
<button type="submit" class="btn btn-primary">🔍 بحث</button>
</div>
</form>
</div>
{% if error %}
<div class="error-banner">{{ error }}</div>
{% endif %}
<!-- Person results -->
{% if persons %}
<div class="results-section">
<h2>نتائج البحث بالاسم ({{ persons|length }} شخص)</h2>
{% if selected_person %}
<!-- Single person: show full details inline -->
<div class="person-info-card">
<div class="info-grid">
<div class="info-item"><span class="info-label">الاسم الكامل</span><span>{{ selected_person.person.first_name }} {{ selected_person.person.father_name or '' }} {{ selected_person.person.family_name or '' }}</span></div>
{% if selected_person.person.family_origin %}<div class="info-item"><span class="info-label">الهوا</span><span>{{ selected_person.person.family_origin }}</span></div>{% endif %}
{% if selected_person.person.nationality %}<div class="info-item"><span class="info-label">الجنسية</span><span>{{ selected_person.person.nationality }}</span></div>{% endif %}
{% if selected_person.person.registry_number %}<div class="info-item"><span class="info-label">رقم السجل</span><span>{{ selected_person.person.registry_number }}</span></div>{% endif %}
{% set scopes = selected_person.documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %}
{% if scopes %}<div class="info-item"><span class="info-label">القضاء</span><span>{{ scopes | join('، ') }}</span></div>{% endif %}
</div>
<div style="margin-top:1rem">
<a href="/persons/{{ selected_person.person.id }}" class="btn btn-secondary">عرض التفاصيل كاملة</a>
{% set scopes = selected_person.documents | selectattr('search_scope') | map(attribute='search_scope') | unique | list %}
<div class="export-dropdown" style="position: relative; display: inline-block;">
<button class="btn btn-primary" onclick="this.nextElementSibling.classList.toggle('show')">📥 تصدير كملف إكسل ▾</button>
<div class="export-menu" style="display:none; position:absolute; left:0; top:100%; background:#fff; border:1px solid #ddd; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,.15); z-index:100; min-width:220px; margin-top:4px;">
<a href="/persons/{{ selected_person.person.id }}/export" class="export-option" download style="display:block; padding:10px 16px; text-decoration:none; color:#333; border-bottom:1px solid #eee;">📥 تصدير الكل</a>
{% for scope in scopes %}
{% if scope %}
<a href="/persons/{{ selected_person.person.id }}/export?qaza={{ scope|urlencode }}" class="export-option" download style="display:block; padding:10px 16px; text-decoration:none; color:#333;">📥 {{ scope }}</a>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
{% if selected_person.properties %}
<div class="table-wrapper" style="margin-top:1rem">
<table class="results-table">
<thead>
<tr>
<th>#</th>
<th>اسم الفريق</th>
<th>رقم العقار</th>
<th>القسم</th>
<th>البلوك</th>
<th>المنطقة العقارية</th>
<th>القضاء</th>
<th>عدد الأسهم</th>
<th>نوع الملكية</th>
</tr>
</thead>
<tbody>
{% for pr in selected_person.properties %}
<tr {% if pr.ownership_type == 'لا يملك' %}style="background-color: #fff3f3; color: #dc3545; font-weight: bold;"{% endif %}>
<td>{{ loop.index }}</td>
<td>{{ pr.party_name or '' }}</td>
<td>{{ pr.property_number or '' }}</td>
<td>{{ pr.section or '' }}</td>
<td>{{ pr.block or '' }}</td>
<td>{{ pr.real_estate_district or '' }}</td>
<td>{{ pr.qaza or '' }}</td>
<td>{{ pr.num_shares or '' }}</td>
<td>{{ pr.ownership_type or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="margin-top: 1rem; padding: 15px; background-color: #fff3f3; border: 1px solid #ffcaca; border-radius: 8px; color: #dc3545; font-weight: bold; text-align: center;">
⚠️ نتيجة البحث: لا يملك أي عقار في القضاء المذكور.
</div>
{% endif %}
{% else %}
<div class="persons-grid">
{% for p in persons %}
<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-meta">
{% if p.family_origin %}{{ p.family_origin }} · {% endif %}
{% if p.property_count > 0 %}
{{ p.property_count }} عقار
{% else %}
<span style="color: #dc3545; font-weight: bold;">لا يملك عقارات</span>
{% endif %}
</div>
{% if p.search_scopes %}
<div class="person-reg">نطاق البحث: {{ p.search_scopes | replace(',', '، ') }}</div>
{% endif %}
{% if p.registry_number %}
<div class="person-reg">سجل: {{ p.registry_number }}</div>
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
<!-- Property results -->
{% if properties %}
<div class="results-section">
<h2>نتائج البحث بالعقار ({{ properties|length }} سجل)</h2>
<div class="table-wrapper">
<table class="results-table">
<thead>
<tr>
<th>المالك</th>
<th>اسم الفريق</th>
<th>رقم العقار</th>
<th>القسم</th>
<th>البلوك</th>
<th>المنطقة العقارية</th>
<th>القضاء</th>
<th>عدد الأسهم</th>
<th>نوع الملكية</th>
</tr>
</thead>
<tbody>
{% for pr in properties %}
<tr>
<td>
{% if pr.person_id %}
<a href="/persons/{{ pr.person_id }}">{{ pr.first_name or '' }} {{ pr.family_name or '' }}</a>
{% else %}—{% endif %}
</td>
<td>{{ pr.party_name or '' }}</td>
<td class="prop-num">{{ pr.property_number or '' }}</td>
<td>{{ pr.section or '' }}</td>
<td>{{ pr.block or '' }}</td>
<td>{{ pr.real_estate_district or '' }}</td>
<td>{{ pr.qaza or '' }}</td>
<td>{{ pr.num_shares or '' }}</td>
<td>{{ pr.ownership_type or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if (q or property_number or district or block) and not persons and not properties %}
<div class="no-results">
<p>لا توجد نتائج للبحث.</p>
<p><a href="/search">بحث جديد</a></p>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function showTab(tab) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
document.getElementById('tab-' + tab).classList.remove('hidden');
event.target.classList.add('active');
}
// Export dropdown toggle
document.addEventListener('click', function(e) {
document.querySelectorAll('.export-menu').forEach(function(menu) {
if (!menu.parentElement.contains(e.target)) {
menu.classList.remove('show');
}
});
});
// CSS for show class
var style = document.createElement('style');
style.textContent = '.export-menu.show { display: block !important; } .export-option:hover { background-color: #f0f0f0; }';
document.head.appendChild(style);
// Auto-select correct tab based on query params
window.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(location.search);
if (params.get('property_number') || params.get('district') || params.get('block')) {
showTab('property');
document.querySelectorAll('.tab-btn')[1].classList.add('active');
document.querySelectorAll('.tab-btn')[0].classList.remove('active');
} else {
document.getElementById('qInput').focus();
}
});
</script>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}إدارة المستخدمين - سجل العقارات اللبناني{% endblock %}
{% block content %}
<div class="users-container" style="max-width: 800px; margin: 20px auto; padding: 20px;">
<h2>إدارة النظام والمستخدمين</h2>
{% if backup_msg %}
<div style="padding: 10px; background-color: #d4edda; color: #155724; border-radius: 4px; margin-bottom: 15px;">
{{ backup_msg }}
</div>
{% endif %}
<div style="margin-bottom: 30px; padding: 15px; border: 1px solid #ddd; border-radius: 8px;">
<h3>نسخ احتياطي لقاعدة البيانات</h3>
<p>قم بإنشاء نسخة احتياطية من قاعدة البيانات الحالية.</p>
<a href="/auth/backup" style="display: inline-block; padding: 8px 15px; background-color: #28a745; color: white; text-decoration: none; border-radius: 4px;">إنشاء نسخة احتياطية</a>
</div>
<div style="margin-bottom: 30px; padding: 15px; border: 1px solid #ddd; border-radius: 8px;">
<h3>إضافة مستخدم جديد</h3>
<form method="post" action="/auth/users/create" style="display: flex; gap: 10px; align-items: flex-end;">
<div>
<label for="username" style="display: block; margin-bottom: 5px;">اسم المستخدم</label>
<input type="text" id="username" name="username" required style="padding: 8px;">
</div>
<div>
<label for="password" style="display: block; margin-bottom: 5px;">كلمة المرور</label>
<input type="password" id="password" name="password" required style="padding: 8px;">
</div>
<button type="submit" style="padding: 9px 15px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">إضافة</button>
</form>
</div>
<div style="padding: 15px; border: 1px solid #ddd; border-radius: 8px;">
<h3>المستخدمين</h3>
<table style="width: 100%; border-collapse: collapse; margin-top: 10px;">
<thead>
<tr style="background-color: #f8f9fa;">
<th style="padding: 10px; border-bottom: 2px solid #dee2e6; text-align: right;">الرقم</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6; text-align: right;">اسم المستخدم</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6; text-align: right;">تاريخ الإنشاء</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6; text-align: right;">إجراءات</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">{{ user.id }}</td>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">{{ user.username }}</td>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">{{ user.created_at }}</td>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">
<form method="post" action="/auth/users/delete/{{ user.id }}" onsubmit="return confirm('هل أنت متأكد من حذف هذا المستخدم؟');" style="display: inline;">
<button type="submit" style="padding: 5px 10px; background-color: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer;">حذف</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}