wizard firm creation

This commit is contained in:
A R R R Associates
2026-07-01 15:17:38 +05:30
parent a834dc0e9d
commit 779565ac6c
7 changed files with 923 additions and 0 deletions
View File
+323
View File
@@ -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,
)
@@ -0,0 +1,50 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-4xl">
<div class="rounded-3xl border border-emerald-200 bg-white p-6 shadow-soft">
<div class="rounded-2xl bg-emerald-50 p-4 text-emerald-800">
<h1 class="text-2xl font-semibold">Firm created successfully</h1>
<p class="mt-1 text-sm">The tenant, primary branch, Firm Admin and invite token have been created.</p>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-2">
<div class="rounded-2xl border p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Firm</div>
<div class="mt-2 text-lg font-semibold">{{ tenant.name }}</div>
<div class="text-sm text-slate-500">{{ tenant.code }} · {{ tenant.firm_type }}</div>
</div>
<div class="rounded-2xl border p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Primary Branch</div>
<div class="mt-2 text-lg font-semibold">{{ branch.name }}</div>
<div class="text-sm text-slate-500">{{ branch.code }} · Head Office</div>
</div>
<div class="rounded-2xl border p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Firm Admin</div>
<div class="mt-2 text-lg font-semibold">{{ firm_admin.full_name }}</div>
<div class="text-sm text-slate-500">{{ firm_admin.email }}</div>
</div>
<div class="rounded-2xl border p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Financial Year</div>
{% if financial_year %}
<div class="mt-2 text-lg font-semibold">{{ financial_year.year_code }}</div>
<div class="text-sm text-slate-500">AY {{ financial_year.assessment_year }}{% if financial_year.is_current %} · Current{% endif %}</div>
{% else %}
<div class="mt-2 text-sm text-slate-500">Not created from wizard.</div>
{% endif %}
</div>
</div>
<div class="mt-6 rounded-2xl border border-brand-200 bg-brand-50 p-4">
<div class="text-sm font-semibold text-brand-900">Firm Admin invite link</div>
<p class="mt-1 text-xs text-brand-800">Copy and share this link with the Firm Admin. The user will set their password through the invite acceptance page.</p>
<div class="mt-3 break-all rounded-xl border border-brand-200 bg-white p-3 text-sm text-brand-900">{{ invite_url }}</div>
</div>
<div class="mt-6 flex flex-wrap gap-3">
<a href="/wizards/system/firm/new" class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800">Create another firm</a>
<a href="/system-settings/tenants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">View firms</a>
<a href="/system-settings/users" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">View users</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,184 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="max-w-5xl">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">System Admin · Firm Creation Wizard</h1>
<p class="mt-1 text-sm text-slate-500">Create a new firm, primary branch, primary Firm Admin and optional default financial year in one controlled flow.</p>
</div>
<a href="/system-settings/tenants" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Tenants</a>
</div>
{% if errors %}
<div class="mt-4 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<div class="font-semibold">Please correct the following:</div>
<ul class="mt-2 list-disc pl-5">
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
</ul>
</div>
{% endif %}
<form class="mt-5 space-y-5" method="post" action="/wizards/system/firm/preview">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<section class="rounded-2xl border bg-white p-5 shadow-soft">
<div class="mb-4">
<h2 class="text-lg font-semibold">1. Firm details</h2>
<p class="text-sm text-slate-500">These are platform-level firm/tenant details controlled by System Admin.</p>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Firm code</span>
<input class="rounded-xl border px-3 py-2 uppercase" name="tenant_code" value="{{ form.tenant_code }}" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Firm name</span>
<input class="rounded-xl border px-3 py-2" name="tenant_name" value="{{ form.tenant_name }}" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Firm type</span>
<select class="rounded-xl border px-3 py-2" name="firm_type">
<option value="partnership" {% if form.firm_type == 'partnership' %}selected{% endif %}>Partnership Firm</option>
<option value="proprietorship" {% if form.firm_type == 'proprietorship' %}selected{% endif %}>Proprietorship Firm</option>
<option value="individual" {% if form.firm_type == 'individual' %}selected{% endif %}>Individual Audit Practice</option>
</select>
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Default timezone</span>
<input class="rounded-xl border px-3 py-2" name="default_timezone" value="{{ form.default_timezone or 'Asia/Kolkata' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Session duration minutes</span>
<input class="rounded-xl border px-3 py-2" type="number" min="15" name="default_session_duration_minutes" value="{{ form.default_session_duration_minutes or 480 }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Storage mode</span>
<select class="rounded-xl border px-3 py-2" name="default_storage_mode">
<option value="local_only" {% if form.default_storage_mode == 'local_only' %}selected{% endif %}>local_only</option>
<option value="cloud_only" {% if form.default_storage_mode == 'cloud_only' %}selected{% endif %}>cloud_only</option>
<option value="hybrid" {% if form.default_storage_mode == 'hybrid' %}selected{% endif %}>hybrid</option>
</select>
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">OTP required roles CSV</span>
<input class="rounded-xl border px-3 py-2" name="default_otp_required_roles_csv" value="{{ form.default_otp_required_roles_csv or 'Partner,System Admin' }}" />
</label>
</div>
</section>
<section class="rounded-2xl border bg-white p-5 shadow-soft">
<div class="mb-4">
<h2 class="text-lg font-semibold">2. Primary branch</h2>
<p class="text-sm text-slate-500">The wizard creates the head office branch and basic branch settings.</p>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Branch code</span>
<input class="rounded-xl border px-3 py-2 uppercase" name="branch_code" value="{{ form.branch_code or 'HO' }}" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Branch name</span>
<input class="rounded-xl border px-3 py-2" name="branch_name" value="{{ form.branch_name or 'Head Office' }}" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Branch timezone</span>
<input class="rounded-xl border px-3 py-2" name="branch_timezone" value="{{ form.branch_timezone or form.default_timezone or 'Asia/Kolkata' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">State</span>
<input class="rounded-xl border px-3 py-2" name="branch_state" value="{{ form.branch_state }}" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Address line 1</span>
<input class="rounded-xl border px-3 py-2" name="branch_address_line1" value="{{ form.branch_address_line1 }}" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Address line 2</span>
<input class="rounded-xl border px-3 py-2" name="branch_address_line2" value="{{ form.branch_address_line2 }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">City</span>
<input class="rounded-xl border px-3 py-2" name="branch_city" value="{{ form.branch_city }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">PIN code</span>
<input class="rounded-xl border px-3 py-2" name="branch_pin_code" value="{{ form.branch_pin_code }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Branch PAN</span>
<input class="rounded-xl border px-3 py-2 uppercase" name="branch_pan" value="{{ form.branch_pan }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Branch GSTIN</span>
<input class="rounded-xl border px-3 py-2 uppercase" name="branch_gstin" value="{{ form.branch_gstin }}" />
</label>
</div>
</section>
<section class="rounded-2xl border bg-white p-5 shadow-soft">
<div class="mb-4">
<h2 class="text-lg font-semibold">3. Primary Firm Admin</h2>
<p class="text-sm text-slate-500">This user gets the Firm Admin role and an invite link to set password.</p>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Full name</span>
<input class="rounded-xl border px-3 py-2" name="admin_full_name" value="{{ form.admin_full_name }}" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Email</span>
<input class="rounded-xl border px-3 py-2" type="email" name="admin_email" value="{{ form.admin_email }}" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Mobile</span>
<input class="rounded-xl border px-3 py-2" name="admin_mobile" value="{{ form.admin_mobile }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Designation</span>
<input class="rounded-xl border px-3 py-2" name="admin_designation" value="{{ form.admin_designation or 'Firm Admin' }}" />
</label>
</div>
</section>
<section class="rounded-2xl border bg-white p-5 shadow-soft">
<div class="mb-4 flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">4. Default financial year</h2>
<p class="text-sm text-slate-500">Optional. Firm Admin can also manage financial years later from System Settings.</p>
</div>
<label class="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" name="create_financial_year" {% if form.create_financial_year %}checked{% endif %} />
Create FY
</label>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Financial year code</span>
<input class="rounded-xl border px-3 py-2" name="fy_year_code" value="{{ form.fy_year_code }}" placeholder="2026-27" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Assessment year</span>
<input class="rounded-xl border px-3 py-2" name="fy_assessment_year" value="{{ form.fy_assessment_year }}" placeholder="2027-28" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Start date</span>
<input class="rounded-xl border px-3 py-2" type="date" name="fy_start_date" value="{{ form.fy_start_date }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">End date</span>
<input class="rounded-xl border px-3 py-2" type="date" name="fy_end_date" value="{{ form.fy_end_date }}" />
</label>
<label class="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" name="fy_is_current" {% if form.fy_is_current %}checked{% endif %} />
Mark as current financial year
</label>
</div>
</section>
<div class="flex gap-3">
<button class="rounded-xl bg-slate-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-slate-800" type="submit">Preview Firm Creation</button>
<a class="rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50" href="/system-settings">Cancel</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,76 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="max-w-5xl">
<h1 class="text-2xl font-semibold">Preview Firm Creation</h1>
<p class="mt-1 text-sm text-slate-500">Review the details. Nothing is saved until you click Confirm.</p>
<form class="mt-5" method="post" action="/wizards/system/firm/confirm">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
{% for key, value in form.items() %}
{% if value is sameas true %}
<input type="hidden" name="{{ key }}" value="on" />
{% elif value is not sameas false and value is not none %}
<input type="hidden" name="{{ key }}" value="{{ value }}" />
{% endif %}
{% endfor %}
<div class="grid gap-5 lg:grid-cols-2">
<section class="rounded-2xl border bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold">Firm</h2>
<dl class="mt-4 space-y-2 text-sm">
<div class="flex justify-between gap-4"><dt class="text-slate-500">Code</dt><dd class="font-medium">{{ form.tenant_code }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">Name</dt><dd class="font-medium">{{ form.tenant_name }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">Type</dt><dd class="font-medium">{{ form.firm_type }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">Timezone</dt><dd class="font-medium">{{ form.default_timezone }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">Storage</dt><dd class="font-medium">{{ form.default_storage_mode }}</dd></div>
</dl>
</section>
<section class="rounded-2xl border bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold">Primary Branch</h2>
<dl class="mt-4 space-y-2 text-sm">
<div class="flex justify-between gap-4"><dt class="text-slate-500">Code</dt><dd class="font-medium">{{ form.branch_code }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">Name</dt><dd class="font-medium">{{ form.branch_name }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">City / State</dt><dd class="font-medium">{{ form.branch_city or '-' }}{% if form.branch_state %}, {{ form.branch_state }}{% endif %}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">PAN</dt><dd class="font-medium">{{ form.branch_pan or '-' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">GSTIN</dt><dd class="font-medium">{{ form.branch_gstin or '-' }}</dd></div>
</dl>
</section>
<section class="rounded-2xl border bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold">Primary Firm Admin</h2>
<dl class="mt-4 space-y-2 text-sm">
<div class="flex justify-between gap-4"><dt class="text-slate-500">Name</dt><dd class="font-medium">{{ form.admin_full_name }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">Email</dt><dd class="font-medium">{{ form.admin_email }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">Mobile</dt><dd class="font-medium">{{ form.admin_mobile or '-' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">Role</dt><dd class="font-medium">Firm Admin</dd></div>
</dl>
</section>
<section class="rounded-2xl border bg-white p-5 shadow-soft">
<h2 class="text-lg font-semibold">Financial Year</h2>
{% if form.create_financial_year %}
<dl class="mt-4 space-y-2 text-sm">
<div class="flex justify-between gap-4"><dt class="text-slate-500">FY</dt><dd class="font-medium">{{ form.fy_year_code }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">AY</dt><dd class="font-medium">{{ form.fy_assessment_year }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">Dates</dt><dd class="font-medium">{{ form.fy_start_date }} to {{ form.fy_end_date }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-500">Current</dt><dd class="font-medium">{{ 'Yes' if form.fy_is_current else 'No' }}</dd></div>
</dl>
{% else %}
<p class="mt-4 text-sm text-slate-500">No financial year will be created now.</p>
{% endif %}
</section>
</div>
<div class="mt-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
Confirming will create the tenant, head office branch, Firm Admin user, invite token and optional financial year.
</div>
<div class="mt-5 flex gap-3">
<button class="rounded-xl bg-slate-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-slate-800" type="submit">Confirm & Create Firm</button>
<button class="rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50" type="button" onclick="history.back()">Back to Edit</button>
<a class="rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50" href="/system-settings">Cancel</a>
</div>
</form>
</div>
{% endblock %}
+288
View File
@@ -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()
+2
View File
@@ -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.email_integration.ui import router as email_integration_router
from app.modules.domain_management.ui import router as domain_management_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.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 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(documents_ui_router)
app.include_router(alerts_ui_router) app.include_router(alerts_ui_router)
app.include_router(notice_cases_router) app.include_router(notice_cases_router)
app.include_router(wizards_ui_router)
app.include_router(work_detail_ui_router) app.include_router(work_detail_ui_router)
app.include_router(clients_ui_router) app.include_router(clients_ui_router)
app.include_router(employees_ui_router) app.include_router(employees_ui_router)