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