From 623d21bc3beb9391053fdac2b845d07ed82bd522 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Mon, 6 Jul 2026 16:03:09 +0530 Subject: [PATCH] Add firm admin onboarding for partner manager and staff --- app/modules/firm_admin_dashboard/service.py | 279 +++++++++++++++++- .../firm_admin_dashboard/partials/users.html | 35 ++- .../partials/wizards.html | 5 +- .../firm_admin_dashboard/user_onboarding.html | 108 +++++++ .../user_onboarding_done.html | 32 ++ app/modules/firm_admin_dashboard/ui.py | 139 ++++++++- 6 files changed, 579 insertions(+), 19 deletions(-) create mode 100644 app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding.html create mode 100644 app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding_done.html diff --git a/app/modules/firm_admin_dashboard/service.py b/app/modules/firm_admin_dashboard/service.py index d5d8634..fdbfe21 100644 --- a/app/modules/firm_admin_dashboard/service.py +++ b/app/modules/firm_admin_dashboard/service.py @@ -1,16 +1,21 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import date, datetime +from datetime import date, datetime, timezone from typing import Any from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.core.security.passwords import hash_password +from app.core.settings import get_settings +from app.modules.core.iam.invite_service import issue_invite_token +from app.modules.email_integration.services import send_user_invite_email from app.modules.core.iam.models import User from app.modules.core.rbac.models import Role, UserRole from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant from app.modules.clients.models import Client +from app.modules.employees.models import Employee from app.modules.services.models import ( FirmServiceSelection, FirmServiceTaskTemplate, @@ -150,6 +155,9 @@ def _user_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]: ).scalars().all() rows: list[dict[str, Any]] = [] for user in users: + employee = db.execute( + select(Employee).where(Employee.tenant_id == tenant_id, Employee.user_id == user.id) + ).scalar_one_or_none() rows.append( { "id": user.id, @@ -159,6 +167,9 @@ def _user_rows(db: Session, tenant_id: int | None) -> list[dict[str, Any]]: "mobile": user.mobile, "branch_id": user.branch_id, "roles": get_user_role_names(db, user.id), + "employee_id": employee.id if employee else None, + "employee_linked": bool(employee), + "employee_code": employee.employee_code if employee else None, "is_active": user.is_active, "allow_login": user.allow_login, "is_locked": user.is_locked, @@ -247,6 +258,268 @@ def _smtp_status(db: Session, tenant_id: int | None) -> dict[str, Any]: return {"available": False, "configured": False, "label": "Check settings", "message": "Open email settings to verify SMTP."} + +ONBOARDING_ROLE_CONFIG: dict[str, dict[str, str]] = { + "partner": { + "role_name": "Partner", + "label": "Partner", + "default_designation": "Partner", + "default_department": "Management", + "default_employment_type": "full_time", + }, + "manager": { + "role_name": "Branch Manager", + "label": "Manager", + "default_designation": "Branch Manager", + "default_department": "Operations", + "default_employment_type": "full_time", + }, + "staff": { + "role_name": "Staff", + "label": "Staff", + "default_designation": "Staff", + "default_department": "Operations", + "default_employment_type": "full_time", + }, +} + + +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 _parse_optional_date(value: str | None) -> date | None: + value = (value or "").strip() + if not value: + return None + return date.fromisoformat(value) + + +def _next_employee_code(db: Session, tenant_id: int) -> str: + prefix = "EMP" + rows = db.execute( + select(Employee.employee_code) + .where(Employee.tenant_id == int(tenant_id), Employee.employee_code.ilike(f"{prefix}%")) + .order_by(Employee.employee_code.desc()) + ).all() + max_no = 0 + for (code,) in rows: + suffix = "".join(ch for ch in str(code or "") if ch.isdigit()) + if suffix: + max_no = max(max_no, int(suffix)) + return f"{prefix}{max_no + 1:05d}" + + +def _ensure_employee_for_onboarded_user( + db: Session, + *, + actor: User, + user_obj: User, + employee_code: str | None, + mobile: str | None, + department: str | None, + designation: str | None, + date_of_joining: str | None, + employment_type: str | None, +) -> dict[str, Any]: + if not user_obj.tenant_id or not user_obj.branch_id: + raise ValueError("Tenant and Branch are mandatory for internal firm users.") + + existing_linked = db.execute( + select(Employee).where(Employee.tenant_id == int(user_obj.tenant_id), Employee.user_id == int(user_obj.id)) + ).scalar_one_or_none() + if existing_linked: + return {"required": True, "created": False, "linked_existing": True, "employee_id": existing_linked.id} + + email = (user_obj.email or "").strip().lower() + existing_unlinked = None + if email: + existing_unlinked = db.execute( + select(Employee).where( + Employee.tenant_id == int(user_obj.tenant_id), + Employee.user_id.is_(None), + Employee.email == email, + ) + ).scalar_one_or_none() + + if existing_unlinked: + existing_unlinked.user_id = user_obj.id + existing_unlinked.branch_id = user_obj.branch_id + existing_unlinked.full_name = user_obj.full_name or existing_unlinked.full_name + existing_unlinked.mobile = (mobile or "").strip() or existing_unlinked.mobile + existing_unlinked.department = (department or "").strip() or existing_unlinked.department + existing_unlinked.designation = (designation or "").strip() or existing_unlinked.designation + existing_unlinked.updated_by_user_id = actor.id + existing_unlinked.updated_at_utc = datetime.now(timezone.utc) + return {"required": True, "created": False, "linked_existing": True, "employee_id": existing_unlinked.id} + + code = (employee_code or "").strip() or _next_employee_code(db, int(user_obj.tenant_id)) + duplicate = db.execute( + select(Employee).where(Employee.tenant_id == int(user_obj.tenant_id), Employee.employee_code == code) + ).scalar_one_or_none() + if duplicate: + raise ValueError(f"Employee code '{code}' already exists in this firm.") + + emp_type = (employment_type or "full_time").strip() or "full_time" + if emp_type not in {"full_time", "part_time", "article_assistant", "intern", "consultant", "contract"}: + emp_type = "full_time" + + employee = Employee( + tenant_id=int(user_obj.tenant_id), + branch_id=int(user_obj.branch_id), + user_id=int(user_obj.id), + employee_code=code, + full_name=(user_obj.full_name or email or code).strip(), + email=email or None, + mobile=(mobile or "").strip() or None, + date_of_joining=_parse_optional_date(date_of_joining), + employment_type=emp_type, + status="active", + is_active=True, + department=(department or "").strip() or None, + designation=(designation or "").strip() or None, + created_by_user_id=actor.id, + updated_by_user_id=actor.id, + ) + db.add(employee) + db.flush() + return {"required": True, "created": True, "linked_existing": False, "employee_id": employee.id} + + +def get_onboarding_role_config(role_key: str) -> dict[str, str] | None: + return ONBOARDING_ROLE_CONFIG.get((role_key or "").strip().lower()) + + +def build_user_onboarding_payload(db: Session, request, current_user, role_key: str) -> dict[str, Any]: + roles = set(get_user_role_names(db, current_user.id)) + tenant_id = _active_tenant_id(request, current_user, roles) + tenant = _tenant(db, tenant_id) + config = get_onboarding_role_config(role_key) + branches = _branch_rows(db, tenant_id) + return { + "tenant": tenant, + "tenant_id": tenant_id, + "role_key": (role_key or "").strip().lower(), + "role_config": config, + "branches": branches, + "employee_code_suggestion": _next_employee_code(db, int(tenant_id)) if tenant_id else "", + } + + +def create_firm_internal_user( + db: Session, + *, + request, + actor: User, + role_key: str, + email: str, + full_name: str, + branch_id: int, + employee_code: str | None = None, + mobile: str | None = None, + department: str | None = None, + designation: str | None = None, + date_of_joining: str | None = None, + employment_type: str | None = None, +) -> dict[str, Any]: + actor_roles = set(get_user_role_names(db, actor.id)) + if not actor_roles.intersection(FIRM_ADMIN_ROLES): + raise PermissionError("Only Firm Admin or System Admin can add firm users.") + + config = get_onboarding_role_config(role_key) + if not config: + raise ValueError("Invalid onboarding role.") + + tenant_id = _active_tenant_id(request, actor, actor_roles) + if not tenant_id: + raise ValueError("Active firm context is required before adding users.") + + branch = db.get(Branch, int(branch_id)) if branch_id else None + if not branch or int(branch.tenant_id) != int(tenant_id): + raise ValueError("Please select a valid branch for the active firm.") + if not branch.is_active: + raise ValueError("Selected branch is inactive.") + + role = db.execute( + select(Role).where(Role.name == config["role_name"], Role.is_active.is_(True)) + ).scalar_one_or_none() + if not role: + raise ValueError(f"Role '{config['role_name']}' is not available. Please seed startup roles first.") + + email_clean = (email or "").strip().lower() + name_clean = (full_name or "").strip() + if not email_clean or "@" not in email_clean: + raise ValueError("A valid email is required.") + if not name_clean: + raise ValueError("Full name is required.") + if db.execute(select(User).where(User.email == email_clean)).scalar_one_or_none(): + raise ValueError("Email already exists.") + + temp_password = hash_password(__import__("secrets").token_urlsafe(18)) + user = User( + email=email_clean, + full_name=name_clean, + password_hash=temp_password, + tenant_id=int(tenant_id), + branch_id=int(branch.id), + is_active=True, + allow_login=True, + is_locked=False, + deleted_at=None, + must_change_password=True, + password_changed_at_utc=None, + designation=(designation or config.get("default_designation") or "").strip() or None, + mobile=(mobile or "").strip() or None, + ) + db.add(user) + db.flush() + db.add(UserRole(user_id=user.id, role_id=role.id)) + + employee_link_result = _ensure_employee_for_onboarded_user( + db, + actor=actor, + user_obj=user, + employee_code=employee_code, + mobile=mobile, + department=(department or config.get("default_department") or ""), + designation=(designation or config.get("default_designation") or ""), + date_of_joining=date_of_joining, + employment_type=(employment_type or config.get("default_employment_type") or "full_time"), + ) + + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise ValueError("Could not create user due to duplicate or invalid data.") from exc + + db.refresh(user) + invite_token = issue_invite_token(db, user) + invite_url = _public_invite_url(invite_token) + email_status = "not_attempted" + email_error = None + try: + email_log = send_user_invite_email(db, user=user, invite_token=invite_token) + db.commit() + email_status = getattr(email_log, "status", None) or "attempted" + email_error = getattr(email_log, "error_message", None) + except Exception as exc: # invite link fallback remains available + db.rollback() + email_status = "failed" + email_error = str(exc) + + return { + "user": user, + "role_name": role.name, + "role_label": config["label"], + "branch": branch, + "employee_link_result": employee_link_result, + "invite_url": invite_url, + "email_status": email_status, + "email_error": email_error, + } + def build_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]: roles = set(get_user_role_names(db, current_user.id)) tenant_id = _active_tenant_id(request, current_user, roles) @@ -346,7 +619,7 @@ def _report_cards() -> list[dict[str, str]]: def _wizard_cards() -> list[dict[str, str]]: return [ {"title": "Add / Manage Branches", "desc": "Create branch, office timing, login control and local storage path.", "href": "/system-settings/branches"}, - {"title": "Invite Users", "desc": "Create Firm Admin, Partner, Manager, Staff and role mapping.", "href": "/system-settings/users"}, + {"title": "Invite Users", "desc": "Create Partner, Manager and Staff directly from Firm Admin.", "href": "/firm-admin/dashboard?tab=users"}, {"title": "Firm Profile & Branding", "desc": "Display name, logo, colours and contact details.", "href": "/system-settings/branding"}, {"title": "Select Firm Services", "desc": "Enable services from system catalogue for this firm.", "href": "/services"}, {"title": "Firm Task Templates", "desc": "Customize task list and document requirements for enabled services.", "href": "/services/templates"}, diff --git a/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/users.html b/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/users.html index 4d94341..45ccd7c 100644 --- a/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/users.html +++ b/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/users.html @@ -1,22 +1,47 @@
-
-

Users & Roles

Firm Admin controls user invitations, branch mapping and role mapping.

- Manage Users +
+
+

Users & Roles

+

Firm Admin controls user invitations, branch mapping, employee linking and role mapping.

+
+
- + + + + + + + + + + {% for user in users %} + {% else %} - + {% endfor %}
UserRolesLogin StatusDesignationMobile
UserRolesEmployee LinkLogin StatusDesignationMobile
{{ user.name }}
{{ user.email }}
{% for role in user.roles %}{{ role }}{% else %}No role{% endfor %}
+ {% if user.employee_linked %} + Linked +
{{ user.employee_code or ('#' ~ user.employee_id) }}
+ {% else %} + Not linked + {% endif %} +
{% if user.must_change_password %}
Invite/password pending
{% endif %}
{{ user.designation or '-' }} {{ user.mobile or '-' }}
No users found.
No users found.
diff --git a/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/wizards.html b/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/wizards.html index 754f76a..6d3f32b 100644 --- a/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/wizards.html +++ b/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/partials/wizards.html @@ -1,7 +1,10 @@

Firm Admin Wizards & Setup Shortcuts

-

These cards link to existing setup screens. They do not duplicate existing features.

+

These cards link to existing setup screens and focused Firm Admin onboarding flows. They do not remove the existing system settings screens.

+
Add Partner

Create partner user, link employee master and generate invite.

Open
+
Add Manager

Creates user with Branch Manager role.

Open
+
Add Staff

Create staff user, employee master and invite link.

Open
{% for wizard in wizards %}
{{ wizard.title }}

{{ wizard.desc }}

Open
{% endfor %} diff --git a/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding.html b/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding.html new file mode 100644 index 0000000..46b485d --- /dev/null +++ b/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding.html @@ -0,0 +1,108 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% set data = form_data if form_data is defined and form_data else {} %} +
+
+
+
+

Firm Admin User Onboarding

+

Add {{ role_config.label if role_config else 'Firm User' }}

+

Create the user account, assign branch and role, create/link employee master, generate invite link and attempt invite email.

+
+ Back to Users +
+
+ + {% if not tenant_id %} +
+ Active firm context was not found. Please select/open a firm context before adding users. +
+ {% elif not branches %} +
+ No active branch is available for this firm. Create a branch first, then add Partner / Manager / Staff. +
+ {% elif not role_config %} +
+ Invalid onboarding role. +
+ {% else %} + {% if flash %} +
{{ flash }}
+ {% endif %} + +
+ + + +
+ + + + + + + + + + + +
+ +
+ The system will generate an invite token and show the fallback invite link after saving. The user will set their own password from that link. +
+ +
+ Cancel + +
+
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding_done.html b/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding_done.html new file mode 100644 index 0000000..fb9a0b2 --- /dev/null +++ b/app/modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding_done.html @@ -0,0 +1,32 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +{% set result = onboarding_result %} +
+
+

User Created

+

{{ result.role_label }} invite is ready

+

The user account, role mapping, branch assignment and employee master link have been completed.

+
+ +
+
+
User
{{ result.user.full_name or result.user.email }}
{{ result.user.email }}
+
Role
{{ result.role_name }}
+
Branch
{{ result.branch.name }}
+
Employee Link
{% if result.employee_link_result.employee_id %}Linked #{{ result.employee_link_result.employee_id }}{% else %}Not linked{% endif %}
+
Invite Email
{{ result.email_status }}
{% if result.email_error %}
{{ result.email_error }}
{% endif %}
+
+ +
+
Fallback Invite Link
+
{{ result.invite_url }}
+

Copy and share this link manually if the email is not delivered.

+
+ + +
+
+{% endblock %} diff --git a/app/modules/firm_admin_dashboard/ui.py b/app/modules/firm_admin_dashboard/ui.py index fd6c831..b5c8495 100644 --- a/app/modules/firm_admin_dashboard/ui.py +++ b/app/modules/firm_admin_dashboard/ui.py @@ -1,14 +1,21 @@ -from __future__ import annotations +from __future__ import annotations -from fastapi import APIRouter, Request +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse from app.core.db.common import CommonSessionLocal from app.core.http_responses import ui_access_denied -from app.core.security.csrf import get_or_create_csrf_token +from app.core.security.csrf import get_or_create_csrf_token, validate_csrf from app.core.security.session_auth import get_current_user from app.core.templating import templates from app.modules.core.rbac.deps import get_user_permissions, get_user_roles -from app.modules.firm_admin_dashboard.service import build_dashboard_payload, can_access_firm_admin_dashboard +from app.modules.firm_admin_dashboard.service import ( + build_dashboard_payload, + build_user_onboarding_payload, + can_access_firm_admin_dashboard, + create_firm_internal_user, + get_onboarding_role_config, +) router = APIRouter(prefix="/firm-admin", tags=["firm-admin-dashboard-ui"]) @@ -25,9 +32,13 @@ VALID_TABS = { } -def _ctx(request: Request, db, current_user, *, active_tab: str = "overview"): +def _redirect_login(): + return RedirectResponse(url="/login", status_code=303) + + +def _ctx(request: Request, db, current_user, *, active_tab: str = "overview", **extra): payload = build_dashboard_payload(db, request, current_user) - return { + ctx = { "request": request, "current_user": current_user, "current_user_roles": get_user_roles(db, current_user.id), @@ -37,6 +48,23 @@ def _ctx(request: Request, db, current_user, *, active_tab: str = "overview"): "active_tab": active_tab, **payload, } + ctx.update(extra) + return ctx + + +def _onboarding_ctx(request: Request, db, current_user, role: str, **extra): + payload = build_user_onboarding_payload(db, request, current_user, role) + ctx = { + "request": request, + "current_user": current_user, + "current_user_roles": get_user_roles(db, current_user.id), + "current_user_permissions": get_user_permissions(db, current_user.id), + "csrf_token": get_or_create_csrf_token(request), + "title": "Add Firm User", + **payload, + } + ctx.update(extra) + return ctx @router.get("/dashboard") @@ -45,8 +73,7 @@ def dashboard(request: Request, tab: str = "overview"): try: current_user = get_current_user(request, db=db) if not current_user: - from fastapi.responses import RedirectResponse - return RedirectResponse(url="/login", status_code=303) + return _redirect_login() if not can_access_firm_admin_dashboard(db, current_user): return ui_access_denied() active_tab = tab if tab in VALID_TABS else "overview" @@ -64,8 +91,7 @@ def dashboard_tab(request: Request, tab_name: str): try: current_user = get_current_user(request, db=db) if not current_user: - from fastapi.responses import RedirectResponse - return RedirectResponse(url="/login", status_code=303) + return _redirect_login() if not can_access_firm_admin_dashboard(db, current_user): return ui_access_denied() active_tab = tab_name if tab_name in VALID_TABS else "overview" @@ -73,3 +99,96 @@ def dashboard_tab(request: Request, tab_name: str): finally: db.close() + +@router.get("/users/new") +def user_onboarding_form(request: Request, role: str = "staff"): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + if not can_access_firm_admin_dashboard(db, current_user): + return ui_access_denied() + if not get_onboarding_role_config(role): + return RedirectResponse(url="/firm-admin/dashboard?tab=users", status_code=303) + return templates.TemplateResponse( + "modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding.html", + _onboarding_ctx(request, db, current_user, role), + ) + finally: + db.close() + + +@router.post("/users/new") +def user_onboarding_submit( + request: Request, + role: str = Form(...), + email: str = Form(...), + full_name: str = Form(...), + branch_id: int = Form(...), + employee_code: str = Form(""), + mobile: str = Form(""), + department: str = Form(""), + designation: str = Form(""), + date_of_joining: str = Form(""), + employment_type: str = Form("full_time"), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: + return _redirect_login() + if not can_access_firm_admin_dashboard(db, current_user): + return ui_access_denied() + if not get_onboarding_role_config(role): + return RedirectResponse(url="/firm-admin/dashboard?tab=users", status_code=303) + + try: + result = create_firm_internal_user( + db, + request=request, + actor=current_user, + role_key=role, + email=email, + full_name=full_name, + branch_id=branch_id, + employee_code=employee_code, + mobile=mobile, + department=department, + designation=designation, + date_of_joining=date_of_joining, + employment_type=employment_type, + ) + except Exception as exc: + db.rollback() + return templates.TemplateResponse( + "modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding.html", + _onboarding_ctx( + request, + db, + current_user, + role, + flash=str(exc), + form_data={ + "email": email, + "full_name": full_name, + "branch_id": branch_id, + "employee_code": employee_code, + "mobile": mobile, + "department": department, + "designation": designation, + "date_of_joining": date_of_joining, + "employment_type": employment_type, + }, + ), + status_code=400, + ) + + return templates.TemplateResponse( + "modules/firm_admin_dashboard/templates/firm_admin_dashboard/user_onboarding_done.html", + _ctx(request, db, current_user, active_tab="users", title="Invite Link", onboarding_result=result), + ) + finally: + db.close()