feat: add dashboard landing page and return quality review to queue tab
This commit is contained in:
parent
5cef8f9b59
commit
fcd70ec256
173
app/main.py
173
app/main.py
|
|
@ -1,24 +1,187 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import create_engine, func
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import DATABASE_URL
|
||||
from app.models.document import Document
|
||||
from app.models.extracted_field import ExtractedField
|
||||
from app.models.receipt_line_item import ReceiptLineItem
|
||||
from app.routes.documents import router as documents_router
|
||||
from app.routes.health import router as health_router
|
||||
from app.routes.ingest import router as ingest_router
|
||||
from app.routes.line_items import router as line_items_router
|
||||
from app.routes.queue import router as queue_router
|
||||
from app.routes.trash import router as trash_router
|
||||
|
||||
app = FastAPI(title="document-processor")
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
app.mount("/files", StaticFiles(directory="/mnt/storage/document-processor"), name="files")
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(documents_router)
|
||||
app.include_router(ingest_router)
|
||||
app.include_router(line_items_router)
|
||||
app.include_router(queue_router)
|
||||
app.include_router(trash_router)
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
|
||||
|
||||
def _to_str(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"app": "document-processor", "status": "running"}
|
||||
def root_dashboard(request: Request):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
total_documents = db.query(func.count(Document.id)).scalar() or 0
|
||||
active_documents = (
|
||||
db.query(func.count(Document.id))
|
||||
.filter(Document.is_trashed.is_(False))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
reviewed_documents = (
|
||||
db.query(func.count(Document.id))
|
||||
.filter(Document.is_trashed.is_(False))
|
||||
.filter(Document.review_status == "reviewed")
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
pending_review_documents = (
|
||||
db.query(func.count(Document.id))
|
||||
.filter(Document.is_trashed.is_(False))
|
||||
.filter(Document.review_status != "reviewed")
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
trashed_documents = (
|
||||
db.query(func.count(Document.id))
|
||||
.filter(Document.is_trashed.is_(True))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
extracted_documents = db.query(func.count(ExtractedField.id)).scalar() or 0
|
||||
total_line_items = db.query(func.count(ReceiptLineItem.id)).scalar() or 0
|
||||
cocktail_count = (
|
||||
db.query(func.count(ReceiptLineItem.id))
|
||||
.filter(func.lower(ReceiptLineItem.item_category) == "cocktail")
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
quality_candidates = (
|
||||
db.query(ReceiptLineItem.extra_json)
|
||||
.filter(func.lower(ReceiptLineItem.item_category) == "cocktail")
|
||||
.all()
|
||||
)
|
||||
|
||||
rated_cocktails = 0
|
||||
pending_cocktail_reviews = 0
|
||||
na_cocktails = 0
|
||||
rating_sum = Decimal("0")
|
||||
|
||||
for (extra_json,) in quality_candidates:
|
||||
extra = extra_json or {}
|
||||
status = str(extra.get("quality_status") or "").strip().lower()
|
||||
rating_raw = str(extra.get("quality_rating") or "").strip()
|
||||
|
||||
rating_dec = None
|
||||
if rating_raw:
|
||||
try:
|
||||
rating_dec = Decimal(rating_raw)
|
||||
except Exception:
|
||||
rating_dec = None
|
||||
|
||||
if status == "na":
|
||||
na_cocktails += 1
|
||||
elif rating_dec is not None:
|
||||
rated_cocktails += 1
|
||||
rating_sum += rating_dec
|
||||
else:
|
||||
pending_cocktail_reviews += 1
|
||||
|
||||
avg_cocktail_rating = ""
|
||||
if rated_cocktails > 0:
|
||||
avg_cocktail_rating = str((rating_sum / Decimal(rated_cocktails)).quantize(Decimal("0.01")))
|
||||
|
||||
recent_documents = (
|
||||
db.query(Document)
|
||||
.filter(Document.is_trashed.is_(False))
|
||||
.order_by(Document.updated_at.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
recent_line_items = (
|
||||
db.query(ReceiptLineItem)
|
||||
.join(Document, ReceiptLineItem.document_id == Document.id)
|
||||
.filter(Document.is_trashed.is_(False))
|
||||
.order_by(ReceiptLineItem.updated_at.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
recent_line_item_rows = []
|
||||
for item in recent_line_items:
|
||||
doc = item.document
|
||||
merchant = ""
|
||||
transaction_date = ""
|
||||
if doc and doc.extracted_fields:
|
||||
extracted = sorted(
|
||||
doc.extracted_fields,
|
||||
key=lambda x: x.updated_at or x.created_at,
|
||||
reverse=True,
|
||||
)[0]
|
||||
merchant = extracted.merchant_normalized or extracted.merchant_raw or ""
|
||||
if extracted.transaction_date:
|
||||
transaction_date = extracted.transaction_date.isoformat()
|
||||
|
||||
extra = item.extra_json or {}
|
||||
recent_line_item_rows.append(
|
||||
{
|
||||
"document_id": doc.document_id if doc else "",
|
||||
"merchant": merchant,
|
||||
"transaction_date": transaction_date,
|
||||
"description": item.normalized_description or item.raw_description or "",
|
||||
"category": item.item_category or "",
|
||||
"line_total": _to_str(item.line_total),
|
||||
"quality_rating": _to_str(extra.get("quality_rating")),
|
||||
"quality_status": _to_str(extra.get("quality_status")),
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="dashboard.html",
|
||||
context={
|
||||
"request": request,
|
||||
"active_page": "",
|
||||
"total_documents": total_documents,
|
||||
"active_documents": active_documents,
|
||||
"reviewed_documents": reviewed_documents,
|
||||
"pending_review_documents": pending_review_documents,
|
||||
"trashed_documents": trashed_documents,
|
||||
"extracted_documents": extracted_documents,
|
||||
"total_line_items": total_line_items,
|
||||
"cocktail_count": cocktail_count,
|
||||
"rated_cocktails": rated_cocktails,
|
||||
"pending_cocktail_reviews": pending_cocktail_reviews,
|
||||
"na_cocktails": na_cocktails,
|
||||
"avg_cocktail_rating": avg_cocktail_rating,
|
||||
"recent_documents": recent_documents,
|
||||
"recent_line_items": recent_line_item_rows,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -36,18 +36,79 @@ def _to_decimal(value: str | None) -> Decimal | None:
|
|||
return None
|
||||
|
||||
|
||||
def _line_item_extra(item: ReceiptLineItem) -> dict:
|
||||
return dict(item.extra_json or {})
|
||||
|
||||
|
||||
def _line_item_quality_rating(item: ReceiptLineItem) -> str:
|
||||
extra = item.extra_json or {}
|
||||
value = extra.get("quality_rating")
|
||||
value = _line_item_extra(item).get("quality_rating")
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _line_item_quality_note(item: ReceiptLineItem) -> str:
|
||||
extra = item.extra_json or {}
|
||||
value = extra.get("quality_note")
|
||||
value = _line_item_extra(item).get("quality_note")
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _line_item_quality_status(item: ReceiptLineItem) -> str:
|
||||
value = _line_item_extra(item).get("quality_status")
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _is_quality_queue_candidate(item: ReceiptLineItem) -> bool:
|
||||
if (item.item_category or "").lower() != "cocktail":
|
||||
return False
|
||||
|
||||
extra = _line_item_extra(item)
|
||||
status = str(extra.get("quality_status") or "").strip().lower()
|
||||
rating = str(extra.get("quality_rating") or "").strip()
|
||||
|
||||
if status == "na":
|
||||
return False
|
||||
if rating:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _build_row(item: ReceiptLineItem) -> dict | None:
|
||||
document = item.document
|
||||
if document is None:
|
||||
return None
|
||||
|
||||
extracted = get_current_extracted_fields(document)
|
||||
merchant_value = ""
|
||||
transaction_date = ""
|
||||
|
||||
if extracted is not None:
|
||||
merchant_value = (
|
||||
extracted.merchant_normalized
|
||||
or extracted.merchant_raw
|
||||
or ""
|
||||
)
|
||||
if extracted.transaction_date:
|
||||
transaction_date = extracted.transaction_date.isoformat()
|
||||
|
||||
if not transaction_date and document.created_at:
|
||||
transaction_date = document.created_at.date().isoformat()
|
||||
|
||||
return {
|
||||
"line_item_id": item.id,
|
||||
"document_id": document.document_id,
|
||||
"transaction_date": transaction_date,
|
||||
"merchant": merchant_value,
|
||||
"description": item.normalized_description or item.raw_description or "",
|
||||
"raw_description": item.raw_description or "",
|
||||
"quantity": _decimal_to_str(item.quantity),
|
||||
"line_total": _decimal_to_str(item.line_total),
|
||||
"category": item.item_category or "",
|
||||
"confidence": _decimal_to_str(item.confidence),
|
||||
"quality_rating": _line_item_quality_rating(item),
|
||||
"quality_note": _line_item_quality_note(item),
|
||||
"quality_status": _line_item_quality_status(item),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{line_item_id}/review", response_class=RedirectResponse)
|
||||
def save_line_item_review(
|
||||
line_item_id: int,
|
||||
|
|
@ -58,32 +119,51 @@ def save_line_item_review(
|
|||
date_to: str = Form(""),
|
||||
rating_min: str = Form(""),
|
||||
rating_max: str = Form(""),
|
||||
return_to: str = Form("list"),
|
||||
quality_rating: str = Form(""),
|
||||
quality_note: str = Form(""),
|
||||
quality_status: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
item = db.query(ReceiptLineItem).filter(ReceiptLineItem.id == line_item_id).first()
|
||||
if item is None:
|
||||
return RedirectResponse(url="/line-items/", status_code=303)
|
||||
|
||||
extra = dict(item.extra_json or {})
|
||||
extra = _line_item_extra(item)
|
||||
|
||||
rating_clean = quality_rating.strip()
|
||||
note_clean = quality_note.strip()
|
||||
status_clean = quality_status.strip().lower()
|
||||
|
||||
if rating_clean:
|
||||
extra["quality_rating"] = rating_clean
|
||||
else:
|
||||
if status_clean == "na":
|
||||
extra["quality_status"] = "na"
|
||||
extra.pop("quality_rating", None)
|
||||
|
||||
if note_clean:
|
||||
extra["quality_note"] = note_clean
|
||||
if note_clean:
|
||||
extra["quality_note"] = note_clean
|
||||
else:
|
||||
extra.pop("quality_note", None)
|
||||
else:
|
||||
extra.pop("quality_note", None)
|
||||
if rating_clean:
|
||||
extra["quality_rating"] = rating_clean
|
||||
extra["quality_status"] = "rated"
|
||||
else:
|
||||
extra.pop("quality_rating", None)
|
||||
if status_clean == "rated":
|
||||
extra["quality_status"] = "rated"
|
||||
else:
|
||||
extra.pop("quality_status", None)
|
||||
|
||||
if note_clean:
|
||||
extra["quality_note"] = note_clean
|
||||
else:
|
||||
extra.pop("quality_note", None)
|
||||
|
||||
item.extra_json = extra
|
||||
db.commit()
|
||||
|
||||
if return_to == "quality_queue":
|
||||
return RedirectResponse(url="/queue/?tab=quality", status_code=303)
|
||||
|
||||
redirect_url = (
|
||||
f"/line-items/?q={q}&merchant={merchant}&category={category}"
|
||||
f"&date_from={date_from}&date_to={date_to}"
|
||||
|
|
@ -92,6 +172,50 @@ def save_line_item_review(
|
|||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@router.get("/queue", response_class=HTMLResponse)
|
||||
def quality_queue(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
items = (
|
||||
db.query(ReceiptLineItem)
|
||||
.options(
|
||||
selectinload(ReceiptLineItem.document).selectinload(Document.extracted_fields)
|
||||
)
|
||||
.order_by(ReceiptLineItem.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
rows = []
|
||||
for item in items:
|
||||
if not _is_quality_queue_candidate(item):
|
||||
continue
|
||||
row = _build_row(item)
|
||||
if row is not None:
|
||||
rows.append(row)
|
||||
|
||||
rows.sort(
|
||||
key=lambda row: (
|
||||
row["transaction_date"] or "",
|
||||
row["merchant"] or "",
|
||||
row["description"] or "",
|
||||
)
|
||||
)
|
||||
|
||||
next_row = rows[0] if rows else None
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="line_items/queue.html",
|
||||
context={
|
||||
"request": request,
|
||||
"rows": rows,
|
||||
"next_row": next_row,
|
||||
"active_page": "line_items",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def list_line_items(
|
||||
request: Request,
|
||||
|
|
@ -122,45 +246,21 @@ def list_line_items(
|
|||
rows: list[dict] = []
|
||||
|
||||
for item in items:
|
||||
document = item.document
|
||||
if document is None:
|
||||
row = _build_row(item)
|
||||
if row is None:
|
||||
continue
|
||||
|
||||
extracted = get_current_extracted_fields(document)
|
||||
merchant_value = ""
|
||||
transaction_date = ""
|
||||
quality_rating_dec = _to_decimal(row["quality_rating"])
|
||||
|
||||
if extracted is not None:
|
||||
merchant_value = (
|
||||
extracted.merchant_normalized
|
||||
or extracted.merchant_raw
|
||||
or ""
|
||||
)
|
||||
if extracted.transaction_date:
|
||||
transaction_date = extracted.transaction_date.isoformat()
|
||||
|
||||
if not transaction_date and document.created_at:
|
||||
transaction_date = document.created_at.date().isoformat()
|
||||
|
||||
description_value = (
|
||||
item.normalized_description
|
||||
or item.raw_description
|
||||
or ""
|
||||
)
|
||||
category_value = item.item_category or ""
|
||||
quality_rating_value = _line_item_quality_rating(item)
|
||||
quality_note_value = _line_item_quality_note(item)
|
||||
quality_rating_dec = _to_decimal(quality_rating_value)
|
||||
|
||||
if q_norm and q_norm not in description_value.lower():
|
||||
if q_norm and q_norm not in row["description"].lower():
|
||||
continue
|
||||
if merchant_norm and merchant_norm not in merchant_value.lower():
|
||||
if merchant_norm and merchant_norm not in row["merchant"].lower():
|
||||
continue
|
||||
if category_norm and category_norm not in category_value.lower():
|
||||
if category_norm and category_norm not in row["category"].lower():
|
||||
continue
|
||||
if date_from and (not transaction_date or transaction_date < date_from):
|
||||
if date_from and (not row["transaction_date"] or row["transaction_date"] < date_from):
|
||||
continue
|
||||
if date_to and (not transaction_date or transaction_date > date_to):
|
||||
if date_to and (not row["transaction_date"] or row["transaction_date"] > date_to):
|
||||
continue
|
||||
if rating_min_dec is not None:
|
||||
if quality_rating_dec is None or quality_rating_dec < rating_min_dec:
|
||||
|
|
@ -169,22 +269,7 @@ def list_line_items(
|
|||
if quality_rating_dec is None or quality_rating_dec > rating_max_dec:
|
||||
continue
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"line_item_id": item.id,
|
||||
"document_id": document.document_id,
|
||||
"transaction_date": transaction_date,
|
||||
"merchant": merchant_value,
|
||||
"description": description_value,
|
||||
"raw_description": item.raw_description or "",
|
||||
"quantity": _decimal_to_str(item.quantity),
|
||||
"line_total": _decimal_to_str(item.line_total),
|
||||
"category": category_value,
|
||||
"confidence": _decimal_to_str(item.confidence),
|
||||
"quality_rating": quality_rating_value,
|
||||
"quality_note": quality_note_value,
|
||||
}
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
rows.sort(
|
||||
key=lambda row: (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dashboard</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell" id="app-shell">
|
||||
{% include "partials/sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1 class="page-title">Dashboard</h1>
|
||||
<p class="page-subtitle">Overview of document processing, extraction coverage, and line item review status.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="button-row">
|
||||
<a class="button-link primary" href="/documents/">Open documents</a>
|
||||
<a class="button-link" href="/queue/">Open queue</a>
|
||||
<a class="button-link" href="/line-items/">Open line items</a>
|
||||
<a class="button-link" href="/line-items/summary">Open summary</a>
|
||||
<a class="button-link" href="/ingest/">Open ingest</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">Document overview</h2>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item"><span class="meta-label">Total documents</span>{{ total_documents }}</div>
|
||||
<div class="meta-item"><span class="meta-label">Active documents</span>{{ active_documents }}</div>
|
||||
<div class="meta-item"><span class="meta-label">Reviewed documents</span>{{ reviewed_documents }}</div>
|
||||
<div class="meta-item"><span class="meta-label">Pending OCR/review</span>{{ pending_review_documents }}</div>
|
||||
<div class="meta-item"><span class="meta-label">Extracted field rows</span>{{ extracted_documents }}</div>
|
||||
<div class="meta-item"><span class="meta-label">Trashed documents</span>{{ trashed_documents }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">Line item overview</h2>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item"><span class="meta-label">Total line items</span>{{ total_line_items }}</div>
|
||||
<div class="meta-item"><span class="meta-label">Cocktail items</span>{{ cocktail_count }}</div>
|
||||
<div class="meta-item"><span class="meta-label">Rated cocktails</span>{{ rated_cocktails }}</div>
|
||||
<div class="meta-item"><span class="meta-label">Pending cocktail ratings</span>{{ pending_cocktail_reviews }}</div>
|
||||
<div class="meta-item"><span class="meta-label">Cocktails marked N/A</span>{{ na_cocktails }}</div>
|
||||
<div class="meta-item"><span class="meta-label">Average cocktail rating</span>{{ avg_cocktail_rating or "—" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">Recent documents</h2>
|
||||
{% if recent_documents %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Document</th>
|
||||
<th>Type</th>
|
||||
<th>Review status</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for doc in recent_documents %}
|
||||
<tr>
|
||||
<td><a href="/documents/{{ doc.document_id }}">{{ doc.document_id }}</a></td>
|
||||
<td>{{ doc.document_type }}</td>
|
||||
<td><span class="badge {% if doc.review_status == 'reviewed' %}reviewed{% else %}pending{% endif %}">{{ doc.review_status }}</span></td>
|
||||
<td>{{ doc.updated_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">No documents found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">Recent line items</h2>
|
||||
{% if recent_line_items %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Merchant</th>
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th>Total</th>
|
||||
<th>Rating</th>
|
||||
<th>Status</th>
|
||||
<th>Document</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in recent_line_items %}
|
||||
<tr>
|
||||
<td>{{ row.transaction_date }}</td>
|
||||
<td>{{ row.merchant }}</td>
|
||||
<td>{{ row.description }}</td>
|
||||
<td>{{ row.category }}</td>
|
||||
<td>{{ row.line_total }}</td>
|
||||
<td>{{ row.quality_rating }}</td>
|
||||
<td>{{ row.quality_status }}</td>
|
||||
<td><a href="/documents/{{ row.document_id }}?tab=extracted-fields">{{ row.document_id }}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">No line items found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const appShell = document.getElementById("app-shell");
|
||||
const menuToggle = document.getElementById("menu-toggle");
|
||||
if (!appShell || !menuToggle) return;
|
||||
menuToggle.addEventListener("click", function () {
|
||||
appShell.classList.toggle("nav-open");
|
||||
});
|
||||
menuToggle.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
appShell.classList.toggle("nav-open");
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue