Group search results by owner; auto-resume stuck pending documents
This commit is contained in:
@@ -38,6 +38,24 @@ async def lifespan(app: FastAPI):
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -233,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():
|
||||
"""
|
||||
|
||||
+2
-5
@@ -31,12 +31,9 @@ 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 render_template(
|
||||
templates,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user