From 779565ac6ca74fa271a9b1b49e8110275bc2b0a8 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Wed, 1 Jul 2026 15:17:38 +0530 Subject: [PATCH] wizard firm creation --- app/modules/wizards/__init__.py | 0 app/modules/wizards/service.py | 323 ++++++++++++++++++ .../wizards/system_firm_complete.html | 50 +++ .../templates/wizards/system_firm_new.html | 184 ++++++++++ .../wizards/system_firm_preview.html | 76 +++++ app/modules/wizards/ui.py | 288 ++++++++++++++++ app/ui/app.py | 2 + 7 files changed, 923 insertions(+) create mode 100644 app/modules/wizards/__init__.py create mode 100644 app/modules/wizards/service.py create mode 100644 app/modules/wizards/templates/wizards/system_firm_complete.html create mode 100644 app/modules/wizards/templates/wizards/system_firm_new.html create mode 100644 app/modules/wizards/templates/wizards/system_firm_preview.html create mode 100644 app/modules/wizards/ui.py diff --git a/app/modules/wizards/__init__.py b/app/modules/wizards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/wizards/service.py b/app/modules/wizards/service.py new file mode 100644 index 0000000..a28a99d --- /dev/null +++ b/app/modules/wizards/service.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, timedelta +import hashlib +import re +import secrets +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.security.jwt_tokens import utcnow +from app.core.security.passwords import hash_password +from app.core.settings import get_settings +from app.modules.core.iam.models import User +from app.modules.core.iam.password_flows_models import InviteToken +from app.modules.core.rbac.models import Role, UserRole +from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant +from app.modules.core.tenancy.settings_models import BranchSettings + + +class FirmWizardError(ValueError): + """Raised when the firm creation wizard receives invalid data.""" + + +@dataclass(slots=True) +class FirmWizardResult: + tenant: Tenant + branch: Branch + firm_admin: User + financial_year: FinancialYear | None + invite_url: str + invite_token: str + + +def normalize_code(value: str, *, upper: bool = True) -> str: + value = (value or "").strip() + value = re.sub(r"\s+", "_", value) + value = re.sub(r"[^A-Za-z0-9_\-]", "", value) + return value.upper() if upper else value + + +def clean_text(value: str | None) -> str: + return (value or "").strip() + + +def parse_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value or "").strip().lower() in {"1", "true", "yes", "on", "y"} + + +def parse_int(value: Any, default: int) -> int: + try: + return int(str(value).strip()) + except Exception: + return default + + +def parse_iso_date(value: str | None) -> date | None: + value = clean_text(value) + if not value: + return None + return date.fromisoformat(value) + + +def default_ay_from_fy(year_code: str) -> str: + year_code = clean_text(year_code) + try: + start_year = int(year_code.split("-", 1)[0]) + except Exception: + return "" + ay_start = start_year + 1 + return f"{ay_start}-{str(ay_start + 1)[-2:]}" + + +def default_dates_from_fy(year_code: str) -> tuple[date | None, date | None]: + year_code = clean_text(year_code) + try: + start_year = int(year_code.split("-", 1)[0]) + except Exception: + return None, None + return date(start_year, 4, 1), date(start_year + 1, 3, 31) + + +def public_invite_url(invite_token: str) -> str: + base = (get_settings().ERP_PUBLIC_BASE_URL or "").strip().rstrip("/") or "http://localhost:8000" + return f"{base}/invite/accept?token={invite_token}" + + +def build_firm_wizard_payload(form: dict[str, Any]) -> dict[str, Any]: + """Return a normalized payload used by preview and confirm pages.""" + tenant_code = normalize_code(str(form.get("tenant_code") or form.get("firm_code") or "")) + branch_code = normalize_code(str(form.get("branch_code") or "HO")) + admin_email = clean_text(str(form.get("admin_email") or "")).lower() + + fy_enabled = parse_bool(form.get("create_financial_year")) + fy_code = clean_text(str(form.get("fy_year_code") or "")) + ay_code = clean_text(str(form.get("fy_assessment_year") or "")) + fy_start_date = clean_text(str(form.get("fy_start_date") or "")) + fy_end_date = clean_text(str(form.get("fy_end_date") or "")) + + if fy_enabled and fy_code: + if not ay_code: + ay_code = default_ay_from_fy(fy_code) + if not fy_start_date or not fy_end_date: + start, end = default_dates_from_fy(fy_code) + fy_start_date = fy_start_date or (start.isoformat() if start else "") + fy_end_date = fy_end_date or (end.isoformat() if end else "") + + return { + "tenant_code": tenant_code, + "tenant_name": clean_text(str(form.get("tenant_name") or form.get("firm_name") or "")), + "firm_type": clean_text(str(form.get("firm_type") or "partnership")) or "partnership", + "default_timezone": clean_text(str(form.get("default_timezone") or "Asia/Kolkata")) or "Asia/Kolkata", + "default_session_duration_minutes": parse_int(form.get("default_session_duration_minutes"), 480), + "default_otp_required_roles_csv": clean_text(str(form.get("default_otp_required_roles_csv") or "Partner,System Admin")) or "Partner,System Admin", + "default_storage_mode": clean_text(str(form.get("default_storage_mode") or "local_only")) or "local_only", + "branch_code": branch_code, + "branch_name": clean_text(str(form.get("branch_name") or "Head Office")) or "Head Office", + "branch_timezone": clean_text(str(form.get("branch_timezone") or form.get("default_timezone") or "Asia/Kolkata")) or "Asia/Kolkata", + "branch_address_line1": clean_text(str(form.get("branch_address_line1") or "")), + "branch_address_line2": clean_text(str(form.get("branch_address_line2") or "")), + "branch_city": clean_text(str(form.get("branch_city") or "")), + "branch_state": clean_text(str(form.get("branch_state") or "")), + "branch_pin_code": clean_text(str(form.get("branch_pin_code") or "")), + "branch_gstin": clean_text(str(form.get("branch_gstin") or "")).upper(), + "branch_pan": clean_text(str(form.get("branch_pan") or "")).upper(), + "admin_full_name": clean_text(str(form.get("admin_full_name") or "")), + "admin_email": admin_email, + "admin_mobile": clean_text(str(form.get("admin_mobile") or "")), + "admin_designation": clean_text(str(form.get("admin_designation") or "Firm Admin")) or "Firm Admin", + "create_financial_year": fy_enabled, + "fy_year_code": fy_code, + "fy_assessment_year": ay_code, + "fy_start_date": fy_start_date, + "fy_end_date": fy_end_date, + "fy_is_current": parse_bool(form.get("fy_is_current")) if fy_enabled else False, + } + + +def validate_firm_wizard_payload(db: Session, payload: dict[str, Any]) -> list[str]: + errors: list[str] = [] + + if not payload["tenant_code"]: + errors.append("Firm code is required.") + if not payload["tenant_name"]: + errors.append("Firm name is required.") + if not payload["branch_code"]: + errors.append("Primary branch code is required.") + if not payload["branch_name"]: + errors.append("Primary branch name is required.") + if not payload["admin_full_name"]: + errors.append("Primary Firm Admin full name is required.") + if not payload["admin_email"]: + errors.append("Primary Firm Admin email is required.") + elif "@" not in payload["admin_email"]: + errors.append("Primary Firm Admin email is invalid.") + + if payload["default_session_duration_minutes"] < 15: + errors.append("Session duration must be at least 15 minutes.") + + allowed_firm_types = {"partnership", "proprietorship", "individual"} + if payload["firm_type"] not in allowed_firm_types: + errors.append("Invalid firm type.") + + allowed_storage_modes = {"local_only", "cloud_only", "hybrid"} + if payload["default_storage_mode"] not in allowed_storage_modes: + errors.append("Invalid default storage mode.") + + if payload["tenant_code"]: + existing_tenant = db.execute(select(Tenant).where(Tenant.code == payload["tenant_code"])).scalar_one_or_none() + if existing_tenant: + errors.append("Firm code already exists.") + + if payload["admin_email"]: + existing_user = db.execute(select(User).where(User.email == payload["admin_email"])).scalar_one_or_none() + if existing_user: + errors.append("Primary Firm Admin email already exists as a user.") + + role = db.execute(select(Role).where(Role.name == "Firm Admin", Role.is_active.is_(True))).scalar_one_or_none() + if not role: + errors.append("Firm Admin role is missing or inactive. Please seed roles before using this wizard.") + + if payload["create_financial_year"]: + if not payload["fy_year_code"]: + errors.append("Financial year code is required when default FY is enabled.") + if not payload["fy_assessment_year"]: + errors.append("Assessment year is required when default FY is enabled.") + try: + start = parse_iso_date(payload["fy_start_date"]) + end = parse_iso_date(payload["fy_end_date"]) + if not start or not end: + errors.append("Financial year start and end date are required.") + elif end <= start: + errors.append("Financial year end date must be after start date.") + except Exception: + errors.append("Financial year dates must be valid ISO dates, for example 2026-04-01.") + + return errors + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def create_invite_token_without_commit(db: Session, user: User) -> str: + plain = secrets.token_urlsafe(32) + now = utcnow() + db.add( + InviteToken( + user_id=user.id, + token_hash=_hash_token(plain), + created_at_utc=now.replace(tzinfo=None), + expires_at_utc=(now + timedelta(hours=get_settings().INVITE_TOKEN_HOURS)).replace(tzinfo=None), + used_at_utc=None, + ) + ) + user.must_change_password = True + return plain + + +def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardResult: + errors = validate_firm_wizard_payload(db, payload) + if errors: + raise FirmWizardError(" ".join(errors)) + + role = db.execute(select(Role).where(Role.name == "Firm Admin", Role.is_active.is_(True))).scalar_one() + temp_password = secrets.token_urlsafe(18) + + tenant = Tenant( + code=payload["tenant_code"], + name=payload["tenant_name"], + display_name=payload["tenant_name"], + is_active=True, + firm_type=payload["firm_type"], + default_timezone=payload["default_timezone"], + default_session_duration_minutes=payload["default_session_duration_minutes"], + default_otp_required_roles_csv=payload["default_otp_required_roles_csv"], + default_storage_mode=payload["default_storage_mode"], + contact_email=payload["admin_email"], + contact_mobile=payload["admin_mobile"] or None, + ) + db.add(tenant) + db.flush() + + branch = Branch( + tenant_id=tenant.id, + code=payload["branch_code"], + name=payload["branch_name"], + is_active=True, + timezone=payload["branch_timezone"], + allow_login=True, + allow_new_assignments=True, + is_head_office=True, + smtp_use_tls=True, + ) + db.add(branch) + db.flush() + + branch_settings = BranchSettings( + branch_id=branch.id, + address_line1=payload["branch_address_line1"] or None, + address_line2=payload["branch_address_line2"] or None, + city=payload["branch_city"] or None, + state=payload["branch_state"] or None, + pin_code=payload["branch_pin_code"] or None, + gstin=payload["branch_gstin"] or None, + pan=payload["branch_pan"] or None, + storage_mode=payload["default_storage_mode"], + otp_required_roles_csv=payload["default_otp_required_roles_csv"], + session_duration_minutes=payload["default_session_duration_minutes"], + ) + db.add(branch_settings) + db.flush() + + firm_admin = User( + email=payload["admin_email"], + full_name=payload["admin_full_name"], + password_hash=hash_password(temp_password), + tenant_id=tenant.id, + branch_id=branch.id, + is_active=True, + allow_login=True, + is_locked=False, + deleted_at=None, + must_change_password=True, + password_changed_at_utc=None, + mobile=payload["admin_mobile"] or None, + designation=payload["admin_designation"] or "Firm Admin", + ) + db.add(firm_admin) + db.flush() + db.add(UserRole(user_id=firm_admin.id, role_id=role.id)) + + financial_year = None + if payload["create_financial_year"]: + financial_year = FinancialYear( + tenant_id=tenant.id, + year_code=payload["fy_year_code"], + assessment_year=payload["fy_assessment_year"], + start_date=parse_iso_date(payload["fy_start_date"]), + end_date=parse_iso_date(payload["fy_end_date"]), + is_current=payload["fy_is_current"], + is_locked=False, + created_at_utc=datetime.utcnow(), + updated_at_utc=datetime.utcnow(), + ) + db.add(financial_year) + db.flush() + + invite_token = create_invite_token_without_commit(db, firm_admin) + invite_url = public_invite_url(invite_token) + + return FirmWizardResult( + tenant=tenant, + branch=branch, + firm_admin=firm_admin, + financial_year=financial_year, + invite_url=invite_url, + invite_token=invite_token, + ) diff --git a/app/modules/wizards/templates/wizards/system_firm_complete.html b/app/modules/wizards/templates/wizards/system_firm_complete.html new file mode 100644 index 0000000..3347acb --- /dev/null +++ b/app/modules/wizards/templates/wizards/system_firm_complete.html @@ -0,0 +1,50 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Firm created successfully

