Integrate UDIN controls into final document workflow
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user