From b84090d8c92cfe8aaa264cf4310d78edffd2c31f Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Thu, 2 Jul 2026 23:43:24 +0530 Subject: [PATCH] Add platform SMTP and firm wizard invite email --- .../20260620_phase_204f_platform_smtp.py | 60 ++++ app/modules/email_integration/models.py | 32 ++ app/modules/email_integration/services.py | 151 +++++++- .../email_integration/platform_smtp.html | 78 +++++ app/modules/email_integration/ui.py | 111 +++++- app/modules/system_settings/ui.py | 3 +- app/modules/wizards/service.py | 43 ++- ...rvice.py.bak_employee_link_20260702_150318 | 323 ++++++++++++++++++ .../wizards/system_firm_complete.html | 10 +- app/modules/wizards/ui.py | 4 + app/ui/app.py | 3 +- app/ui/templates/base/layout.html | 48 +-- 12 files changed, 821 insertions(+), 45 deletions(-) create mode 100644 alembic/versions/20260620_phase_204f_platform_smtp.py create mode 100644 app/modules/email_integration/templates/email_integration/platform_smtp.html create mode 100644 app/modules/wizards/service.py.bak_employee_link_20260702_150318 diff --git a/alembic/versions/20260620_phase_204f_platform_smtp.py b/alembic/versions/20260620_phase_204f_platform_smtp.py new file mode 100644 index 0000000..6790145 --- /dev/null +++ b/alembic/versions/20260620_phase_204f_platform_smtp.py @@ -0,0 +1,60 @@ +"""Phase v2.0.4-F - Platform SMTP settings + +Revision ID: 20260620_phase_204f_platform_smtp +Revises: 20260619_phase_204e_year_lock_backup +Create Date: 2026-06-20 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260620_phase_204f_platform_smtp" +down_revision = "20260619_phase_204e_year_lock_backup" +branch_labels = None +depends_on = None + + +def _has_table(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _has_index(bind, table_name: str, index_name: str) -> bool: + try: + return any(idx.get("name") == index_name for idx in sa.inspect(bind).get_indexes(table_name)) + except Exception: + return False + + +def upgrade() -> None: + bind = op.get_bind() + if not _has_table(bind, "platform_email_settings"): + op.create_table( + "platform_email_settings", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("smtp_host", sa.String(length=255), nullable=True), + sa.Column("smtp_port", sa.Integer(), nullable=True), + sa.Column("smtp_username", sa.String(length=255), nullable=True), + sa.Column("smtp_password", sa.String(length=500), nullable=True), + sa.Column("smtp_security", sa.String(length=20), nullable=False, server_default="SSL"), + sa.Column("smtp_timeout_seconds", sa.Integer(), nullable=False, server_default="20"), + sa.Column("from_email", sa.String(length=255), nullable=True), + sa.Column("from_name", sa.String(length=255), nullable=True), + sa.Column("reply_to_email", sa.String(length=255), nullable=True), + sa.Column("send_auth_emails", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + if not _has_index(bind, "platform_email_settings", "ix_platform_email_settings_is_active"): + op.create_index("ix_platform_email_settings_is_active", "platform_email_settings", ["is_active"]) + + +def downgrade() -> None: + bind = op.get_bind() + if _has_table(bind, "platform_email_settings"): + if _has_index(bind, "platform_email_settings", "ix_platform_email_settings_is_active"): + op.drop_index("ix_platform_email_settings_is_active", table_name="platform_email_settings") + op.drop_table("platform_email_settings") diff --git a/app/modules/email_integration/models.py b/app/modules/email_integration/models.py index a92082c..9aa7c02 100644 --- a/app/modules/email_integration/models.py +++ b/app/modules/email_integration/models.py @@ -68,6 +68,38 @@ class EmailSetting(CommonBase): updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) +class PlatformEmailSetting(CommonBase): + """Platform-level SMTP settings controlled by System Admin. + + Used before a tenant/firm is operational, for example Firm Creation + Wizard invites, platform password/reset notices and system security emails. + Firm/branch SMTP remains in EmailSetting and is not changed by this table. + """ + + __tablename__ = "platform_email_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True) + smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_password: Mapped[str | None] = mapped_column(String(500), nullable=True) + smtp_security: Mapped[str] = mapped_column(String(20), nullable=False, default="SSL") # SSL|STARTTLS|NONE + smtp_timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=20) + + from_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + from_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + reply_to_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + + send_auth_emails: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + class EmailTemplate(CommonBase): __tablename__ = "email_templates" __table_args__ = ( diff --git a/app/modules/email_integration/services.py b/app/modules/email_integration/services.py index 84e2210..bc2a5cb 100644 --- a/app/modules/email_integration/services.py +++ b/app/modules/email_integration/services.py @@ -11,7 +11,7 @@ from typing import Any from sqlalchemy import select from sqlalchemy.orm import Session -from app.modules.email_integration.models import EmailLog, EmailSetting, EmailTemplate +from app.modules.email_integration.models import EmailLog, EmailSetting, EmailTemplate, PlatformEmailSetting from app.modules.email_integration.attachment_service import EmailAttachment from app.modules.core.iam.models import User from app.core.settings import get_settings @@ -232,6 +232,38 @@ def seed_default_email_templates(db: Session, tenant_id: int | None = None, bran existing_codes.add(code) + +def get_platform_email_setting(db: Session) -> PlatformEmailSetting | None: + return db.execute( + select(PlatformEmailSetting).order_by(PlatformEmailSetting.id.asc()) + ).scalars().first() + + +def get_or_create_platform_email_setting(db: Session, actor_user_id: int | None = None) -> PlatformEmailSetting: + row = get_platform_email_setting(db) + if row: + return row + + settings = get_settings() + row = PlatformEmailSetting( + smtp_host=getattr(settings, "SMTP_HOST", None) or "mail.vavalam.com", + smtp_port=int(getattr(settings, "SMTP_PORT", 465) or 465), + smtp_security="SSL", + smtp_username=getattr(settings, "SMTP_USERNAME", None) or "no-reply@vavalam.com", + smtp_password=getattr(settings, "SMTP_PASSWORD", None), + from_email=getattr(settings, "SMTP_FROM_EMAIL", None) or "no-reply@vavalam.com", + from_name=getattr(settings, "SMTP_FROM_NAME", None) or "ERP Platform", + reply_to_email=getattr(settings, "SMTP_FROM_EMAIL", None) or "no-reply@vavalam.com", + send_auth_emails=True, + is_active=True, + created_by_user_id=actor_user_id, + updated_by_user_id=actor_user_id, + ) + db.add(row) + db.flush() + return row + + def get_email_setting(db: Session, tenant_id: int | None, branch_id: int | None) -> EmailSetting | None: if tenant_id is None: return None @@ -585,6 +617,123 @@ def process_pending_email_queue( db.flush() return result + +def send_platform_email_log_now(db: Session, log: EmailLog) -> EmailLog: + """Attempt immediate send through System Admin platform SMTP.""" + setting = get_platform_email_setting(db) + now = datetime.now(timezone.utc) + log.processing_started_at = now + log.last_attempt_at = now + log.attempt_count = int(getattr(log, "attempt_count", 0) or 0) + 1 + + if not setting or not setting.is_active: + log.status = "SKIPPED" + log.error_message = "Platform SMTP settings not configured or inactive." + log.is_retryable = False + log.processing_started_at = None + db.flush() + return log + + try: + _send_smtp(setting, log.recipient_email, log.subject, log.body or "", is_html=False) + log.status = "SENT" + log.sent_at = datetime.now(timezone.utc) + log.error_message = None + log.next_retry_at = None + log.processing_started_at = None + log.is_retryable = False + except Exception as exc: + log.status = "FAILED" + log.error_message = str(exc) + log.processing_started_at = None + log.is_retryable = False + log.next_retry_at = None + db.flush() + return log + + +def send_platform_template_email( + db: Session, + *, + recipient_email: str, + template_code: str, + context: dict[str, Any] | None = None, + related_module: str | None = None, + related_id: int | None = None, + send_immediately: bool = True, + queue_priority: int = 10, +) -> EmailLog: + """Queue/send a platform email without tenant SMTP dependency.""" + context = dict(context or {}) + context.setdefault("firm_name", "ERP Platform") + context.setdefault("support_email", "") + + template = _get_template(db, None, None, template_code) + if not template: + subject = template_code + body = "" + else: + subject = render_template_text(template.subject_template, context) + body = render_template_text(template.body_template, context) + + log = EmailLog( + tenant_id=None, + branch_id=None, + recipient_email=recipient_email, + subject=subject[:500], + body=body, + status="PENDING", + related_module=related_module, + related_id=related_id, + template_code=template_code, + queued_at=datetime.now(timezone.utc), + max_attempts=1, + queue_priority=int(queue_priority or 10), + is_retryable=False, + ) + db.add(log) + db.flush() + + setting = get_platform_email_setting(db) + if not setting or not setting.is_active: + log.status = "SKIPPED" + log.error_message = "Platform SMTP settings not configured or inactive." + db.flush() + return log + + if template_code.upper().startswith("AUTH_") and not getattr(setting, "send_auth_emails", True): + log.status = "SKIPPED" + log.error_message = "Platform authentication/invite emails are disabled." + db.flush() + return log + + if send_immediately: + send_platform_email_log_now(db, log) + return log + + +def send_platform_user_invite_email(db: Session, *, user: User, invite_token: str, firm_name: str | None = None) -> EmailLog | None: + if not getattr(user, "email", None): + return None + invite_link = f"{_public_base_url()}/invite/accept?token={invite_token}" + return send_platform_template_email( + db, + recipient_email=str(user.email), + template_code="AUTH_USER_INVITE", + context={ + "firm_name": firm_name or "ERP Platform", + "user_name": _user_display_name(user), + "user_email": str(user.email), + "invite_link": invite_link, + "expiry_hours": str(get_settings().INVITE_TOKEN_HOURS), + "support_email": "", + }, + related_module="wizard_firm_invite", + related_id=int(user.id), + queue_priority=5, + ) + + def send_template_email( db: Session, *, diff --git a/app/modules/email_integration/templates/email_integration/platform_smtp.html b/app/modules/email_integration/templates/email_integration/platform_smtp.html new file mode 100644 index 0000000..15cbf4b --- /dev/null +++ b/app/modules/email_integration/templates/email_integration/platform_smtp.html @@ -0,0 +1,78 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+
+

Platform SMTP Settings

+

System Admin SMTP used for firm creation invites, primary Firm Admin onboarding, password/reset emails and platform notices before a firm configures its own SMTP.

+
+ +
+ {% if flash %} +
{{ flash }}
+ {% endif %} +
+ +
+ +
+

Outgoing SMTP

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

Platform Email Controls

+
+ + +
+
+
+
+ +
+

Send Test Email

+
+ + + +
+
+ +
+

Recent Platform Email Attempts

+
+ + + + {% for log in recent_logs %} + + {% else %} + + {% endfor %} + +
TimeRecipientSubjectStatusError
{{ log.created_at_utc }}{{ log.recipient_email }}{{ log.subject }}{{ log.status }}{{ log.error_message or '' }}
No platform email attempts yet.
+
+
+
+{% endblock %} diff --git a/app/modules/email_integration/ui.py b/app/modules/email_integration/ui.py index 7f0f7c2..b75d15c 100644 --- a/app/modules/email_integration/ui.py +++ b/app/modules/email_integration/ui.py @@ -12,7 +12,7 @@ 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.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailLog, EmailSetting, EmailTemplate +from app.modules.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailLog, EmailSetting, EmailTemplate, PlatformEmailSetting from app.modules.email_integration.imap_service import fetch_incoming_emails from app.modules.email_integration.mapping_service import apply_email_mapping, auto_map_unmapped_emails from app.modules.billing.models import BillingInvoice @@ -23,6 +23,8 @@ from app.modules.email_integration.services import ( seed_default_email_templates, send_template_email, process_pending_email_queue, + get_or_create_platform_email_setting, + send_platform_template_email, ) router = APIRouter(prefix="/email", tags=["email-integration-ui"]) @@ -32,6 +34,11 @@ def _redirect_login(): return RedirectResponse(url="/login", status_code=303) + +def _user_is_system_admin(db, user) -> bool: + return "System Admin" in set(get_user_roles(db, int(user.id))) + + def _user_can_manage_email(db, user) -> bool: roles = set(get_user_roles(db, int(user.id))) perms = set(get_user_permissions(db, int(user.id))) @@ -58,6 +65,108 @@ def _ctx(request: Request, db, user, **extra): return ctx +@router.get("/platform-smtp") +def platform_smtp_page(request: Request, flash: str | None = None): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_is_system_admin(db, user): + return forbidden_response(request, "Access denied: platform SMTP is restricted to System Admin") + setting = get_or_create_platform_email_setting(db, actor_user_id=int(user.id)) + db.commit() + recent_logs = db.execute( + select(EmailLog) + .where(EmailLog.tenant_id.is_(None), EmailLog.related_module.in_(["platform_smtp_test", "wizard_firm_invite"])) + .order_by(desc(EmailLog.created_at_utc)) + .limit(10) + ).scalars().all() + return templates.TemplateResponse( + "modules/email_integration/templates/email_integration/platform_smtp.html", + _ctx(request, db, user, title="Platform SMTP Settings", setting=setting, recent_logs=recent_logs, flash=flash), + ) + finally: + db.close() + + +@router.post("/platform-smtp") +def save_platform_smtp( + request: Request, + csrf_token: str = Form(...), + smtp_host: str = Form(""), + smtp_port: int = Form(465), + smtp_username: str = Form(""), + smtp_password: str = Form(""), + smtp_security: str = Form("SSL"), + smtp_timeout_seconds: int = Form(20), + from_email: str = Form(""), + from_name: str = Form(""), + reply_to_email: str = Form(""), + send_auth_emails: str | None = Form(None), + is_active: str | None = Form(None), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_is_system_admin(db, user): + return forbidden_response(request, "Access denied: platform SMTP is restricted to System Admin") + + setting = get_or_create_platform_email_setting(db, actor_user_id=int(user.id)) + setting.smtp_host = smtp_host.strip() or None + setting.smtp_port = int(smtp_port or 465) + setting.smtp_username = smtp_username.strip() or None + if smtp_password.strip(): + setting.smtp_password = smtp_password.strip() + setting.smtp_security = (smtp_security or "SSL").upper() + setting.smtp_timeout_seconds = max(5, min(int(smtp_timeout_seconds or 20), 120)) + setting.from_email = from_email.strip() or None + setting.from_name = from_name.strip() or None + setting.reply_to_email = reply_to_email.strip() or None + setting.send_auth_emails = bool(send_auth_emails) + setting.is_active = bool(is_active) + setting.updated_by_user_id = int(user.id) + db.commit() + return RedirectResponse(url="/email/platform-smtp?flash=Platform SMTP settings saved.", status_code=303) + finally: + db.close() + + +@router.post("/platform-smtp/test") +def test_platform_smtp(request: Request, csrf_token: str = Form(...), test_email: str = Form("")): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return _redirect_login() + if not _user_is_system_admin(db, user): + return forbidden_response(request, "Access denied: platform SMTP is restricted to System Admin") + + recipient = (test_email or getattr(user, "email", "")).strip() + send_platform_template_email( + db, + recipient_email=recipient, + template_code="AUTH_USER_INVITE", + context={ + "firm_name": "ERP Platform", + "user_name": getattr(user, "full_name", None) or recipient, + "invite_link": "This is a platform SMTP test email. No action is required.", + "expiry_hours": "0", + }, + related_module="platform_smtp_test", + related_id=int(user.id), + queue_priority=1, + ) + db.commit() + return RedirectResponse(url="/email/platform-smtp?flash=Test email attempted. Check recent logs below.", status_code=303) + finally: + db.close() + + @router.get("/audit") diff --git a/app/modules/system_settings/ui.py b/app/modules/system_settings/ui.py index edb4398..200cb89 100644 --- a/app/modules/system_settings/ui.py +++ b/app/modules/system_settings/ui.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations from datetime import date, datetime, time, timezone from pathlib import Path @@ -1492,4 +1492,3 @@ def switch_active_branch(request: Request, branch_id: int): return _redirect_back(request) finally: db.close() - diff --git a/app/modules/wizards/service.py b/app/modules/wizards/service.py index c705d59..5689c71 100644 --- a/app/modules/wizards/service.py +++ b/app/modules/wizards/service.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations from dataclasses import dataclass from datetime import date, datetime, timedelta @@ -19,6 +19,7 @@ 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 from app.modules.employees.models import Employee +from app.modules.email_integration.services import send_platform_user_invite_email class FirmWizardError(ValueError): @@ -33,6 +34,8 @@ class FirmWizardResult: financial_year: FinancialYear | None invite_url: str invite_token: str + invite_email_status: str | None = None + invite_email_error: str | None = None def normalize_code(value: str, *, upper: bool = True) -> str: @@ -202,6 +205,19 @@ def validate_firm_wizard_payload(db: Session, payload: dict[str, Any]) -> list[s return errors + +def safe_employee_code_for_firm_admin(db: Session, tenant_id: int, tenant_code: str, user_id: int) -> str: + base = normalize_code(f"{tenant_code}_ADMIN_{user_id}")[:50] + employee_code = base + suffix = 1 + while db.execute( + select(Employee).where(Employee.tenant_id == tenant_id, Employee.employee_code == employee_code) + ).scalar_one_or_none(): + suffix += 1 + employee_code = f"{base[:44]}_{suffix}"[:50] + return employee_code + + def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() @@ -295,14 +311,11 @@ def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardR db.flush() db.add(UserRole(user_id=firm_admin.id, role_id=role.id)) - # Auto-create and link Employee Master for the primary Firm Admin. - # Employee Portal requires employees.user_id to match the logged-in user. - employee_code = normalize_code(f"{payload['tenant_code']}_ADMIN")[:50] firm_admin_employee = Employee( tenant_id=tenant.id, branch_id=branch.id, user_id=firm_admin.id, - employee_code=employee_code, + employee_code=safe_employee_code_for_firm_admin(db, tenant.id, tenant.code, firm_admin.id), full_name=payload["admin_full_name"], email=payload["admin_email"], mobile=payload["admin_mobile"] or None, @@ -338,6 +351,23 @@ def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardR invite_token = create_invite_token_without_commit(db, firm_admin) invite_url = public_invite_url(invite_token) + invite_email_status = None + invite_email_error = None + try: + invite_email_log = send_platform_user_invite_email( + db, + user=firm_admin, + invite_token=invite_token, + firm_name=tenant.display_name or tenant.name, + ) + if invite_email_log: + invite_email_status = invite_email_log.status + invite_email_error = invite_email_log.error_message + except Exception as exc: + # Firm creation must not fail only because SMTP is unavailable. + invite_email_status = "FAILED" + invite_email_error = str(exc) + return FirmWizardResult( tenant=tenant, branch=branch, @@ -345,5 +375,6 @@ def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardR financial_year=financial_year, invite_url=invite_url, invite_token=invite_token, + invite_email_status=invite_email_status, + invite_email_error=invite_email_error, ) - diff --git a/app/modules/wizards/service.py.bak_employee_link_20260702_150318 b/app/modules/wizards/service.py.bak_employee_link_20260702_150318 new file mode 100644 index 0000000..a28a99d --- /dev/null +++ b/app/modules/wizards/service.py.bak_employee_link_20260702_150318 @@ -0,0 +1,323 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, timedelta +import hashlib +import re +import secrets +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.security.jwt_tokens import utcnow +from app.core.security.passwords import hash_password +from app.core.settings import get_settings +from app.modules.core.iam.models import User +from app.modules.core.iam.password_flows_models import InviteToken +from app.modules.core.rbac.models import Role, UserRole +from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant +from app.modules.core.tenancy.settings_models import BranchSettings + + +class FirmWizardError(ValueError): + """Raised when the firm creation wizard receives invalid data.""" + + +@dataclass(slots=True) +class FirmWizardResult: + tenant: Tenant + branch: Branch + firm_admin: User + financial_year: FinancialYear | None + invite_url: str + invite_token: str + + +def normalize_code(value: str, *, upper: bool = True) -> str: + value = (value or "").strip() + value = re.sub(r"\s+", "_", value) + value = re.sub(r"[^A-Za-z0-9_\-]", "", value) + return value.upper() if upper else value + + +def clean_text(value: str | None) -> str: + return (value or "").strip() + + +def parse_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value or "").strip().lower() in {"1", "true", "yes", "on", "y"} + + +def parse_int(value: Any, default: int) -> int: + try: + return int(str(value).strip()) + except Exception: + return default + + +def parse_iso_date(value: str | None) -> date | None: + value = clean_text(value) + if not value: + return None + return date.fromisoformat(value) + + +def default_ay_from_fy(year_code: str) -> str: + year_code = clean_text(year_code) + try: + start_year = int(year_code.split("-", 1)[0]) + except Exception: + return "" + ay_start = start_year + 1 + return f"{ay_start}-{str(ay_start + 1)[-2:]}" + + +def default_dates_from_fy(year_code: str) -> tuple[date | None, date | None]: + year_code = clean_text(year_code) + try: + start_year = int(year_code.split("-", 1)[0]) + except Exception: + return None, None + return date(start_year, 4, 1), date(start_year + 1, 3, 31) + + +def public_invite_url(invite_token: str) -> str: + base = (get_settings().ERP_PUBLIC_BASE_URL or "").strip().rstrip("/") or "http://localhost:8000" + return f"{base}/invite/accept?token={invite_token}" + + +def build_firm_wizard_payload(form: dict[str, Any]) -> dict[str, Any]: + """Return a normalized payload used by preview and confirm pages.""" + tenant_code = normalize_code(str(form.get("tenant_code") or form.get("firm_code") or "")) + branch_code = normalize_code(str(form.get("branch_code") or "HO")) + admin_email = clean_text(str(form.get("admin_email") or "")).lower() + + fy_enabled = parse_bool(form.get("create_financial_year")) + fy_code = clean_text(str(form.get("fy_year_code") or "")) + ay_code = clean_text(str(form.get("fy_assessment_year") or "")) + fy_start_date = clean_text(str(form.get("fy_start_date") or "")) + fy_end_date = clean_text(str(form.get("fy_end_date") or "")) + + if fy_enabled and fy_code: + if not ay_code: + ay_code = default_ay_from_fy(fy_code) + if not fy_start_date or not fy_end_date: + start, end = default_dates_from_fy(fy_code) + fy_start_date = fy_start_date or (start.isoformat() if start else "") + fy_end_date = fy_end_date or (end.isoformat() if end else "") + + return { + "tenant_code": tenant_code, + "tenant_name": clean_text(str(form.get("tenant_name") or form.get("firm_name") or "")), + "firm_type": clean_text(str(form.get("firm_type") or "partnership")) or "partnership", + "default_timezone": clean_text(str(form.get("default_timezone") or "Asia/Kolkata")) or "Asia/Kolkata", + "default_session_duration_minutes": parse_int(form.get("default_session_duration_minutes"), 480), + "default_otp_required_roles_csv": clean_text(str(form.get("default_otp_required_roles_csv") or "Partner,System Admin")) or "Partner,System Admin", + "default_storage_mode": clean_text(str(form.get("default_storage_mode") or "local_only")) or "local_only", + "branch_code": branch_code, + "branch_name": clean_text(str(form.get("branch_name") or "Head Office")) or "Head Office", + "branch_timezone": clean_text(str(form.get("branch_timezone") or form.get("default_timezone") or "Asia/Kolkata")) or "Asia/Kolkata", + "branch_address_line1": clean_text(str(form.get("branch_address_line1") or "")), + "branch_address_line2": clean_text(str(form.get("branch_address_line2") or "")), + "branch_city": clean_text(str(form.get("branch_city") or "")), + "branch_state": clean_text(str(form.get("branch_state") or "")), + "branch_pin_code": clean_text(str(form.get("branch_pin_code") or "")), + "branch_gstin": clean_text(str(form.get("branch_gstin") or "")).upper(), + "branch_pan": clean_text(str(form.get("branch_pan") or "")).upper(), + "admin_full_name": clean_text(str(form.get("admin_full_name") or "")), + "admin_email": admin_email, + "admin_mobile": clean_text(str(form.get("admin_mobile") or "")), + "admin_designation": clean_text(str(form.get("admin_designation") or "Firm Admin")) or "Firm Admin", + "create_financial_year": fy_enabled, + "fy_year_code": fy_code, + "fy_assessment_year": ay_code, + "fy_start_date": fy_start_date, + "fy_end_date": fy_end_date, + "fy_is_current": parse_bool(form.get("fy_is_current")) if fy_enabled else False, + } + + +def validate_firm_wizard_payload(db: Session, payload: dict[str, Any]) -> list[str]: + errors: list[str] = [] + + if not payload["tenant_code"]: + errors.append("Firm code is required.") + if not payload["tenant_name"]: + errors.append("Firm name is required.") + if not payload["branch_code"]: + errors.append("Primary branch code is required.") + if not payload["branch_name"]: + errors.append("Primary branch name is required.") + if not payload["admin_full_name"]: + errors.append("Primary Firm Admin full name is required.") + if not payload["admin_email"]: + errors.append("Primary Firm Admin email is required.") + elif "@" not in payload["admin_email"]: + errors.append("Primary Firm Admin email is invalid.") + + if payload["default_session_duration_minutes"] < 15: + errors.append("Session duration must be at least 15 minutes.") + + allowed_firm_types = {"partnership", "proprietorship", "individual"} + if payload["firm_type"] not in allowed_firm_types: + errors.append("Invalid firm type.") + + allowed_storage_modes = {"local_only", "cloud_only", "hybrid"} + if payload["default_storage_mode"] not in allowed_storage_modes: + errors.append("Invalid default storage mode.") + + if payload["tenant_code"]: + existing_tenant = db.execute(select(Tenant).where(Tenant.code == payload["tenant_code"])).scalar_one_or_none() + if existing_tenant: + errors.append("Firm code already exists.") + + if payload["admin_email"]: + existing_user = db.execute(select(User).where(User.email == payload["admin_email"])).scalar_one_or_none() + if existing_user: + errors.append("Primary Firm Admin email already exists as a user.") + + role = db.execute(select(Role).where(Role.name == "Firm Admin", Role.is_active.is_(True))).scalar_one_or_none() + if not role: + errors.append("Firm Admin role is missing or inactive. Please seed roles before using this wizard.") + + if payload["create_financial_year"]: + if not payload["fy_year_code"]: + errors.append("Financial year code is required when default FY is enabled.") + if not payload["fy_assessment_year"]: + errors.append("Assessment year is required when default FY is enabled.") + try: + start = parse_iso_date(payload["fy_start_date"]) + end = parse_iso_date(payload["fy_end_date"]) + if not start or not end: + errors.append("Financial year start and end date are required.") + elif end <= start: + errors.append("Financial year end date must be after start date.") + except Exception: + errors.append("Financial year dates must be valid ISO dates, for example 2026-04-01.") + + return errors + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def create_invite_token_without_commit(db: Session, user: User) -> str: + plain = secrets.token_urlsafe(32) + now = utcnow() + db.add( + InviteToken( + user_id=user.id, + token_hash=_hash_token(plain), + created_at_utc=now.replace(tzinfo=None), + expires_at_utc=(now + timedelta(hours=get_settings().INVITE_TOKEN_HOURS)).replace(tzinfo=None), + used_at_utc=None, + ) + ) + user.must_change_password = True + return plain + + +def create_firm_from_wizard(db: Session, payload: dict[str, Any]) -> FirmWizardResult: + errors = validate_firm_wizard_payload(db, payload) + if errors: + raise FirmWizardError(" ".join(errors)) + + role = db.execute(select(Role).where(Role.name == "Firm Admin", Role.is_active.is_(True))).scalar_one() + temp_password = secrets.token_urlsafe(18) + + tenant = Tenant( + code=payload["tenant_code"], + name=payload["tenant_name"], + display_name=payload["tenant_name"], + is_active=True, + firm_type=payload["firm_type"], + default_timezone=payload["default_timezone"], + default_session_duration_minutes=payload["default_session_duration_minutes"], + default_otp_required_roles_csv=payload["default_otp_required_roles_csv"], + default_storage_mode=payload["default_storage_mode"], + contact_email=payload["admin_email"], + contact_mobile=payload["admin_mobile"] or None, + ) + db.add(tenant) + db.flush() + + branch = Branch( + tenant_id=tenant.id, + code=payload["branch_code"], + name=payload["branch_name"], + is_active=True, + timezone=payload["branch_timezone"], + allow_login=True, + allow_new_assignments=True, + is_head_office=True, + smtp_use_tls=True, + ) + db.add(branch) + db.flush() + + branch_settings = BranchSettings( + branch_id=branch.id, + address_line1=payload["branch_address_line1"] or None, + address_line2=payload["branch_address_line2"] or None, + city=payload["branch_city"] or None, + state=payload["branch_state"] or None, + pin_code=payload["branch_pin_code"] or None, + gstin=payload["branch_gstin"] or None, + pan=payload["branch_pan"] or None, + storage_mode=payload["default_storage_mode"], + otp_required_roles_csv=payload["default_otp_required_roles_csv"], + session_duration_minutes=payload["default_session_duration_minutes"], + ) + db.add(branch_settings) + db.flush() + + firm_admin = User( + email=payload["admin_email"], + full_name=payload["admin_full_name"], + password_hash=hash_password(temp_password), + tenant_id=tenant.id, + branch_id=branch.id, + is_active=True, + allow_login=True, + is_locked=False, + deleted_at=None, + must_change_password=True, + password_changed_at_utc=None, + mobile=payload["admin_mobile"] or None, + designation=payload["admin_designation"] or "Firm Admin", + ) + db.add(firm_admin) + db.flush() + db.add(UserRole(user_id=firm_admin.id, role_id=role.id)) + + financial_year = None + if payload["create_financial_year"]: + financial_year = FinancialYear( + tenant_id=tenant.id, + year_code=payload["fy_year_code"], + assessment_year=payload["fy_assessment_year"], + start_date=parse_iso_date(payload["fy_start_date"]), + end_date=parse_iso_date(payload["fy_end_date"]), + is_current=payload["fy_is_current"], + is_locked=False, + created_at_utc=datetime.utcnow(), + updated_at_utc=datetime.utcnow(), + ) + db.add(financial_year) + db.flush() + + invite_token = create_invite_token_without_commit(db, firm_admin) + invite_url = public_invite_url(invite_token) + + return FirmWizardResult( + tenant=tenant, + branch=branch, + firm_admin=firm_admin, + financial_year=financial_year, + invite_url=invite_url, + invite_token=invite_token, + ) diff --git a/app/modules/wizards/templates/wizards/system_firm_complete.html b/app/modules/wizards/templates/wizards/system_firm_complete.html index 3347acb..5e366ed 100644 --- a/app/modules/wizards/templates/wizards/system_firm_complete.html +++ b/app/modules/wizards/templates/wizards/system_firm_complete.html @@ -35,8 +35,14 @@
-
Firm Admin invite link
-

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

+
Firm Admin invite
+ {% if invite_email_status == 'SENT' %} +

Invite email was sent through Platform SMTP. The link is also shown below as backup.

+ {% elif invite_email_status in ['FAILED', 'SKIPPED'] %} +

Invite email status: {{ invite_email_status }}{% if invite_email_error %} - {{ invite_email_error }}{% endif %}. Copy and share this link manually, or configure Platform SMTP and resend invite from Users.

+ {% else %} +

Copy and share this link with the Firm Admin. Configure Platform SMTP to send this automatically for future firms.

+ {% endif %}
{{ invite_url }}
diff --git a/app/modules/wizards/ui.py b/app/modules/wizards/ui.py index 67d7d4c..2c1f149 100644 --- a/app/modules/wizards/ui.py +++ b/app/modules/wizards/ui.py @@ -231,6 +231,8 @@ def system_firm_confirm( branch_id = result.branch.id admin_id = result.firm_admin.id fy_id = result.financial_year.id if result.financial_year else None + invite_email_status = result.invite_email_status + invite_email_error = result.invite_email_error db.commit() except FirmWizardError as exc: db.rollback() @@ -282,6 +284,8 @@ def system_firm_confirm( firm_admin=firm_admin, financial_year=financial_year, invite_url=result.invite_url, + invite_email_status=invite_email_status, + invite_email_error=invite_email_error, ), ) finally: diff --git a/app/ui/app.py b/app/ui/app.py index 8a70815..aa1aa1c 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI +from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router @@ -56,4 +56,3 @@ def mount_ui(app: FastAPI) -> None: app.include_router(engagements_ui_router) app.include_router(client_portal_router) app.include_router(consultant_portal_router) - diff --git a/app/ui/templates/base/layout.html b/app/ui/templates/base/layout.html index 072cf26..0c73959 100644 --- a/app/ui/templates/base/layout.html +++ b/app/ui/templates/base/layout.html @@ -1,4 +1,4 @@ - + @@ -108,7 +108,7 @@ {% if firm_branding.logo_url %}{{ current_firm_name }} logo{% else %}
{{ (current_firm_name[:2] if current_firm_name else 'AF')|upper }}
{% endif %}
{{ current_firm_name }}
-
{% if full_auth %}{{ current_branch_name }}{% if current_branch_name and current_branch_name != "-" %} Branch{% endif %} | Workspace{% elif domain_context.is_resolved %}{{ current_branch_name or 'Domain Workspace' }}{% else %}Secure Practice Workspace{% endif %}
+
{% if full_auth %}{{ current_branch_name }}{% if current_branch_name and current_branch_name != "-" %} Branch{% endif %} • Workspace{% elif domain_context.is_resolved %}{{ current_branch_name or 'Domain Workspace' }}{% else %}Secure Practice Workspace{% endif %}
@@ -153,7 +153,7 @@
My Workspace - > +
{% if can_view_employee_portal(current_user, ui_perms, ui_roles) %} @@ -186,7 +186,7 @@
Partner Workspace - > +
Overview @@ -201,7 +201,7 @@
Team Workspace - > +
{% if can_manage_employee_work(current_user, ui_perms, ui_roles) %} @@ -226,7 +226,7 @@
Billing - > +
Invoices @@ -244,7 +244,7 @@
Team Administration - > +
{% if can_view_employee_dashboard(current_user, ui_perms, ui_roles) %}HR Dashboard{% endif %} @@ -282,7 +282,7 @@
Firm Administration - > +
{% if "Firm Admin" in ui_roles or is_system_admin_user %} @@ -338,7 +338,7 @@
Platform - > +
Domain Configuration
@@ -348,11 +348,11 @@ Consultant Domains DNS Verification SSL Status - {% if can_view_platform_billing(current_user, ui_perms, ui_roles) or can_view_marketplace_leads(current_user, ui_perms, ui_roles) %}
Platform Operations
+ Platform SMTP + Create Firm Wizard {% if can_view_platform_billing(current_user, ui_perms, ui_roles) %}Platform Billing{% endif %} {% if can_view_marketplace_leads(current_user, ui_perms, ui_roles) %}Marketplace{% endif %} - {% endif %}
{% endif %} @@ -360,7 +360,7 @@
Core Setup - > +
{% if can_view_settings(current_user, ui_perms, ui_roles) %} @@ -400,11 +400,11 @@
{{ current_user.full_name or current_user.email }}
{{ current_user.email }}
{% if current_user.qualification or current_user.designation %} -
{{ current_user.qualification or '' }}{% if current_user.qualification and current_user.designation %} | {% endif %}{{ current_user.designation or '' }}
+
{{ current_user.qualification or '' }}{% if current_user.qualification and current_user.designation %} • {% endif %}{{ current_user.designation or '' }}
{% endif %}
Your Firm: {{ current_firm_name }} - > Branch: {{ current_branch_name }}{% if active_financial_year %} > FY: {{ active_financial_year }}{% endif %} + • Branch: {{ current_branch_name }}{% if active_financial_year %} • FY: {{ active_financial_year }}{% endif %}
{% if "Consultant" in ui_roles %} @@ -426,7 +426,7 @@
{% else %} {% if domain_context.is_resolved %} -
{{ current_firm_name }}{% if current_branch_name %} | {{ current_branch_name }}{% endif %}
+
{{ current_firm_name }}{% if current_branch_name %} • {{ current_branch_name }}{% endif %}
{% endif %} Login {% endif %} @@ -472,20 +472,10 @@
{% endif %} - - {% if is_system_admin_user %} -
- - - + Create Firm - -
- {% endif %}
Active Scope: {{ current_firm_name }} - | {{ current_branch_name if active_branch_id else "All Branches" }}{% if active_financial_year %} | FY {{ active_financial_year }}{% endif %} + • {{ current_branch_name if active_branch_id else "All Branches" }}{% if active_financial_year %} • FY {{ active_financial_year }}{% endif %}
{% endif %} @@ -557,7 +547,7 @@ wrapper.className = "overflow-hidden rounded-2xl border shadow-soft " + priorityClasses(alert.priority); wrapper.innerHTML = `
-
🔔
+
🔔
New Alert
@@ -625,7 +615,3 @@ - - - -