+

The tenant, primary branch, Firm Admin and invite token have been created.

+
+ +
+
+
Firm
+
{{ tenant.name }}
+
{{ tenant.code }} · {{ tenant.firm_type }}
+
+
+
Primary Branch
+
{{ branch.name }}
+
{{ branch.code }} · Head Office
+
+
+
Firm Admin
+
{{ firm_admin.full_name }}
+
{{ firm_admin.email }}
+
+
+
Financial Year
+ {% if financial_year %} +
{{ financial_year.year_code }}
+
AY {{ financial_year.assessment_year }}{% if financial_year.is_current %} · Current{% endif %}
+ {% else %} +
Not created from wizard.
+ {% endif %} +
+
+ +
+
Firm Admin invite link
+

Copy and share this link with the Firm Admin. The user will set their password through the invite acceptance page.

+
{{ invite_url }}
+
+ + +
+
+{% endblock %} diff --git a/app/modules/wizards/templates/wizards/system_firm_new.html b/app/modules/wizards/templates/wizards/system_firm_new.html new file mode 100644 index 0000000..d65a247 --- /dev/null +++ b/app/modules/wizards/templates/wizards/system_firm_new.html @@ -0,0 +1,184 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

System Admin · Firm Creation Wizard

+

Create a new firm, primary branch, primary Firm Admin and optional default financial year in one controlled flow.

+
+ Tenants +
+ + {% if errors %} +
+
Please correct the following:
+
    + {% for error in errors %}
  • {{ error }}
  • {% endfor %} +
+
+ {% endif %} + +
+ + +
+
+

1. Firm details

+

These are platform-level firm/tenant details controlled by System Admin.

+
+
+ + + + + + + +
+
+ +
+
+

2. Primary branch

+

The wizard creates the head office branch and basic branch settings.

+
+
+ + + + + + + + + + +
+
+ +
+
+

3. Primary Firm Admin

+

This user gets the Firm Admin role and an invite link to set password.

+
+
+ + + + +
+
+ +
+
+
+

4. Default financial year

+

Optional. Firm Admin can also manage financial years later from System Settings.

+
+ +
+
+ + + + + +
+
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/app/modules/wizards/templates/wizards/system_firm_preview.html b/app/modules/wizards/templates/wizards/system_firm_preview.html new file mode 100644 index 0000000..15e826c --- /dev/null +++ b/app/modules/wizards/templates/wizards/system_firm_preview.html @@ -0,0 +1,76 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+

Preview Firm Creation

+

Review the details. Nothing is saved until you click Confirm.

+ +
+ + {% for key, value in form.items() %} + {% if value is sameas true %} + + {% elif value is not sameas false and value is not none %} + + {% endif %} + {% endfor %} + +
+
+

