diff --git a/alembic/versions/20260621_phase_1_udin_final_documents.py b/alembic/versions/20260621_phase_1_udin_final_documents.py new file mode 100644 index 0000000..58cf1fd --- /dev/null +++ b/alembic/versions/20260621_phase_1_udin_final_documents.py @@ -0,0 +1,103 @@ +"""Phase 1 - UDIN on final documents + +Revision ID: 20260621_phase_1_udin_final_documents +Revises: 20260620_phase_204f_platform_smtp +Create Date: 2026-07-06 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +revision = "20260621_phase_1_udin_final_documents" +down_revision = "20260620_phase_204f_platform_smtp" +branch_labels = None +depends_on = None + + +def _has_column(bind, table_name: str, column_name: str) -> bool: + try: + return column_name in {col["name"] for col in inspect(bind).get_columns(table_name)} + except Exception: + return False + + +def _has_index(bind, table_name: str, index_name: str) -> bool: + try: + return index_name in {idx["name"] for idx in inspect(bind).get_indexes(table_name)} + except Exception: + return False + + +def _add_column_if_missing(bind, table_name: str, column: sa.Column) -> None: + if not _has_column(bind, table_name, column.name): + with op.batch_alter_table(table_name) as batch: + batch.add_column(column) + + +def _create_index_if_missing(bind, index_name: str, table_name: str, columns: list[str]) -> None: + if not _has_index(bind, table_name, index_name): + op.create_index(index_name, table_name, columns) + + +def upgrade() -> None: + bind = op.get_bind() + if "engagement_documents" not in inspect(bind).get_table_names(): + return + + _add_column_if_missing(bind, "engagement_documents", sa.Column("udin_required", sa.Boolean(), nullable=False, server_default=sa.false())) + _add_column_if_missing(bind, "engagement_documents", sa.Column("udin_status", sa.String(length=30), nullable=False, server_default="not_required")) + _add_column_if_missing(bind, "engagement_documents", sa.Column("udin_number", sa.String(length=30), nullable=True)) + _add_column_if_missing(bind, "engagement_documents", sa.Column("udin_date", sa.Date(), nullable=True)) + _add_column_if_missing(bind, "engagement_documents", sa.Column("udin_document_date", sa.Date(), nullable=True)) + _add_column_if_missing(bind, "engagement_documents", sa.Column("udin_document_type", sa.String(length=120), nullable=True)) + _add_column_if_missing(bind, "engagement_documents", sa.Column("udin_financial_year", sa.String(length=9), nullable=True)) + _add_column_if_missing(bind, "engagement_documents", sa.Column("udin_amount", sa.Numeric(14, 2), nullable=True)) + _add_column_if_missing(bind, "engagement_documents", sa.Column("udin_generated_by_user_id", sa.Integer(), nullable=True)) + _add_column_if_missing(bind, "engagement_documents", sa.Column("udin_verified_at_utc", sa.DateTime(timezone=True), nullable=True)) + _add_column_if_missing(bind, "engagement_documents", sa.Column("udin_notes", sa.Text(), nullable=True)) + _add_column_if_missing(bind, "engagement_documents", sa.Column("final_release_status", sa.String(length=30), nullable=False, server_default="draft")) + _add_column_if_missing(bind, "engagement_documents", sa.Column("released_by_user_id", sa.Integer(), nullable=True)) + _add_column_if_missing(bind, "engagement_documents", sa.Column("released_at_utc", sa.DateTime(timezone=True), nullable=True)) + _add_column_if_missing(bind, "engagement_documents", sa.Column("release_notes", sa.Text(), nullable=True)) + + _create_index_if_missing(bind, "ix_engagement_documents_udin_required", "engagement_documents", ["udin_required"]) + _create_index_if_missing(bind, "ix_engagement_documents_udin_status", "engagement_documents", ["udin_status"]) + _create_index_if_missing(bind, "ix_engagement_documents_udin_number", "engagement_documents", ["udin_number"]) + _create_index_if_missing(bind, "ix_engagement_documents_udin_date", "engagement_documents", ["udin_date"]) + _create_index_if_missing(bind, "ix_engagement_documents_udin_financial_year", "engagement_documents", ["udin_financial_year"]) + _create_index_if_missing(bind, "ix_engagement_documents_udin_generated_by_user_id", "engagement_documents", ["udin_generated_by_user_id"]) + _create_index_if_missing(bind, "ix_engagement_documents_final_release_status", "engagement_documents", ["final_release_status"]) + _create_index_if_missing(bind, "ix_engagement_documents_released_by_user_id", "engagement_documents", ["released_by_user_id"]) + + # Lightweight FK creation is skipped intentionally in idempotent migration mode + # because SQLite batch mode and existing production databases may differ. The + # SQLAlchemy model still defines FK intent, and user IDs are set by application logic. + + +def downgrade() -> None: + bind = op.get_bind() + if "engagement_documents" not in inspect(bind).get_table_names(): + return + for idx in [ + "ix_engagement_documents_released_by_user_id", + "ix_engagement_documents_final_release_status", + "ix_engagement_documents_udin_generated_by_user_id", + "ix_engagement_documents_udin_financial_year", + "ix_engagement_documents_udin_date", + "ix_engagement_documents_udin_number", + "ix_engagement_documents_udin_status", + "ix_engagement_documents_udin_required", + ]: + if _has_index(bind, "engagement_documents", idx): + op.drop_index(idx, table_name="engagement_documents") + with op.batch_alter_table("engagement_documents") as batch: + for col in [ + "release_notes", "released_at_utc", "released_by_user_id", "final_release_status", + "udin_notes", "udin_verified_at_utc", "udin_generated_by_user_id", "udin_amount", + "udin_financial_year", "udin_document_type", "udin_document_date", "udin_date", + "udin_number", "udin_status", "udin_required", + ]: + if _has_column(bind, "engagement_documents", col): + batch.drop_column(col) diff --git a/app/core/startup.py b/app/core/startup.py index 7b2e5cc..7363c01 100644 --- a/app/core/startup.py +++ b/app/core/startup.py @@ -232,6 +232,9 @@ ROLE_PERMISSION_MAP = { "documents.download", "documents.delete", "documents.audit.view", + "udin.view", + "udin.manage", + "udin.export", "employees.dashboard.view", "employees.view", "employees.create", @@ -330,6 +333,9 @@ ROLE_PERMISSION_MAP = { "documents.upload", "documents.download", "documents.delete", + "udin.view", + "udin.manage", + "udin.export", "clients.view.own_only", "employees.dashboard.view", "employees.view", @@ -425,6 +431,7 @@ ROLE_PERMISSION_MAP = { "documents.view", "documents.upload", "documents.download", + "udin.view", "employees.dashboard.view", "employees.view", "employees.create", @@ -492,6 +499,7 @@ ROLE_PERMISSION_MAP = { "documents.view", "documents.upload", "documents.download", + "udin.view", ], "Client": [], "Consultant": [ diff --git a/app/modules/core/rbac/permissions_registry.py b/app/modules/core/rbac/permissions_registry.py index dec728a..27c7b8b 100644 --- a/app/modules/core/rbac/permissions_registry.py +++ b/app/modules/core/rbac/permissions_registry.py @@ -143,6 +143,9 @@ PERMISSIONS = { "documents.download": "Download Engagement Documents", "documents.delete": "Archive Engagement Documents", "documents.audit.view": "View Document Access Logs", + "udin.view": "View UDIN Register", + "udin.manage": "Manage UDIN and Final Document Release", + "udin.export": "Export UDIN Register", "notice_cases.view": "View Notice and Case Management", "notice_cases.create": "Create Notices and Cases", diff --git a/app/modules/documents/models.py b/app/modules/documents/models.py index a0c1687..3849511 100644 --- a/app/modules/documents/models.py +++ b/app/modules/documents/models.py @@ -1,8 +1,9 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import date, datetime, timezone +from decimal import Decimal -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.db.common import CommonBase @@ -38,6 +39,29 @@ class EngagementDocument(CommonBase): description: Mapped[str | None] = mapped_column(Text, nullable=True) current_version_no: Mapped[int] = mapped_column(Integer, nullable=False, default=0) status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True) + + # Phase 1 - UDIN and final document release controls. + # These fields convert the existing engagement document table into the UDIN + # source register. A separate manual UDIN register is intentionally avoided: + # partner-wise/client-wise/FY-wise reports are generated from these workflow + # fields. + udin_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + udin_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_required", index=True) + udin_number: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True) + udin_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + udin_document_date: Mapped[date | None] = mapped_column(Date, nullable=True) + udin_document_type: Mapped[str | None] = mapped_column(String(120), nullable=True) + udin_financial_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True) + udin_amount: Mapped[Decimal | None] = mapped_column(Numeric(14, 2), nullable=True) + udin_generated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + udin_verified_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + udin_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + final_release_status: Mapped[str] = mapped_column(String(30), nullable=False, default="draft", index=True) + released_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + released_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + release_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) deleted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) deleted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) @@ -244,6 +268,29 @@ class PermanentClientDocument(CommonBase): description: Mapped[str | None] = mapped_column(Text, nullable=True) current_version_no: Mapped[int] = mapped_column(Integer, nullable=False, default=0) status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True) + + # Phase 1 - UDIN and final document release controls. + # These fields convert the existing engagement document table into the UDIN + # source register. A separate manual UDIN register is intentionally avoided: + # partner-wise/client-wise/FY-wise reports are generated from these workflow + # fields. + udin_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + udin_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_required", index=True) + udin_number: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True) + udin_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + udin_document_date: Mapped[date | None] = mapped_column(Date, nullable=True) + udin_document_type: Mapped[str | None] = mapped_column(String(120), nullable=True) + udin_financial_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True) + udin_amount: Mapped[Decimal | None] = mapped_column(Numeric(14, 2), nullable=True) + udin_generated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + udin_verified_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + udin_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + final_release_status: Mapped[str] = mapped_column(String(30), nullable=False, default="draft", index=True) + released_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + released_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + release_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) deleted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) deleted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) diff --git a/app/modules/documents/services.py b/app/modules/documents/services.py index 66bb7d1..b13ea12 100644 --- a/app/modules/documents/services.py +++ b/app/modules/documents/services.py @@ -6,6 +6,8 @@ import os import re import secrets import shutil +from datetime import date, datetime, timezone +from decimal import Decimal, InvalidOperation from dataclasses import dataclass from pathlib import Path from typing import BinaryIO @@ -45,6 +47,10 @@ DOCUMENT_TYPES = [ "ACKNOWLEDGEMENT", ] +UDIN_STATUSES = ["not_required", "pending", "generated", "cancelled"] +FINAL_RELEASE_STATUSES = ["draft", "released"] +UDIN_REQUIRED_DOCUMENT_TYPES = {"AUDIT_REPORT", "SIGNED_OUTPUT", "ACKNOWLEDGEMENT"} + MAX_UPLOAD_BYTES = int(os.getenv("DOCUMENT_MAX_UPLOAD_MB", "50")) * 1024 * 1024 @@ -107,6 +113,47 @@ def _has_perm(db: Session, user, code: str) -> bool: return False +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _clean_udin_number(value: str | None) -> str | None: + text = (value or "").strip().upper().replace(" ", "") + return text or None + + +def _to_decimal(value: str | Decimal | int | float | None) -> Decimal | None: + if value in (None, ""): + return None + try: + return Decimal(str(value).replace(",", "")).quantize(Decimal("0.01")) + except (InvalidOperation, ValueError): + raise ValueError("Invalid UDIN amount.") + + +def _normalize_udin_required(document_type: str | None, explicit_value: bool | str | None = None) -> bool: + if isinstance(explicit_value, str): + lowered = explicit_value.strip().lower() + if lowered in {"1", "true", "yes", "on", "required"}: + return True + if lowered in {"0", "false", "no", "off", "not_required"}: + return False + if explicit_value is not None: + return bool(explicit_value) + return (document_type or "").strip().upper() in UDIN_REQUIRED_DOCUMENT_TYPES + + +def user_can_manage_udin(db: Session, user, document: EngagementDocument, scope: DocumentScope) -> bool: + if not _has_perm(db, user, "udin.manage"): + return False + engagement = getattr(document, "engagement", None) or db.get(ClientServiceSubscription, document.engagement_id) + return bool(engagement and user_can_view_engagement(db, user, engagement, scope)) + + +def user_can_view_udin_register(db: Session, user, scope: DocumentScope) -> bool: + return _has_perm(db, user, "udin.view") + + def build_document_scope(request, db: Session, user) -> DocumentScope: from app.modules.core.rbac.deps import get_user_roles @@ -421,6 +468,146 @@ def log_document_access(db: Session, *, action: str, result: str, user, request= +def update_document_udin( + db: Session, + *, + document: EngagementDocument, + udin_required: bool | str | None, + udin_number: str | None, + udin_date: date | None, + udin_document_date: date | None, + udin_document_type: str | None, + udin_financial_year: str | None, + udin_amount: str | Decimal | int | float | None, + udin_notes: str | None, + user, +) -> EngagementDocument: + required = _normalize_udin_required(document.document_type, udin_required) + number = _clean_udin_number(udin_number) + + document.udin_required = required + document.udin_number = number + document.udin_date = udin_date + document.udin_document_date = udin_document_date + document.udin_document_type = (udin_document_type or "").strip()[:120] or None + document.udin_financial_year = (udin_financial_year or document.financial_year or "").strip()[:9] or None + document.udin_amount = _to_decimal(udin_amount) + document.udin_notes = (udin_notes or "").strip() or None + document.updated_by_user_id = getattr(user, "id", None) + + if required: + if number: + document.udin_status = "generated" + document.udin_generated_by_user_id = getattr(user, "id", None) + document.udin_verified_at_utc = _now_utc() + else: + document.udin_status = "pending" + document.udin_generated_by_user_id = None + document.udin_verified_at_utc = None + else: + document.udin_status = "not_required" + if not number: + document.udin_generated_by_user_id = None + document.udin_verified_at_utc = None + + db.flush() + return document + + +def release_document_final( + db: Session, + *, + document: EngagementDocument, + release_notes: str | None, + user, +) -> EngagementDocument: + if not get_latest_version(document): + raise ValueError("Cannot release a document without an uploaded version.") + if document.udin_required and not _clean_udin_number(document.udin_number): + raise ValueError("UDIN is required before final release for this document.") + if document.udin_required and document.udin_status != "generated": + raise ValueError("UDIN status must be Generated before final release.") + document.final_release_status = "released" + document.status = "final" + document.released_by_user_id = getattr(user, "id", None) + document.released_at_utc = _now_utc() + document.release_notes = (release_notes or "").strip() or None + document.updated_by_user_id = getattr(user, "id", None) + db.flush() + return document + + +def reopen_final_document( + db: Session, + *, + document: EngagementDocument, + release_notes: str | None, + user, +) -> EngagementDocument: + document.final_release_status = "draft" + document.status = "active" + document.release_notes = (release_notes or "").strip() or document.release_notes + document.updated_by_user_id = getattr(user, "id", None) + db.flush() + return document + + +def list_udin_records( + db: Session, + user, + scope: DocumentScope, + *, + financial_year: str | None = None, + status: str | None = None, + partner_user_id: int | None = None, + q: str | None = None, + limit: int = 500, +) -> list[EngagementDocument]: + stmt = ( + select(EngagementDocument) + .options( + joinedload(EngagementDocument.client), + joinedload(EngagementDocument.engagement).joinedload(ClientServiceSubscription.assigned_partner), + joinedload(EngagementDocument.versions), + ) + .where(EngagementDocument.is_deleted.is_(False)) + .order_by(EngagementDocument.udin_date.desc().nullslast(), EngagementDocument.updated_at_utc.desc()) + ) + + if scope.is_firm_admin: + stmt = stmt.where(EngagementDocument.tenant_id == getattr(user, "tenant_id", None)) + elif scope.is_partner: + stmt = stmt.join(ClientServiceSubscription, ClientServiceSubscription.id == EngagementDocument.engagement_id).outerjoin(Client, Client.id == EngagementDocument.client_id).where( + EngagementDocument.tenant_id == getattr(user, "tenant_id", None), + or_(ClientServiceSubscription.assigned_partner_user_id == user.id, Client.partner_id == user.id, EngagementDocument.udin_generated_by_user_id == user.id), + ) + elif scope.is_branch_manager: + stmt = stmt.where( + EngagementDocument.tenant_id == getattr(user, "tenant_id", None), + or_(EngagementDocument.branch_id == getattr(user, "branch_id", None), EngagementDocument.branch_id.is_(None)), + ) + elif scope.is_staff: + stmt = stmt.join(ClientServiceSubscription, ClientServiceSubscription.id == EngagementDocument.engagement_id).where( + EngagementDocument.tenant_id == getattr(user, "tenant_id", None), + ClientServiceSubscription.assigned_staff_user_id == user.id, + ) + else: + return [] + + if financial_year: + stmt = stmt.where(EngagementDocument.financial_year == financial_year.strip()) + if status: + stmt = stmt.where(EngagementDocument.udin_status == status.strip()) + if partner_user_id: + stmt = stmt.join(ClientServiceSubscription, ClientServiceSubscription.id == EngagementDocument.engagement_id).where( + or_(ClientServiceSubscription.assigned_partner_user_id == int(partner_user_id), EngagementDocument.udin_generated_by_user_id == int(partner_user_id)) + ) + if q: + pattern = f"%{q.strip()}%" + stmt = stmt.where(or_(EngagementDocument.title.ilike(pattern), EngagementDocument.document_code.ilike(pattern), EngagementDocument.udin_number.ilike(pattern))) + return db.execute(stmt.limit(limit)).unique().scalars().all() + + def hash_storage_secret(secret: str) -> str: return hashlib.sha256((secret or "").encode("utf-8")).hexdigest() @@ -813,9 +1000,11 @@ def save_uploaded_revision( remarks: str | None, user, existing_document_id: int | None = None, + udin_required: bool | str | None = None, ) -> EngagementDocument: original_filename = Path(upload_file.filename or "document.bin").name document_type = document_type if document_type in DOCUMENT_TYPES else "GENERAL" + resolved_udin_required = _normalize_udin_required(document_type, udin_required) if existing_document_id: document = db.get(EngagementDocument, existing_document_id) @@ -826,6 +1015,11 @@ def save_uploaded_revision( if description is not None: document.description = description.strip() or None document.document_type = document_type + document.udin_required = resolved_udin_required + if resolved_udin_required and (document.udin_status or "not_required") == "not_required": + document.udin_status = "pending" + if not resolved_udin_required and not document.udin_number: + document.udin_status = "not_required" else: document = EngagementDocument( tenant_id=engagement.tenant_id, @@ -836,6 +1030,9 @@ def save_uploaded_revision( assessment_year=engagement.assessment_year, document_code="PENDING", document_type=document_type, + udin_required=resolved_udin_required, + udin_status="pending" if resolved_udin_required else "not_required", + final_release_status="draft", title=(title.strip()[:255] if title else original_filename[:255]), description=description.strip() if description else None, created_by_user_id=user.id, diff --git a/app/modules/documents/templates/documents/engagement_documents.html b/app/modules/documents/templates/documents/engagement_documents.html index 6a8b1b5..9b38d55 100644 --- a/app/modules/documents/templates/documents/engagement_documents.html +++ b/app/modules/documents/templates/documents/engagement_documents.html @@ -8,6 +8,7 @@
Engagement + UDIN Register Back
@@ -15,7 +16,10 @@ {% if request.query_params.get('uploaded') %}
Document uploaded successfully.
{% endif %} {% if request.query_params.get('deleted') %}
Document archived successfully.
{% endif %} {% if request.query_params.get('download_queued') %}
The file is stored in branch local storage. A secure retrieval request #{{ request.query_params.get('download_queued') }} has been queued. Please refresh after the local storage app fulfils it.
{% endif %} - {% if request.query_params.get('error') %}
Action failed. Please check file size, permission and storage path.
{% endif %} + {% if request.query_params.get('udin_saved') %}
UDIN details saved successfully.
{% endif %} + {% if request.query_params.get('released') %}
Document released as final.
{% endif %} + {% if request.query_params.get('reopened') %}
Final release reopened to draft.
{% endif %} + {% if request.query_params.get('error') %}
Action failed. Please check file size, permission, UDIN, release status and storage path.
{% endif %} {% if can_upload %}
@@ -43,6 +47,13 @@ +
+ +

Use this for certificate, audit report, signed output or other deliverable where partner UDIN is applicable.

+
@@ -66,6 +77,7 @@ Type Latest Version Storage + UDIN / Release Revision History Action @@ -83,13 +95,58 @@
{{ latest.local_relative_path }}
{% else %}-{% endif %} + +
{{ (doc.final_release_status or 'draft').replace('_',' ').title() }}
+
UDIN: {{ (doc.udin_status or 'not_required').replace('_',' ').title() }}
+ {% if doc.udin_number %}
{{ doc.udin_number }}
{% endif %} + {% if doc.udin_date %}
Date: {{ doc.udin_date }}
{% endif %} + {% if doc.udin_required and not doc.udin_number %}
UDIN pending before final release.
{% endif %} + {% for v in doc.versions %}
v{{ v.version_no }} · {{ v.original_filename }}{% if v.remarks %} · {{ v.remarks }}{% endif %}
{% endfor %} - + {% if latest %}Download Latest{% endif %} + {% if 'udin.manage' in current_user_permissions %} +
+ UDIN / Release +
+
+ + + + +
+ + +
+ +
+ + +
+ + +
+ {% if doc.final_release_status != 'released' %} +
+ + + + +
+ {% else %} +
+ + + +
+ {% endif %} +
+
+ {% endif %} {% if can_upload %}
@@ -99,7 +156,7 @@ {% else %} - No documents uploaded for this engagement. + No documents uploaded for this engagement. {% endfor %} diff --git a/app/modules/documents/templates/documents/task_documents.html b/app/modules/documents/templates/documents/task_documents.html index 19dac0c..df22edb 100644 --- a/app/modules/documents/templates/documents/task_documents.html +++ b/app/modules/documents/templates/documents/task_documents.html @@ -17,7 +17,9 @@
{% if request.query_params.get('uploaded') %}
Task document uploaded successfully.
{% endif %} - {% if request.query_params.get('error') %}
Upload failed. Please verify file, requirement and permission.
{% endif %} + {% if request.query_params.get('udin_saved') %}
UDIN details saved successfully.
{% endif %} + {% if request.query_params.get('released') %}
Document released as final.
{% endif %} + {% if request.query_params.get('error') %}
Upload/action failed. Please verify file, requirement, UDIN and permission.
{% endif %}
Mandatory Pending
{{ requirement_status|selectattr('is_pending_mandatory')|list|length }}
@@ -61,6 +63,13 @@ +
+ +

Use this for final certificates, reports or signed outputs prepared from this task.

+
@@ -95,12 +104,52 @@

All Task Documents

- + {% for doc in documents %} {% set latest = doc.versions[0] if doc.versions else None %} - - {% else %}{% endfor %} + + + + + + + {% else %}{% endfor %}
DocumentLatest VersionAction
DocumentLatest VersionUDIN / ReleaseAction
{{ doc.title }}
{{ doc.document_type.replace('_',' ').title() }}{% if doc.document_requirement %} · {{ doc.document_requirement.document_name }}{% endif %}
{% if latest %}v{{ latest.version_no }} · {{ latest.original_filename }}{% else %}-{% endif %}{% if latest %}Download{% endif %}
No task documents uploaded yet.
{{ doc.title }}
{{ doc.document_type.replace('_',' ').title() }}{% if doc.document_requirement %} · {{ doc.document_requirement.document_name }}{% endif %}
{% if latest %}v{{ latest.version_no }} · {{ latest.original_filename }}{% else %}-{% endif %} +
{{ (doc.final_release_status or 'draft').replace('_',' ').title() }}
+
UDIN: {{ (doc.udin_status or 'not_required').replace('_',' ').title() }}
+ {% if doc.udin_number %}
{{ doc.udin_number }}
{% endif %} + {% if doc.udin_required and not doc.udin_number %}
UDIN pending before final release.
{% endif %} +
+ {% if latest %}Download{% endif %} + {% if 'udin.manage' in current_user_permissions %} +
+ UDIN / Release +
+ + + + + +
+ + +
+ +
+ + + + {% if doc.final_release_status != 'released' %} +
+ + + +
+ {% endif %} +
+
+ {% endif %} +
No task documents uploaded yet.
diff --git a/app/modules/documents/templates/documents/udin_register.html b/app/modules/documents/templates/documents/udin_register.html new file mode 100644 index 0000000..c57623a --- /dev/null +++ b/app/modules/documents/templates/documents/udin_register.html @@ -0,0 +1,74 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

UDIN Register

+

Auto-generated from final document workflow. No separate manual register is required.

+
+ Engagement Documents +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ {% set pending_count = rows|selectattr('udin_status','equalto','pending')|list|length %} + {% set generated_count = rows|selectattr('udin_status','equalto','generated')|list|length %} + {% set released_count = rows|selectattr('final_release_status','equalto','released')|list|length %} +
Total Documents
{{ rows|length }}
+
UDIN Pending
{{ pending_count }}
+
UDIN Generated
{{ generated_count }}
+
Final Released
{{ released_count }}
+
+ +
+ + + + + + + + + + + + + {% for doc in rows %} + + + + + + + + + {% else %} + + {% endfor %} + +
Date / FYClient / EngagementDocumentUDINReleaseAction
{{ doc.udin_date or '-' }}
FY {{ doc.udin_financial_year or doc.financial_year }}
{{ doc.client.client_name if doc.client else '-' }}
{{ doc.engagement.catalogue.service_name if doc.engagement and doc.engagement.catalogue else 'Engagement #' ~ doc.engagement_id }}
{{ doc.title }}
{{ doc.document_code }} · {{ doc.document_type.replace('_',' ').title() }}
{{ (doc.udin_status or 'not_required').replace('_',' ').title() }}
{% if doc.udin_number %}
{{ doc.udin_number }}
{% endif %}
{{ (doc.final_release_status or 'draft').replace('_',' ').title() }}{% if doc.released_at_utc %}
{{ doc.released_at_utc }}
{% endif %}
Open{% set latest = doc.versions[0] if doc.versions else None %}{% if latest %}Download{% endif %}
No UDIN records found.
+
+
+{% endblock %} diff --git a/app/modules/documents/ui.py b/app/modules/documents/ui.py index 68123ee..ce767d0 100644 --- a/app/modules/documents/ui.py +++ b/app/modules/documents/ui.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import date, datetime, timezone import asyncio import logging from pathlib import Path @@ -34,6 +34,7 @@ from app.modules.documents.services import ( get_storage_job_for_node, list_documents_for_engagement, list_download_requests, + list_udin_records, list_recent_download_requests_for_engagement, list_visible_engagements, list_pending_download_requests, @@ -41,6 +42,11 @@ from app.modules.documents.services import ( list_storage_jobs, list_storage_nodes, log_document_access, + release_document_final, + reopen_final_document, + update_document_udin, + user_can_manage_udin, + user_can_view_udin_register, create_download_request_for_version, download_request_cache_path, fulfill_download_request_from_upload, @@ -128,6 +134,18 @@ def _active_financial_year(request: Request) -> str | None: value = (value or "").strip() return value or None + +def _parse_optional_date(value: str | None) -> date | None: + value = (value or "").strip() + if not value: + return None + return date.fromisoformat(value) + + +def _bool_from_form(value: str | None) -> bool: + return (value or "").strip().lower() in {"1", "true", "yes", "on", "required"} + + def _load_engagement(db, engagement_id: int): return db.execute( select(ClientServiceSubscription) @@ -186,6 +204,7 @@ def upload_engagement_document( description: str | None = Form(None), remarks: str | None = Form(None), existing_document_id: str | None = Form(None), + udin_required: str | None = Form(None), file: UploadFile = File(...), csrf_token: str = Form(...), ): @@ -217,6 +236,7 @@ def upload_engagement_document( remarks=remarks, user=user, existing_document_id=int(existing_document_id) if existing_document_id else None, + udin_required=_bool_from_form(udin_required), ) log_document_access(db, action="upload", result="success", user=user, request=request, document=doc) db.commit() @@ -287,6 +307,7 @@ def upload_task_document( description: str | None = Form(None), remarks: str | None = Form(None), existing_document_id: str | None = Form(None), + udin_required: str | None = Form(None), file: UploadFile = File(...), csrf_token: str = Form(...), ): @@ -325,6 +346,7 @@ def upload_task_document( remarks=remarks, user=user, existing_document_id=int(existing_document_id) if existing_document_id else None, + udin_required=_bool_from_form(udin_required), ) log_document_access(db, action="task_upload", result="success", user=user, request=request, document=doc) db.commit() @@ -341,6 +363,157 @@ def upload_task_document( finally: db.close() +@router.get("/udin-register") +def udin_register(request: Request, financial_year: str = "", status: str = "", q: str = ""): + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "udin.view") + if response: + return response + scope = build_document_scope(request, db, user) + if not user_can_view_udin_register(db, user, scope): + return _redirect_denied() + selected_financial_year = (financial_year or _active_financial_year(request) or "").strip() + rows = list_udin_records( + db, + user, + scope, + financial_year=selected_financial_year or None, + status=status or None, + q=q or None, + ) + return _render( + request, + "modules/documents/templates/documents/udin_register.html", + db, + user, + title="UDIN Register", + rows=rows, + financial_year=selected_financial_year, + selected_status=status, + q=q, + ) + finally: + db.close() + + +@router.post("/{document_id}/udin") +def update_document_udin_submit( + request: Request, + document_id: int, + udin_required: str | None = Form(None), + udin_number: str | None = Form(None), + udin_date: str | None = Form(None), + udin_document_date: str | None = Form(None), + udin_document_type: str | None = Form(None), + udin_financial_year: str | None = Form(None), + udin_amount: str | None = Form(None), + udin_notes: str | None = Form(None), + redirect_to: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "udin.manage") + if response: + return response + scope = build_document_scope(request, db, user) + document = get_document(db, document_id) + if not document or not user_can_manage_udin(db, user, document, scope): + return _redirect_denied() + if is_row_financial_year_locked(db, document.engagement): + return RedirectResponse(url=f"/documents/engagements/{document.engagement_id}?year_locked=1", status_code=303) + try: + update_document_udin( + db, + document=document, + udin_required=_bool_from_form(udin_required), + udin_number=udin_number, + udin_date=_parse_optional_date(udin_date), + udin_document_date=_parse_optional_date(udin_document_date), + udin_document_type=udin_document_type, + udin_financial_year=udin_financial_year or document.financial_year, + udin_amount=udin_amount, + udin_notes=udin_notes, + user=user, + ) + log_document_access(db, action="udin_update", result="success", user=user, request=request, document=document) + db.commit() + target = (redirect_to or f"/documents/engagements/{document.engagement_id}").strip() + return RedirectResponse(url=f"{target}{'&' if '?' in target else '?'}udin_saved=1", status_code=303) + except Exception as exc: + db.rollback() + log_document_access(db, action="udin_update", result="failed", user=user, request=request, document=document, message=str(exc)[:1000]) + db.commit() + return RedirectResponse(url=f"/documents/engagements/{document.engagement_id}?error=udin_failed", status_code=303) + finally: + db.close() + + +@router.post("/{document_id}/final-release") +def release_document_final_submit( + request: Request, + document_id: int, + release_notes: str | None = Form(None), + redirect_to: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "udin.manage") + if response: + return response + scope = build_document_scope(request, db, user) + document = get_document(db, document_id) + if not document or not user_can_manage_udin(db, user, document, scope): + return _redirect_denied() + if is_row_financial_year_locked(db, document.engagement): + return RedirectResponse(url=f"/documents/engagements/{document.engagement_id}?year_locked=1", status_code=303) + try: + release_document_final(db, document=document, release_notes=release_notes, user=user) + log_document_access(db, action="final_release", result="success", user=user, request=request, document=document) + db.commit() + target = (redirect_to or f"/documents/engagements/{document.engagement_id}").strip() + return RedirectResponse(url=f"{target}{'&' if '?' in target else '?'}released=1", status_code=303) + except Exception as exc: + db.rollback() + log_document_access(db, action="final_release", result="failed", user=user, request=request, document=document, message=str(exc)[:1000]) + db.commit() + target = (redirect_to or f"/documents/engagements/{document.engagement_id}").strip() + return RedirectResponse(url=f"{target}{'&' if '?' in target else '?'}error=release_failed", status_code=303) + finally: + db.close() + + +@router.post("/{document_id}/reopen-final") +def reopen_final_document_submit( + request: Request, + document_id: int, + release_notes: str | None = Form(None), + redirect_to: str | None = Form(None), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_user(request, db, "udin.manage") + if response: + return response + scope = build_document_scope(request, db, user) + document = get_document(db, document_id) + if not document or not user_can_manage_udin(db, user, document, scope): + return _redirect_denied() + reopen_final_document(db, document=document, release_notes=release_notes, user=user) + log_document_access(db, action="final_reopen", result="success", user=user, request=request, document=document) + db.commit() + target = (redirect_to or f"/documents/engagements/{document.engagement_id}").strip() + return RedirectResponse(url=f"{target}{'&' if '?' in target else '?'}reopened=1", status_code=303) + finally: + db.close() + + def _stream_file(path: Path, download_name: str, content_type: str | None = None): def file_iterator(): with path.open("rb") as fh: diff --git a/app/modules/services/task_documents.py b/app/modules/services/task_documents.py index 648d7d1..a2301d5 100644 --- a/app/modules/services/task_documents.py +++ b/app/modules/services/task_documents.py @@ -218,6 +218,7 @@ def save_uploaded_task_document( remarks: str | None, user, existing_document_id: int | None = None, + udin_required: bool | str | None = None, ) -> EngagementDocument: engagement = task.subscription or db.get(ClientServiceSubscription, task.subscription_id) if engagement is None: @@ -236,6 +237,7 @@ def save_uploaded_task_document( remarks=remarks, user=user, existing_document_id=existing_document_id, + udin_required=udin_required, ) doc.task_instance_id = task.id doc.document_requirement_id = requirement.id if requirement else None