diff --git a/app/modules/services/task_category_import.py b/app/modules/services/task_category_import.py new file mode 100644 index 0000000..3081233 --- /dev/null +++ b/app/modules/services/task_category_import.py @@ -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() diff --git a/app/modules/services/templates/services/task_category_import.html b/app/modules/services/templates/services/task_category_import.html new file mode 100644 index 0000000..3a93ec4 --- /dev/null +++ b/app/modules/services/templates/services/task_category_import.html @@ -0,0 +1,76 @@ +{% extends "base/layout.html" %} +{% block content %} +
+
+
+

Task Categories Import

+

Assign task categories to existing firm task templates for the active tenant. Validation is performed before any update.

+
+ Download CSV template +
+ +
+ Active tenant: {{ tenant_id }}. Required columns are service_code, task_name and task_category. Matching is intentionally strict so an incorrect category is never applied to the wrong task. +
+ + {% if errors %} +
+
Import not applied
+ +
+ {% endif %} + + {% if imported %} +
+ Task categories were updated successfully. + {% if summary and summary.existing_tasks_updated %} {{ summary.existing_tasks_updated }} existing generated task(s) were also synchronized.{% endif %} +
+ {% endif %} + +
+ +
+ + +
+ +
+ + +
+
+ + {% if summary %} +
+
Rows
{{ summary.total }}
+
Ready
{{ summary.ready }}
+
Unchanged
{{ summary.unchanged }}
+
Errors
{{ summary.errors }}
+
+ {% endif %} + + {% if results %} +
+ + + + + + {% for row in results %} + + + + + + {% endfor %} + +
RowServiceTaskCategoryStatusResult
{{ row.row_no }}{{ row.service_code }}{{ row.task_name }}{{ row.task_category }}{{ row.status }}{{ row.message }}
+
+ {% endif %} +
+{% endblock %} diff --git a/app/ui/app.py b/app/ui/app.py index e3531ed..55ded2d 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -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.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.task_category_import import router as task_category_import_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.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(engagements_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(consultant_portal_router)