wizard firm creation
This commit is contained in:
@@ -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 %}
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user