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 @@
@@ -15,7 +16,10 @@ {% if request.query_params.get('uploaded') %}Use this for certificate, audit report, signed output or other deliverable where partner UDIN is applicable.
+Use this for final certificates, reports or signed outputs prepared from this task.
+| Document | Latest Version | Action | |
|---|---|---|---|
| Document | Latest Version | UDIN / Release | Action |
{{ 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 %}
+
+
+ {% endif %}
+ UDIN / Release+
+
+ {% if doc.final_release_status != 'released' %}
+
+ {% endif %}
+
+ |
+
| No task documents uploaded yet. | |||
Auto-generated from final document workflow. No separate manual register is required.
+| Date / FY | +Client / Engagement | +Document | +UDIN | +Release | +Action | +
|---|---|---|---|---|---|
{{ 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. | |||||