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