Integrate UDIN controls into final document workflow

This commit is contained in:
A R R R Associates
2026-07-06 18:05:00 +05:30
parent eb1cf6cbf2
commit 561de117e4
10 changed files with 723 additions and 10 deletions
@@ -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)
+8
View File
@@ -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": [
@@ -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",
+49 -2
View File
@@ -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)
+197
View File
@@ -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,
@@ -8,6 +8,7 @@
</div>
<div class="flex flex-wrap gap-2">
<a href="/services/engagements/{{ engagement.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Engagement</a>
<a href="/documents/udin-register?financial_year={{ engagement.financial_year }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">UDIN Register</a>
<a href="/documents" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Back</a>
</div>
</div>
@@ -15,7 +16,10 @@
{% if request.query_params.get('uploaded') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Document uploaded successfully.</div>{% endif %}
{% if request.query_params.get('deleted') %}<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">Document archived successfully.</div>{% endif %}
{% if request.query_params.get('download_queued') %}<div class="rounded-2xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-800">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.</div>{% endif %}
{% if request.query_params.get('error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Action failed. Please check file size, permission and storage path.</div>{% endif %}
{% if request.query_params.get('udin_saved') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">UDIN details saved successfully.</div>{% endif %}
{% if request.query_params.get('released') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Document released as final.</div>{% endif %}
{% if request.query_params.get('reopened') %}<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">Final release reopened to draft.</div>{% endif %}
{% if request.query_params.get('error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Action failed. Please check file size, permission, UDIN, release status and storage path.</div>{% endif %}
{% if can_upload %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
@@ -43,6 +47,13 @@
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">File</label>
<input type="file" name="file" required class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div class="md:col-span-2 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">
<label class="inline-flex items-center gap-2 font-semibold">
<input type="checkbox" name="udin_required" value="1" class="rounded border-amber-300">
UDIN required before final release
</label>
<p class="mt-1 text-xs text-amber-800">Use this for certificate, audit report, signed output or other deliverable where partner UDIN is applicable.</p>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Description</label>
<textarea name="description" rows="2" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea>
@@ -66,6 +77,7 @@
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Type</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Latest Version</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Storage</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">UDIN / Release</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Revision History</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th>
</tr>
@@ -83,13 +95,58 @@
<div class="mt-1 font-mono text-[11px] break-all">{{ latest.local_relative_path }}</div>
{% else %}-{% endif %}
</td>
<td class="px-4 py-3 text-xs text-slate-600">
<div><span class="rounded-full px-2 py-1 {{ 'bg-emerald-100 text-emerald-800' if doc.final_release_status == 'released' else 'bg-slate-100 text-slate-700' }}">{{ (doc.final_release_status or 'draft').replace('_',' ').title() }}</span></div>
<div class="mt-2">UDIN: <span class="font-semibold">{{ (doc.udin_status or 'not_required').replace('_',' ').title() }}</span></div>
{% if doc.udin_number %}<div class="font-mono text-[11px]">{{ doc.udin_number }}</div>{% endif %}
{% if doc.udin_date %}<div>Date: {{ doc.udin_date }}</div>{% endif %}
{% if doc.udin_required and not doc.udin_number %}<div class="mt-1 text-red-600">UDIN pending before final release.</div>{% endif %}
</td>
<td class="px-4 py-3 text-xs text-slate-600">
{% for v in doc.versions %}
<div><a href="/documents/versions/{{ v.id }}/download" class="text-brand-700 hover:underline">v{{ v.version_no }}</a> · {{ v.original_filename }}{% if v.remarks %} · {{ v.remarks }}{% endif %}</div>
{% endfor %}
</td>
<td class="px-4 py-3 text-right text-sm">
<td class="px-4 py-3 text-right text-sm align-top">
{% if latest %}<a href="/documents/{{ doc.id }}/download" class="font-medium text-brand-700 hover:underline">Download Latest</a>{% endif %}
{% if 'udin.manage' in current_user_permissions %}
<details class="mt-3 text-left">
<summary class="cursor-pointer text-sm font-semibold text-brand-700">UDIN / Release</summary>
<div class="mt-3 space-y-3 rounded-xl border border-slate-200 bg-slate-50 p-3">
<form method="post" action="/documents/{{ doc.id }}/udin" class="grid gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="redirect_to" value="/documents/engagements/{{ engagement.id }}">
<label class="inline-flex items-center gap-2 text-xs font-semibold text-slate-700"><input type="checkbox" name="udin_required" value="1" {% if doc.udin_required %}checked{% endif %}> UDIN required</label>
<input type="text" name="udin_number" value="{{ doc.udin_number or '' }}" placeholder="UDIN number" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
<div class="grid grid-cols-2 gap-2">
<input type="date" name="udin_date" value="{{ doc.udin_date or '' }}" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
<input type="date" name="udin_document_date" value="{{ doc.udin_document_date or '' }}" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
</div>
<input type="text" name="udin_document_type" value="{{ doc.udin_document_type or doc.document_type.replace('_',' ').title() }}" placeholder="Document type" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
<div class="grid grid-cols-2 gap-2">
<input type="text" name="udin_financial_year" value="{{ doc.udin_financial_year or doc.financial_year }}" placeholder="FY" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
<input type="text" name="udin_amount" value="{{ doc.udin_amount or '' }}" placeholder="Amount, if any" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
</div>
<input type="text" name="udin_notes" value="{{ doc.udin_notes or '' }}" placeholder="UDIN notes" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
<button class="rounded-lg bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white">Save UDIN</button>
</form>
{% if doc.final_release_status != 'released' %}
<form method="post" action="/documents/{{ doc.id }}/final-release" onsubmit="return confirm('Release this document as final?');" class="grid gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="redirect_to" value="/documents/engagements/{{ engagement.id }}">
<input type="text" name="release_notes" placeholder="Release notes" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
<button class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white">Release Final</button>
</form>
{% else %}
<form method="post" action="/documents/{{ doc.id }}/reopen-final" onsubmit="return confirm('Reopen this final document to draft?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="redirect_to" value="/documents/engagements/{{ engagement.id }}">
<button class="rounded-lg border border-amber-300 px-3 py-1.5 text-xs font-semibold text-amber-700">Reopen Draft</button>
</form>
{% endif %}
</div>
</details>
{% endif %}
{% if can_upload %}
<form method="post" action="/documents/{{ doc.id }}/delete" class="ml-3 inline" onsubmit="return confirm('Archive this document?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
@@ -99,7 +156,7 @@
</td>
</tr>
{% else %}
<tr><td colspan="6" class="px-4 py-8 text-center text-sm text-slate-500">No documents uploaded for this engagement.</td></tr>
<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-slate-500">No documents uploaded for this engagement.</td></tr>
{% endfor %}
</tbody>
</table>
@@ -17,7 +17,9 @@
</div>
{% if request.query_params.get('uploaded') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Task document uploaded successfully.</div>{% endif %}
{% if request.query_params.get('error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Upload failed. Please verify file, requirement and permission.</div>{% endif %}
{% if request.query_params.get('udin_saved') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">UDIN details saved successfully.</div>{% endif %}
{% if request.query_params.get('released') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Document released as final.</div>{% endif %}
{% if request.query_params.get('error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Upload/action failed. Please verify file, requirement, UDIN and permission.</div>{% endif %}
<section class="grid gap-4 md:grid-cols-3">
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">Mandatory Pending</div><div class="mt-2 text-2xl font-semibold text-slate-900">{{ requirement_status|selectattr('is_pending_mandatory')|list|length }}</div></div>
@@ -61,6 +63,13 @@
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">File</label>
<input type="file" name="file" required class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div class="md:col-span-2 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">
<label class="inline-flex items-center gap-2 font-semibold">
<input type="checkbox" name="udin_required" value="1" class="rounded border-amber-300">
UDIN required before final release
</label>
<p class="mt-1 text-xs text-amber-800">Use this for final certificates, reports or signed outputs prepared from this task.</p>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Revision Remarks</label>
<input type="text" name="remarks" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
@@ -95,12 +104,52 @@
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-100 px-5 py-4"><h3 class="text-sm font-semibold text-slate-900">All Task Documents</h3></div>
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Document</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Latest Version</th><th class="px-4 py-3 text-right text-xs font-semibold uppercase text-slate-500">Action</th></tr></thead>
<thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Document</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Latest Version</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">UDIN / Release</th><th class="px-4 py-3 text-right text-xs font-semibold uppercase text-slate-500">Action</th></tr></thead>
<tbody class="divide-y divide-slate-100">
{% for doc in documents %}
{% set latest = doc.versions[0] if doc.versions else None %}
<tr><td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ doc.title }}</div><div class="text-xs text-slate-500">{{ doc.document_type.replace('_',' ').title() }}{% if doc.document_requirement %} · {{ doc.document_requirement.document_name }}{% endif %}</div></td><td class="px-4 py-3 text-xs text-slate-600">{% if latest %}v{{ latest.version_no }} · {{ latest.original_filename }}{% else %}-{% endif %}</td><td class="px-4 py-3 text-right text-sm">{% if latest %}<a href="/documents/{{ doc.id }}/download" class="text-brand-700 hover:underline">Download</a>{% endif %}</td></tr>
{% else %}<tr><td colspan="3" class="px-4 py-8 text-center text-sm text-slate-500">No task documents uploaded yet.</td></tr>{% endfor %}
<tr>
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ doc.title }}</div><div class="text-xs text-slate-500">{{ doc.document_type.replace('_',' ').title() }}{% if doc.document_requirement %} · {{ doc.document_requirement.document_name }}{% endif %}</div></td>
<td class="px-4 py-3 text-xs text-slate-600">{% if latest %}v{{ latest.version_no }} · {{ latest.original_filename }}{% else %}-{% endif %}</td>
<td class="px-4 py-3 text-xs text-slate-600">
<div><span class="rounded-full px-2 py-1 {{ 'bg-emerald-100 text-emerald-800' if doc.final_release_status == 'released' else 'bg-slate-100 text-slate-700' }}">{{ (doc.final_release_status or 'draft').replace('_',' ').title() }}</span></div>
<div class="mt-2">UDIN: <span class="font-semibold">{{ (doc.udin_status or 'not_required').replace('_',' ').title() }}</span></div>
{% if doc.udin_number %}<div class="font-mono text-[11px]">{{ doc.udin_number }}</div>{% endif %}
{% if doc.udin_required and not doc.udin_number %}<div class="mt-1 text-red-600">UDIN pending before final release.</div>{% endif %}
</td>
<td class="px-4 py-3 text-right text-sm align-top">
{% if latest %}<a href="/documents/{{ doc.id }}/download" class="text-brand-700 hover:underline">Download</a>{% endif %}
{% if 'udin.manage' in current_user_permissions %}
<details class="mt-3 text-left">
<summary class="cursor-pointer text-sm font-semibold text-brand-700">UDIN / Release</summary>
<div class="mt-3 space-y-3 rounded-xl border border-slate-200 bg-slate-50 p-3">
<form method="post" action="/documents/{{ doc.id }}/udin" class="grid gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="redirect_to" value="/documents/tasks/{{ task.id }}">
<label class="inline-flex items-center gap-2 text-xs font-semibold text-slate-700"><input type="checkbox" name="udin_required" value="1" {% if doc.udin_required %}checked{% endif %}> UDIN required</label>
<input type="text" name="udin_number" value="{{ doc.udin_number or '' }}" placeholder="UDIN number" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
<div class="grid grid-cols-2 gap-2">
<input type="date" name="udin_date" value="{{ doc.udin_date or '' }}" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
<input type="date" name="udin_document_date" value="{{ doc.udin_document_date or '' }}" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
</div>
<input type="text" name="udin_document_type" value="{{ doc.udin_document_type or doc.document_type.replace('_',' ').title() }}" placeholder="Document type" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
<div class="grid grid-cols-2 gap-2"><input type="text" name="udin_financial_year" value="{{ doc.udin_financial_year or doc.financial_year }}" class="rounded-lg border border-slate-300 px-2 py-1 text-xs"><input type="text" name="udin_amount" value="{{ doc.udin_amount or '' }}" placeholder="Amount" class="rounded-lg border border-slate-300 px-2 py-1 text-xs"></div>
<input type="text" name="udin_notes" value="{{ doc.udin_notes or '' }}" placeholder="UDIN notes" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
<button class="rounded-lg bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white">Save UDIN</button>
</form>
{% if doc.final_release_status != 'released' %}
<form method="post" action="/documents/{{ doc.id }}/final-release" onsubmit="return confirm('Release this document as final?');" class="grid gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="redirect_to" value="/documents/tasks/{{ task.id }}">
<input type="text" name="release_notes" placeholder="Release notes" class="rounded-lg border border-slate-300 px-2 py-1 text-xs">
<button class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white">Release Final</button>
</form>
{% endif %}
</div>
</details>
{% endif %}
</td>
</tr>
{% else %}<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">No task documents uploaded yet.</td></tr>{% endfor %}
</tbody>
</table>
</section>
@@ -0,0 +1,74 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">UDIN Register</h2>
<p class="text-sm text-slate-500">Auto-generated from final document workflow. No separate manual register is required.</p>
</div>
<a href="/documents" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Engagement Documents</a>
</div>
<form method="get" action="/documents/udin-register" class="grid gap-3 rounded-2xl bg-white p-4 shadow-soft md:grid-cols-4">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Financial Year</label>
<input type="text" name="financial_year" value="{{ financial_year or '' }}" placeholder="2026-27" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">UDIN Status</label>
<select name="status" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="">All</option>
{% for st in ['pending','generated','not_required','cancelled'] %}
<option value="{{ st }}" {% if selected_status == st %}selected{% endif %}>{{ st.replace('_',' ').title() }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Search</label>
<input type="text" name="q" value="{{ q or '' }}" placeholder="UDIN / title / code" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
</div>
<div class="flex items-end">
<button class="w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Filter</button>
</div>
</form>
<section class="grid gap-4 md:grid-cols-4">
{% 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 %}
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">Total Documents</div><div class="mt-2 text-2xl font-semibold text-slate-900">{{ rows|length }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">UDIN Pending</div><div class="mt-2 text-2xl font-semibold text-red-600">{{ pending_count }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">UDIN Generated</div><div class="mt-2 text-2xl font-semibold text-emerald-600">{{ generated_count }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs uppercase tracking-wide text-slate-500">Final Released</div><div class="mt-2 text-2xl font-semibold text-brand-700">{{ released_count }}</div></div>
</section>
<div class="overflow-hidden rounded-2xl bg-white shadow-soft">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Date / FY</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client / Engagement</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Document</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">UDIN</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Release</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for doc in rows %}
<tr>
<td class="px-4 py-3 text-sm text-slate-700"><div>{{ doc.udin_date or '-' }}</div><div class="text-xs text-slate-500">FY {{ doc.udin_financial_year or doc.financial_year }}</div></td>
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ doc.client.client_name if doc.client else '-' }}</div><div class="text-xs text-slate-500">{{ doc.engagement.catalogue.service_name if doc.engagement and doc.engagement.catalogue else 'Engagement #' ~ doc.engagement_id }}</div></td>
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ doc.title }}</div><div class="text-xs text-slate-500">{{ doc.document_code }} · {{ doc.document_type.replace('_',' ').title() }}</div></td>
<td class="px-4 py-3 text-sm"><div><span class="rounded-full px-2 py-1 text-xs {{ 'bg-emerald-100 text-emerald-800' if doc.udin_status == 'generated' else ('bg-red-100 text-red-800' if doc.udin_status == 'pending' else 'bg-slate-100 text-slate-700') }}">{{ (doc.udin_status or 'not_required').replace('_',' ').title() }}</span></div>{% if doc.udin_number %}<div class="mt-1 font-mono text-xs">{{ doc.udin_number }}</div>{% endif %}</td>
<td class="px-4 py-3 text-sm"><span class="rounded-full px-2 py-1 text-xs {{ 'bg-emerald-100 text-emerald-800' if doc.final_release_status == 'released' else 'bg-slate-100 text-slate-700' }}">{{ (doc.final_release_status or 'draft').replace('_',' ').title() }}</span>{% if doc.released_at_utc %}<div class="mt-1 text-xs text-slate-500">{{ doc.released_at_utc }}</div>{% endif %}</td>
<td class="px-4 py-3 text-right text-sm"><a href="/documents/engagements/{{ doc.engagement_id }}" class="font-medium text-brand-700 hover:underline">Open</a>{% set latest = doc.versions[0] if doc.versions else None %}{% if latest %}<a href="/documents/{{ doc.id }}/download" class="ml-3 font-medium text-brand-700 hover:underline">Download</a>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="6" class="px-4 py-8 text-center text-sm text-slate-500">No UDIN records found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+174 -1
View File
@@ -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:
+2
View File
@@ -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