Firm

+
+
Code
{{ form.tenant_code }}
+
Name
{{ form.tenant_name }}
+
Type
{{ form.firm_type }}
+
Timezone
{{ form.default_timezone }}
+
Storage
{{ form.default_storage_mode }}
+
+
+ +
+

Primary Branch

+
+
Code
{{ form.branch_code }}
+
Name
{{ form.branch_name }}
+
City / State
{{ form.branch_city or '-' }}{% if form.branch_state %}, {{ form.branch_state }}{% endif %}
+
PAN
{{ form.branch_pan or '-' }}
+
GSTIN
{{ form.branch_gstin or '-' }}
+
+
+ +
+

Primary Firm Admin

+
+
Name
{{ form.admin_full_name }}
+
Email
{{ form.admin_email }}
+
Mobile
{{ form.admin_mobile or '-' }}
+
Role
Firm Admin
+
+
+ +
+

Financial Year

+ {% if form.create_financial_year %} +
+
FY
{{ form.fy_year_code }}
+
AY
{{ form.fy_assessment_year }}
+
Dates
{{ form.fy_start_date }} to {{ form.fy_end_date }}
+
Current
{{ 'Yes' if form.fy_is_current else 'No' }}
+
+ {% else %} +

No financial year will be created now.

+ {% endif %} +
+
+ +
+ Confirming will create the tenant, head office branch, Firm Admin user, invite token and optional financial year. +
+ +
+ + + Cancel +
+
+
+{% endblock %} diff --git a/app/modules/wizards/ui.py b/app/modules/wizards/ui.py new file mode 100644 index 0000000..67d7d4c --- /dev/null +++ b/app/modules/wizards/ui.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse +from sqlalchemy import select + +from app.core.db.common import CommonSessionLocal +from app.core.http_responses import forbidden_response, ui_access_denied +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.audit.service import model_snapshot, write_audit_log +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant +from app.modules.core.iam.models import User +from app.modules.wizards.service import ( + FirmWizardError, + build_firm_wizard_payload, + create_firm_from_wizard, + default_ay_from_fy, + default_dates_from_fy, + validate_firm_wizard_payload, +) + +router = APIRouter(prefix="/wizards", tags=["wizards-ui"]) + + +def _ctx(request: Request, db, user, **extra): + ctx = { + "request": request, + "current_user": user, + "current_user_roles": get_user_roles(db, user.id), + "current_user_permissions": get_user_permissions(db, user.id), + "csrf_token": get_or_create_csrf_token(request), + } + ctx.update(extra) + return ctx + + +def _login_redirect(): + return RedirectResponse(url="/login", status_code=303) + + +def _csrf_rejected(request: Request): + return forbidden_response(request, "CSRF validation failed") + + +def _is_system_admin(db, user) -> bool: + return "System Admin" in get_user_roles(db, user.id) + + +def _system_admin_or_denied(request: Request, db): + user = get_current_user(request, db=db) + if not user: + return None, _login_redirect() + if not _is_system_admin(db, user): + return None, ui_access_denied() + return user, None + + +def _default_form_values() -> dict: + start, end = default_dates_from_fy("2026-27") + return { + "tenant_code": "", + "tenant_name": "", + "firm_type": "partnership", + "default_timezone": "Asia/Kolkata", + "default_session_duration_minutes": 480, + "default_otp_required_roles_csv": "Partner,System Admin", + "default_storage_mode": "local_only", + "branch_code": "HO", + "branch_name": "Head Office", + "branch_timezone": "Asia/Kolkata", + "branch_address_line1": "", + "branch_address_line2": "", + "branch_city": "", + "branch_state": "", + "branch_pin_code": "", + "branch_gstin": "", + "branch_pan": "", + "admin_full_name": "", + "admin_email": "", + "admin_mobile": "", + "admin_designation": "Firm Admin", + "create_financial_year": True, + "fy_year_code": "2026-27", + "fy_assessment_year": default_ay_from_fy("2026-27"), + "fy_start_date": start.isoformat() if start else "", + "fy_end_date": end.isoformat() if end else "", + "fy_is_current": True, + } + + +@router.get("/system/firm/new") +def system_firm_new(request: Request): + db = CommonSessionLocal() + try: + user, denied = _system_admin_or_denied(request, db) + if denied: + return denied + return templates.TemplateResponse( + "modules/wizards/templates/wizards/system_firm_new.html", + _ctx( + request, + db, + user, + title="Create Firm Wizard", + form=_default_form_values(), + errors=[], + ), + ) + finally: + db.close() + + +@router.post("/system/firm/preview") +def system_firm_preview( + request: Request, + tenant_code: str = Form(""), + tenant_name: str = Form(""), + firm_type: str = Form("partnership"), + default_timezone: str = Form("Asia/Kolkata"), + default_session_duration_minutes: int = Form(480), + default_otp_required_roles_csv: str = Form("Partner,System Admin"), + default_storage_mode: str = Form("local_only"), + branch_code: str = Form("HO"), + branch_name: str = Form("Head Office"), + branch_timezone: str = Form("Asia/Kolkata"), + branch_address_line1: str = Form(""), + branch_address_line2: str = Form(""), + branch_city: str = Form(""), + branch_state: str = Form(""), + branch_pin_code: str = Form(""), + branch_gstin: str = Form(""), + branch_pan: str = Form(""), + admin_full_name: str = Form(""), + admin_email: str = Form(""), + admin_mobile: str = Form(""), + admin_designation: str = Form("Firm Admin"), + create_financial_year: str | None = Form(None), + fy_year_code: str = Form(""), + fy_assessment_year: str = Form(""), + fy_start_date: str = Form(""), + fy_end_date: str = Form(""), + fy_is_current: str | None = Form(None), + csrf_token: str = Form(...), +): + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) + + db = CommonSessionLocal() + try: + user, denied = _system_admin_or_denied(request, db) + if denied: + return denied + + form = build_firm_wizard_payload(locals()) + errors = validate_firm_wizard_payload(db, form) + if errors: + return templates.TemplateResponse( + "modules/wizards/templates/wizards/system_firm_new.html", + _ctx(request, db, user, title="Create Firm Wizard", form=form, errors=errors), + status_code=400, + ) + + return templates.TemplateResponse( + "modules/wizards/templates/wizards/system_firm_preview.html", + _ctx(request, db, user, title="Preview Firm Creation", form=form, errors=[]), + ) + finally: + db.close() + + +@router.post("/system/firm/confirm") +def system_firm_confirm( + request: Request, + tenant_code: str = Form(""), + tenant_name: str = Form(""), + firm_type: str = Form("partnership"), + default_timezone: str = Form("Asia/Kolkata"), + default_session_duration_minutes: int = Form(480), + default_otp_required_roles_csv: str = Form("Partner,System Admin"), + default_storage_mode: str = Form("local_only"), + branch_code: str = Form("HO"), + branch_name: str = Form("Head Office"), + branch_timezone: str = Form("Asia/Kolkata"), + branch_address_line1: str = Form(""), + branch_address_line2: str = Form(""), + branch_city: str = Form(""), + branch_state: str = Form(""), + branch_pin_code: str = Form(""), + branch_gstin: str = Form(""), + branch_pan: str = Form(""), + admin_full_name: str = Form(""), + admin_email: str = Form(""), + admin_mobile: str = Form(""), + admin_designation: str = Form("Firm Admin"), + create_financial_year: str | None = Form(None), + fy_year_code: str = Form(""), + fy_assessment_year: str = Form(""), + fy_start_date: str = Form(""), + fy_end_date: str = Form(""), + fy_is_current: str | None = Form(None), + csrf_token: str = Form(...), +): + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) + + db = CommonSessionLocal() + try: + user, denied = _system_admin_or_denied(request, db) + if denied: + return denied + + form = build_firm_wizard_payload(locals()) + errors = validate_firm_wizard_payload(db, form) + if errors: + return templates.TemplateResponse( + "modules/wizards/templates/wizards/system_firm_new.html", + _ctx(request, db, user, title="Create Firm Wizard", form=form, errors=errors), + status_code=400, + ) + + try: + result = create_firm_from_wizard(db, form) + tenant_id = result.tenant.id + branch_id = result.branch.id + admin_id = result.firm_admin.id + fy_id = result.financial_year.id if result.financial_year else None + db.commit() + except FirmWizardError as exc: + db.rollback() + return templates.TemplateResponse( + "modules/wizards/templates/wizards/system_firm_new.html", + _ctx(request, db, user, title="Create Firm Wizard", form=form, errors=[str(exc)]), + status_code=400, + ) + except Exception as exc: + db.rollback() + return templates.TemplateResponse( + "modules/wizards/templates/wizards/system_firm_new.html", + _ctx(request, db, user, title="Create Firm Wizard", form=form, errors=[f"Firm was not created: {exc}"]), + status_code=400, + ) + + tenant = db.execute(select(Tenant).where(Tenant.id == tenant_id)).scalar_one() + branch = db.execute(select(Branch).where(Branch.id == branch_id)).scalar_one() + firm_admin = db.execute(select(User).where(User.id == admin_id)).scalar_one() + financial_year = db.execute(select(FinancialYear).where(FinancialYear.id == fy_id)).scalar_one_or_none() if fy_id else None + + write_audit_log( + db, + action="wizard.system.firm.create", + entity_type="tenant", + actor=user, + request=request, + entity_id=tenant.id, + entity_name=tenant.name, + target_tenant_id=tenant.id, + target_branch_id=branch.id, + details={ + "tenant": model_snapshot(tenant, ["code", "name", "firm_type", "is_active", "default_timezone", "default_storage_mode"]), + "branch": model_snapshot(branch, ["code", "name", "is_head_office", "allow_login", "allow_new_assignments"]), + "firm_admin": model_snapshot(firm_admin, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "must_change_password"]), + "financial_year": model_snapshot(financial_year, ["year_code", "assessment_year", "start_date", "end_date", "is_current"]) if financial_year else None, + }, + ) + + return templates.TemplateResponse( + "modules/wizards/templates/wizards/system_firm_complete.html", + _ctx( + request, + db, + user, + title="Firm Created", + tenant=tenant, + branch=branch, + firm_admin=firm_admin, + financial_year=financial_year, + invite_url=result.invite_url, + ), + ) + finally: + db.close() diff --git a/app/ui/app.py b/app/ui/app.py index cbe637f..d2a41da 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -22,6 +22,7 @@ from app.modules.system_settings.ui import router as system_settings_router from app.modules.email_integration.ui import router as email_integration_router from app.modules.domain_management.ui import router as domain_management_router from app.modules.notice_cases.ui import router as notice_cases_router +from app.modules.wizards.ui import router as wizards_ui_router from app.ui.routes.auth import router as auth_router @@ -43,6 +44,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(documents_ui_router) app.include_router(alerts_ui_router) app.include_router(notice_cases_router) + app.include_router(wizards_ui_router) app.include_router(work_detail_ui_router) app.include_router(clients_ui_router) app.include_router(employees_ui_router)