Add admin task category import
This commit is contained in:
@@ -0,0 +1,307 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.core.db.common import CommonSessionLocal
|
||||||
|
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||||
|
from app.core.security.session_auth import get_current_user
|
||||||
|
from app.core.templating import templates
|
||||||
|
from app.modules.core.rbac.deps import get_user_roles
|
||||||
|
from app.modules.services.models import (
|
||||||
|
ClientServiceTaskInstance,
|
||||||
|
FirmServiceTaskTemplate,
|
||||||
|
ServiceCatalogue,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/services/task-categories", tags=["task-category-import-ui"])
|
||||||
|
|
||||||
|
ADMIN_ROLES = {"System Admin", "Firm Admin"}
|
||||||
|
REQUIRED_COLUMNS = {"service_code", "task_name", "task_category"}
|
||||||
|
MAX_UPLOAD_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportRow:
|
||||||
|
row_no: int
|
||||||
|
service_code: str
|
||||||
|
task_name: str
|
||||||
|
task_category: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RowResult:
|
||||||
|
row_no: int
|
||||||
|
service_code: str
|
||||||
|
task_name: str
|
||||||
|
task_category: str
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
firm_template_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _active_tenant_id(request: Request, user) -> int:
|
||||||
|
value = (
|
||||||
|
request.session.get("active_tenant_id")
|
||||||
|
or request.session.get("selected_tenant_id")
|
||||||
|
or request.session.get("tenant_id")
|
||||||
|
or getattr(user, "tenant_id", None)
|
||||||
|
)
|
||||||
|
if not value:
|
||||||
|
raise ValueError("No active tenant is selected.")
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _normal(value) -> str:
|
||||||
|
return " ".join(str(value or "").strip().split())
|
||||||
|
|
||||||
|
|
||||||
|
def _key(value) -> str:
|
||||||
|
return _normal(value).casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_csv(data: bytes) -> list[dict[str, str]]:
|
||||||
|
text = data.decode("utf-8-sig")
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
return [{str(k or "").strip(): "" if v is None else str(v) for k, v in row.items()} for row in reader]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_xlsx(data: bytes) -> list[dict[str, str]]:
|
||||||
|
try:
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ValueError("XLSX support requires openpyxl. Upload CSV instead or install openpyxl.") from exc
|
||||||
|
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||||||
|
ws = wb.active
|
||||||
|
rows = ws.iter_rows(values_only=True)
|
||||||
|
try:
|
||||||
|
headers = [str(v or "").strip() for v in next(rows)]
|
||||||
|
except StopIteration:
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
for values in rows:
|
||||||
|
if all(v is None or str(v).strip() == "" for v in values):
|
||||||
|
continue
|
||||||
|
row = {}
|
||||||
|
for i, header in enumerate(headers):
|
||||||
|
if not header:
|
||||||
|
continue
|
||||||
|
value = values[i] if i < len(values) else None
|
||||||
|
row[header] = "" if value is None else str(value)
|
||||||
|
result.append(row)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_upload(filename: str, data: bytes) -> list[ImportRow]:
|
||||||
|
if not data:
|
||||||
|
raise ValueError("The uploaded file is empty.")
|
||||||
|
if len(data) > MAX_UPLOAD_BYTES:
|
||||||
|
raise ValueError("The uploaded file exceeds the 5 MB limit.")
|
||||||
|
lower = (filename or "").lower()
|
||||||
|
if lower.endswith(".csv"):
|
||||||
|
raw_rows = _read_csv(data)
|
||||||
|
elif lower.endswith(".xlsx"):
|
||||||
|
raw_rows = _read_xlsx(data)
|
||||||
|
else:
|
||||||
|
raise ValueError("Upload a .csv or .xlsx file.")
|
||||||
|
if not raw_rows:
|
||||||
|
raise ValueError("No data rows were found in the uploaded file.")
|
||||||
|
|
||||||
|
actual = {str(k).strip().casefold(): str(k).strip() for k in raw_rows[0].keys()}
|
||||||
|
missing = [c for c in REQUIRED_COLUMNS if c.casefold() not in actual]
|
||||||
|
if missing:
|
||||||
|
raise ValueError("Missing required column(s): " + ", ".join(sorted(missing)))
|
||||||
|
|
||||||
|
def pick(row, name):
|
||||||
|
for k, v in row.items():
|
||||||
|
if str(k).strip().casefold() == name.casefold():
|
||||||
|
return _normal(v)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
parsed: list[ImportRow] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for idx, row in enumerate(raw_rows, start=2):
|
||||||
|
service_code = pick(row, "service_code")
|
||||||
|
task_name = pick(row, "task_name")
|
||||||
|
task_category = pick(row, "task_category")
|
||||||
|
if not any((service_code, task_name, task_category)):
|
||||||
|
continue
|
||||||
|
if not service_code or not task_name or not task_category:
|
||||||
|
raise ValueError(f"Row {idx}: service_code, task_name and task_category are all required.")
|
||||||
|
pair = (_key(service_code), _key(task_name))
|
||||||
|
if pair in seen:
|
||||||
|
raise ValueError(f"Row {idx}: duplicate service/task mapping in the same upload.")
|
||||||
|
seen.add(pair)
|
||||||
|
parsed.append(ImportRow(idx, service_code, task_name, task_category))
|
||||||
|
if not parsed:
|
||||||
|
raise ValueError("No usable mappings were found in the uploaded file.")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_rows(db, *, tenant_id: int, rows: Iterable[ImportRow]) -> list[RowResult]:
|
||||||
|
results: list[RowResult] = []
|
||||||
|
for row in rows:
|
||||||
|
catalogues = db.execute(
|
||||||
|
select(ServiceCatalogue).where(func.lower(ServiceCatalogue.service_code) == row.service_code.lower())
|
||||||
|
).scalars().all()
|
||||||
|
if len(catalogues) != 1:
|
||||||
|
msg = "Service code not found." if not catalogues else "Service code is ambiguous."
|
||||||
|
results.append(RowResult(row.row_no, row.service_code, row.task_name, row.task_category, "error", msg))
|
||||||
|
continue
|
||||||
|
catalogue = catalogues[0]
|
||||||
|
templates = db.execute(
|
||||||
|
select(FirmServiceTaskTemplate).where(
|
||||||
|
FirmServiceTaskTemplate.tenant_id == tenant_id,
|
||||||
|
FirmServiceTaskTemplate.service_catalogue_id == catalogue.id,
|
||||||
|
FirmServiceTaskTemplate.is_active.is_(True),
|
||||||
|
func.lower(FirmServiceTaskTemplate.task_name) == row.task_name.lower(),
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
if len(templates) != 1:
|
||||||
|
msg = "Active firm task template not found for this task name." if not templates else "Multiple active firm task templates have the same task name; no change made."
|
||||||
|
results.append(RowResult(row.row_no, row.service_code, row.task_name, row.task_category, "error", msg))
|
||||||
|
continue
|
||||||
|
template = templates[0]
|
||||||
|
current = _normal(getattr(template, "task_category", None))
|
||||||
|
if current == row.task_category:
|
||||||
|
status, msg = "unchanged", "Category already matches."
|
||||||
|
else:
|
||||||
|
status, msg = "ready", f"{current or '(blank)'} → {row.task_category}"
|
||||||
|
results.append(RowResult(row.row_no, row.service_code, row.task_name, row.task_category, status, msg, int(template.id)))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(request, db, user, **extra):
|
||||||
|
ctx = {
|
||||||
|
"request": request,
|
||||||
|
"current_user": user,
|
||||||
|
"current_user_roles": get_user_roles(db, user.id),
|
||||||
|
"csrf_token": get_or_create_csrf_token(request),
|
||||||
|
"title": "Task Categories Import",
|
||||||
|
}
|
||||||
|
ctx.update(extra)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_or_response(request: Request, db):
|
||||||
|
user = get_current_user(request, db=db)
|
||||||
|
if not user:
|
||||||
|
return None, RedirectResponse(url="/login", status_code=303)
|
||||||
|
roles = set(get_user_roles(db, user.id))
|
||||||
|
if not roles.intersection(ADMIN_ROLES):
|
||||||
|
try:
|
||||||
|
from app.core.http_responses import ui_access_denied
|
||||||
|
return None, ui_access_denied()
|
||||||
|
except Exception:
|
||||||
|
return None, Response("Access denied", status_code=403)
|
||||||
|
return user, None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/import", response_class=HTMLResponse)
|
||||||
|
def task_category_import_page(request: Request):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user, response = _admin_or_response(request, db)
|
||||||
|
if response:
|
||||||
|
return response
|
||||||
|
tenant_id = _active_tenant_id(request, user)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/services/templates/services/task_category_import.html",
|
||||||
|
_ctx(request, db, user, tenant_id=tenant_id, results=[], errors=[], imported=False, summary=None),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/template.csv")
|
||||||
|
def task_category_import_template(request: Request):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user, response = _admin_or_response(request, db)
|
||||||
|
if response:
|
||||||
|
return response
|
||||||
|
content = (
|
||||||
|
"service_code,task_name,task_category\r\n"
|
||||||
|
"TAX-AUDIT-REPORT,Verify opening balances,Planning & Opening Checks\r\n"
|
||||||
|
"TAX-AUDIT-REPORT,Verify fixed assets and depreciation,Fixed Assets & Depreciation\r\n"
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
content,
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="task_category_import_template.csv"'},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_class=HTMLResponse)
|
||||||
|
async def task_category_import_submit(
|
||||||
|
request: Request,
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
action: str = Form("validate"),
|
||||||
|
update_existing_tasks: str | None = Form(None),
|
||||||
|
):
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user, response = _admin_or_response(request, db)
|
||||||
|
if response:
|
||||||
|
return response
|
||||||
|
tenant_id = _active_tenant_id(request, user)
|
||||||
|
errors: list[str] = []
|
||||||
|
results: list[RowResult] = []
|
||||||
|
imported = False
|
||||||
|
summary = None
|
||||||
|
try:
|
||||||
|
data = await file.read()
|
||||||
|
parsed = _parse_upload(file.filename or "", data)
|
||||||
|
results = _validate_rows(db, tenant_id=tenant_id, rows=parsed)
|
||||||
|
error_count = sum(1 for r in results if r.status == "error")
|
||||||
|
ready_count = sum(1 for r in results if r.status == "ready")
|
||||||
|
unchanged_count = sum(1 for r in results if r.status == "unchanged")
|
||||||
|
summary = {"total": len(results), "ready": ready_count, "unchanged": unchanged_count, "errors": error_count, "existing_tasks_updated": 0}
|
||||||
|
|
||||||
|
if action == "import":
|
||||||
|
if error_count:
|
||||||
|
raise ValueError("Import cancelled because validation errors exist. Correct the file and upload it again; no database changes were made.")
|
||||||
|
by_template = {r.firm_template_id: r for r in results if r.firm_template_id and r.status == "ready"}
|
||||||
|
for template_id, result in by_template.items():
|
||||||
|
template = db.get(FirmServiceTaskTemplate, template_id)
|
||||||
|
if template is None or int(template.tenant_id) != tenant_id:
|
||||||
|
raise ValueError(f"Row {result.row_no}: task template changed during import. Please retry.")
|
||||||
|
template.task_category = result.task_category
|
||||||
|
|
||||||
|
if update_existing_tasks:
|
||||||
|
existing = db.execute(
|
||||||
|
select(ClientServiceTaskInstance).where(
|
||||||
|
ClientServiceTaskInstance.tenant_id == tenant_id,
|
||||||
|
ClientServiceTaskInstance.firm_task_template_id == template.id,
|
||||||
|
ClientServiceTaskInstance.is_active.is_(True),
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for task in existing:
|
||||||
|
task.task_category = result.task_category
|
||||||
|
summary["existing_tasks_updated"] += len(existing)
|
||||||
|
db.commit()
|
||||||
|
imported = True
|
||||||
|
for r in results:
|
||||||
|
if r.status == "ready":
|
||||||
|
r.status = "updated"
|
||||||
|
r.message = "Category updated successfully."
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
errors.append(str(exc))
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/services/templates/services/task_category_import.html",
|
||||||
|
_ctx(request, db, user, tenant_id=tenant_id, results=results, errors=errors, imported=imported, summary=summary),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-slate-900">Task Categories Import</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">Assign task categories to existing firm task templates for the active tenant. Validation is performed before any update.</p>
|
||||||
|
</div>
|
||||||
|
<a href="/services/task-categories/template.csv" class="inline-flex items-center rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Download CSV template</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
|
<strong>Active tenant:</strong> {{ tenant_id }}. Required columns are <code>service_code</code>, <code>task_name</code> and <code>task_category</code>. Matching is intentionally strict so an incorrect category is never applied to the wrong task.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if errors %}
|
||||||
|
<div class="rounded-xl border border-red-200 bg-red-50 p-4">
|
||||||
|
<div class="font-semibold text-red-800">Import not applied</div>
|
||||||
|
<ul class="mt-2 list-disc pl-5 text-sm text-red-700">
|
||||||
|
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if imported %}
|
||||||
|
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
|
||||||
|
Task categories were updated successfully.
|
||||||
|
{% if summary and summary.existing_tasks_updated %} {{ summary.existing_tasks_updated }} existing generated task(s) were also synchronized.{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data" class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm space-y-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">CSV or XLSX file</label>
|
||||||
|
<input type="file" name="file" accept=".csv,.xlsx" required class="mt-2 block w-full rounded-lg border border-slate-300 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<label class="flex items-start gap-3 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="update_existing_tasks" value="1" class="mt-1 rounded border-slate-300">
|
||||||
|
<span><strong>Also update existing generated tasks.</strong><br><span class="text-slate-500">Leave this unticked if you only want future/generated tasks to use the new categories. Existing completed/history tasks are otherwise left untouched.</span></span>
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button type="submit" name="action" value="validate" class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Validate only</button>
|
||||||
|
<button type="submit" name="action" value="import" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800">Import now</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if summary %}
|
||||||
|
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<div class="rounded-xl border bg-white p-4"><div class="text-xs uppercase text-slate-500">Rows</div><div class="text-xl font-semibold">{{ summary.total }}</div></div>
|
||||||
|
<div class="rounded-xl border bg-white p-4"><div class="text-xs uppercase text-slate-500">Ready</div><div class="text-xl font-semibold">{{ summary.ready }}</div></div>
|
||||||
|
<div class="rounded-xl border bg-white p-4"><div class="text-xs uppercase text-slate-500">Unchanged</div><div class="text-xl font-semibold">{{ summary.unchanged }}</div></div>
|
||||||
|
<div class="rounded-xl border bg-white p-4"><div class="text-xs uppercase text-slate-500">Errors</div><div class="text-xl font-semibold">{{ summary.errors }}</div></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if results %}
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
<tr><th class="px-4 py-3">Row</th><th class="px-4 py-3">Service</th><th class="px-4 py-3">Task</th><th class="px-4 py-3">Category</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Result</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% for row in results %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3">{{ row.row_no }}</td><td class="px-4 py-3 font-medium">{{ row.service_code }}</td><td class="px-4 py-3">{{ row.task_name }}</td><td class="px-4 py-3">{{ row.task_category }}</td>
|
||||||
|
<td class="px-4 py-3"><span class="rounded-full px-2 py-1 text-xs {% if row.status == 'error' %}bg-red-100 text-red-700{% elif row.status == 'updated' %}bg-emerald-100 text-emerald-700{% elif row.status == 'ready' %}bg-blue-100 text-blue-700{% else %}bg-slate-100 text-slate-700{% endif %}">{{ row.status }}</span></td>
|
||||||
|
<td class="px-4 py-3 text-slate-600">{{ row.message }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -18,6 +18,7 @@ from app.modules.services.ui import router as services_ui_router
|
|||||||
from app.modules.services.engagements_ui import router as engagements_ui_router
|
from app.modules.services.engagements_ui import router as engagements_ui_router
|
||||||
from app.modules.services.subscriptions_ui import router as service_subscriptions_ui_router
|
from app.modules.services.subscriptions_ui import router as service_subscriptions_ui_router
|
||||||
from app.modules.services.work_tracker_ui import router as work_tracker_ui_router
|
from app.modules.services.work_tracker_ui import router as work_tracker_ui_router
|
||||||
|
from app.modules.services.task_category_import import router as task_category_import_router
|
||||||
from app.modules.billing.ui import router as billing_ui_router
|
from app.modules.billing.ui import router as billing_ui_router
|
||||||
from app.modules.platform_billing.ui import router as platform_billing_ui_router
|
from app.modules.platform_billing.ui import router as platform_billing_ui_router
|
||||||
from app.modules.marketplace.ui import router as marketplace_ui_router, public_router as marketplace_public_router
|
from app.modules.marketplace.ui import router as marketplace_ui_router, public_router as marketplace_public_router
|
||||||
@@ -128,6 +129,7 @@ def mount_ui(app: FastAPI) -> None:
|
|||||||
app.include_router(consultants_ui_router)
|
app.include_router(consultants_ui_router)
|
||||||
app.include_router(engagements_ui_router)
|
app.include_router(engagements_ui_router)
|
||||||
app.include_router(service_subscriptions_ui_router)
|
app.include_router(service_subscriptions_ui_router)
|
||||||
|
app.include_router(task_category_import_router)
|
||||||
app.include_router(client_portal_router)
|
app.include_router(client_portal_router)
|
||||||
app.include_router(consultant_portal_router)
|
app.include_router(consultant_portal_router)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user