from __future__ import annotations from datetime import date, datetime, timezone from pathlib import Path from uuid import uuid4 from fastapi import UploadFile from sqlalchemy import and_, func, or_, select from sqlalchemy.orm import Session, joinedload from app.core.settings import get_settings from app.modules.clients.models import Client from app.modules.core.tenancy.settings_models import BranchSettings from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.notice_cases.models import ( NoticeCase, NoticeCaseDocument, NoticeCaseEvent, NoticeCaseHearing, NoticeCaseOrder, ) from app.modules.services.models import ClientServiceSubscription DEPARTMENTS = ["GST", "Income Tax", "ROC", "PF", "ESI", "Labour", "MSME", "Other"] CASE_TYPES = ["Notice", "Assessment", "Appeal", "Rectification", "Refund", "Registration", "Investigation", "Other"] CASE_STATUSES = ["open", "reply_pending", "reply_filed", "hearing_scheduled", "order_received", "appeal_pending", "appeal_filed", "closed", "archived"] CASE_PRIORITIES = ["low", "normal", "high", "urgent"] EVENT_TYPES = ["Notice Received", "Reply Filed", "Hearing", "Order Received", "Appeal Filed", "Rectification Filed", "Payment Made", "Internal Note", "Client Clarification", "Other"] HEARING_STATUSES = ["scheduled", "attended", "adjourned", "missed", "cancelled"] ORDER_TYPES = ["Assessment Order", "Appeal Order", "Rectification Order", "Refund Order", "Penalty Order", "Other"] CASE_DOCUMENT_TYPES = ["NOTICE", "REPLY", "APPEAL", "ORDER", "CHALLAN", "WORKING", "CLIENT_DOCUMENT", "ACKNOWLEDGEMENT", "OTHER"] def _has(permissions: list[str], code: str) -> bool: return code in set(permissions or []) def can_view_notice_cases(db: Session, user) -> bool: perms = get_user_permissions(db, user.id) return _has(perms, "notice_cases.view") def can_manage_notice_cases(db: Session, user) -> bool: perms = get_user_permissions(db, user.id) return _has(perms, "notice_cases.create") or _has(perms, "notice_cases.edit") def can_upload_notice_case_documents(db: Session, user) -> bool: perms = get_user_permissions(db, user.id) return _has(perms, "notice_cases.documents.upload") def can_delete_notice_case_documents(db: Session, user) -> bool: perms = get_user_permissions(db, user.id) return _has(perms, "notice_cases.documents.delete") def user_can_access_case( db: Session, user, case: NoticeCase, *, active_tenant_id: int | None, active_branch_id: int | None, active_financial_year: str | None = None, active_assessment_year: str | None = None, ) -> bool: roles = set(get_user_roles(db, user.id)) perms = set(get_user_permissions(db, user.id)) if "System Admin" in roles and "notice_cases.cross_tenant" in perms: return active_tenant_id in (None, case.tenant_id) or True if case.tenant_id != int(active_tenant_id or getattr(user, "tenant_id", 0) or 0): return False if active_branch_id and case.branch_id and case.branch_id != int(active_branch_id): return False if "notice_cases.view" not in perms: return False fy = (active_financial_year or "").strip() ay = (active_assessment_year or "").strip() if fy and (case.financial_year or "").strip() and (case.financial_year or "").strip() != fy: return False if ay and not fy and (case.assessment_year or "").strip() and (case.assessment_year or "").strip() != ay: return False if "notice_cases.view.own_only" in perms and not ({case.assigned_partner_user_id, case.assigned_manager_user_id, case.assigned_staff_user_id} & {user.id}): return False return True def parse_date(value: str | None) -> date | None: value = (value or "").strip() if not value: return None return date.fromisoformat(value) def normalize_choice(value: str | None, choices: list[str], default: str) -> str: value = (value or "").strip() return value if value in choices else default def make_case_code(db: Session, tenant_id: int, case_id: int, department: str) -> str: prefix = "CASE" dept = (department or "GEN").upper().replace(" ", "")[:4] return f"{prefix}-{tenant_id}-{dept}-{case_id:06d}" def list_clients_for_case(db: Session, *, tenant_id: int, branch_id: int | None) -> list[Client]: stmt = select(Client).where(Client.tenant_id == tenant_id, Client.is_archived.is_(False)) if branch_id: stmt = stmt.where(Client.branch_id == branch_id) return list(db.execute(stmt.order_by(Client.client_name.asc())).scalars()) def list_engagements_for_client( db: Session, *, tenant_id: int, client_id: int, financial_year: str | None = None, ) -> list[ClientServiceSubscription]: stmt = select(ClientServiceSubscription).where( ClientServiceSubscription.tenant_id == tenant_id, ClientServiceSubscription.client_id == client_id, ) if financial_year: stmt = stmt.where(ClientServiceSubscription.financial_year == financial_year.strip()) return list( db.execute( stmt.order_by(ClientServiceSubscription.financial_year.desc(), ClientServiceSubscription.id.desc()) ).scalars() ) def list_assignable_users(db: Session, *, tenant_id: int, branch_id: int | None): from app.modules.core.iam.models import User stmt = select(User).where(User.tenant_id == tenant_id) if branch_id: stmt = stmt.where(or_(User.branch_id == branch_id, User.branch_id.is_(None))) order_cols = [User.full_name.asc()] if hasattr(User, "login_id"): order_cols.append(User.login_id.asc()) elif hasattr(User, "email"): order_cols.append(User.email.asc()) return list(db.execute(stmt.order_by(*order_cols)).scalars()) def list_cases( db: Session, *, tenant_id: int, branch_id: int | None, q: str = "", department: str = "", status: str = "", include_archived: bool = False, financial_year: str | None = None, assessment_year: str | None = None, ) -> list[NoticeCase]: stmt = ( select(NoticeCase) .options(joinedload(NoticeCase.client), joinedload(NoticeCase.assigned_partner), joinedload(NoticeCase.assigned_manager), joinedload(NoticeCase.assigned_staff)) .where(NoticeCase.tenant_id == tenant_id) ) if branch_id: stmt = stmt.where(NoticeCase.branch_id == branch_id) if not include_archived: stmt = stmt.where(NoticeCase.is_archived.is_(False)) if department: stmt = stmt.where(NoticeCase.department == department) if status: stmt = stmt.where(NoticeCase.status == status) fy = (financial_year or "").strip() ay = (assessment_year or "").strip() if fy: stmt = stmt.where(NoticeCase.financial_year == fy) elif ay: stmt = stmt.where(NoticeCase.assessment_year == ay) q = (q or "").strip() if q: like = f"%{q}%" stmt = stmt.join(Client, Client.id == NoticeCase.client_id).where( or_( NoticeCase.case_code.ilike(like), NoticeCase.title.ilike(like), NoticeCase.reference_no.ilike(like), NoticeCase.din_ack_no.ilike(like), Client.client_name.ilike(like), Client.client_code.ilike(like), Client.pan.ilike(like), Client.gstin.ilike(like), ) ) return list(db.execute(stmt.order_by(NoticeCase.due_date.asc().nullslast(), NoticeCase.updated_at_utc.desc())).unique().scalars()) def case_dashboard_summary(rows: list[NoticeCase]) -> dict[str, int]: today = date.today() open_statuses = {"open", "reply_pending", "hearing_scheduled", "appeal_pending"} summary = { "total": len(rows), "open": 0, "reply_pending": 0, "hearing_scheduled": 0, "overdue": 0, "closed": 0, } for row in rows: status = (row.status or "").strip().lower() if status in open_statuses: summary["open"] += 1 if status == "reply_pending": summary["reply_pending"] += 1 if status == "hearing_scheduled": summary["hearing_scheduled"] += 1 if status in {"closed", "archived"}: summary["closed"] += 1 if row.due_date and row.due_date < today and status not in {"closed", "archived"}: summary["overdue"] += 1 return summary def get_case(db: Session, case_id: int) -> NoticeCase | None: return db.execute( select(NoticeCase) .options( joinedload(NoticeCase.client), joinedload(NoticeCase.engagement), joinedload(NoticeCase.assigned_partner), joinedload(NoticeCase.assigned_manager), joinedload(NoticeCase.assigned_staff), joinedload(NoticeCase.events), joinedload(NoticeCase.hearings), joinedload(NoticeCase.orders), joinedload(NoticeCase.documents).joinedload(NoticeCaseDocument.uploaded_by), ) .where(NoticeCase.id == case_id) ).unique().scalar_one_or_none() def create_case(db: Session, *, tenant_id: int, branch_id: int | None, user, data: dict) -> NoticeCase: client = db.get(Client, int(data["client_id"])) if not client or client.tenant_id != tenant_id: raise ValueError("Invalid client selected.") if branch_id and client.branch_id != branch_id: raise ValueError("Selected client does not belong to the active branch.") engagement_id = int(data["engagement_id"]) if data.get("engagement_id") else None active_fy = (data.get("active_financial_year") or "").strip()[:9] or None active_ay = (data.get("active_assessment_year") or "").strip()[:9] or None selected_fy = (data.get("financial_year") or "").strip()[:9] or active_fy selected_ay = (data.get("assessment_year") or "").strip()[:9] or active_ay if engagement_id: engagement = db.get(ClientServiceSubscription, engagement_id) if not engagement or engagement.tenant_id != tenant_id or engagement.client_id != client.id: raise ValueError("Invalid engagement selected.") engagement_fy = (engagement.financial_year or "").strip() engagement_ay = (engagement.assessment_year or "").strip() if selected_fy and engagement_fy and selected_fy != engagement_fy: raise ValueError("Selected engagement belongs to a different financial year.") selected_fy = selected_fy or engagement_fy or None selected_ay = selected_ay or engagement_ay or None row = NoticeCase( tenant_id=tenant_id, branch_id=client.branch_id, client_id=client.id, engagement_id=engagement_id, case_code="PENDING", department=normalize_choice(data.get("department"), DEPARTMENTS, "GST"), case_type=normalize_choice(data.get("case_type"), CASE_TYPES, "Notice"), title=(data.get("title") or "").strip()[:255], reference_no=(data.get("reference_no") or "").strip()[:150] or None, din_ack_no=(data.get("din_ack_no") or "").strip()[:150] or None, notice_date=parse_date(data.get("notice_date")), due_date=parse_date(data.get("due_date")), financial_year=selected_fy, assessment_year=selected_ay, period_label=(data.get("period_label") or "").strip()[:60] or None, status=normalize_choice(data.get("status"), CASE_STATUSES, "open"), priority=normalize_choice(data.get("priority"), CASE_PRIORITIES, "normal"), issue_summary=(data.get("issue_summary") or "").strip() or None, remarks=(data.get("remarks") or "").strip() or None, assigned_partner_user_id=int(data["assigned_partner_user_id"]) if data.get("assigned_partner_user_id") else None, assigned_manager_user_id=int(data["assigned_manager_user_id"]) if data.get("assigned_manager_user_id") else None, assigned_staff_user_id=int(data["assigned_staff_user_id"]) if data.get("assigned_staff_user_id") else None, created_by_user_id=user.id, updated_by_user_id=user.id, ) if not row.title: raise ValueError("Case title is required.") db.add(row) db.flush() row.case_code = make_case_code(db, tenant_id, row.id, row.department) return row def update_case(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCase: case.department = normalize_choice(data.get("department"), DEPARTMENTS, case.department) case.case_type = normalize_choice(data.get("case_type"), CASE_TYPES, case.case_type) case.title = (data.get("title") or case.title).strip()[:255] case.reference_no = (data.get("reference_no") or "").strip()[:150] or None case.din_ack_no = (data.get("din_ack_no") or "").strip()[:150] or None case.notice_date = parse_date(data.get("notice_date")) case.due_date = parse_date(data.get("due_date")) active_fy = (data.get("active_financial_year") or "").strip()[:9] or None active_ay = (data.get("active_assessment_year") or "").strip()[:9] or None case.financial_year = (data.get("financial_year") or "").strip()[:9] or active_fy case.assessment_year = (data.get("assessment_year") or "").strip()[:9] or active_ay case.period_label = (data.get("period_label") or "").strip()[:60] or None case.status = normalize_choice(data.get("status"), CASE_STATUSES, case.status) case.priority = normalize_choice(data.get("priority"), CASE_PRIORITIES, case.priority) case.issue_summary = (data.get("issue_summary") or "").strip() or None case.remarks = (data.get("remarks") or "").strip() or None case.assigned_partner_user_id = int(data["assigned_partner_user_id"]) if data.get("assigned_partner_user_id") else None case.assigned_manager_user_id = int(data["assigned_manager_user_id"]) if data.get("assigned_manager_user_id") else None case.assigned_staff_user_id = int(data["assigned_staff_user_id"]) if data.get("assigned_staff_user_id") else None case.updated_by_user_id = user.id return case def add_event(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCaseEvent: row = NoticeCaseEvent( tenant_id=case.tenant_id, branch_id=case.branch_id, case_id=case.id, event_type=normalize_choice(data.get("event_type"), EVENT_TYPES, "Internal Note"), event_date=parse_date(data.get("event_date")) or date.today(), description=(data.get("description") or "").strip(), next_due_date=parse_date(data.get("next_due_date")), created_by_user_id=user.id, ) if not row.description: raise ValueError("Event description is required.") if row.next_due_date: case.due_date = row.next_due_date case.updated_by_user_id = user.id db.add(row) return row def add_hearing(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCaseHearing: row = NoticeCaseHearing( tenant_id=case.tenant_id, branch_id=case.branch_id, case_id=case.id, hearing_date=parse_date(data.get("hearing_date")) or date.today(), hearing_time=(data.get("hearing_time") or "").strip()[:20] or None, venue_or_mode=(data.get("venue_or_mode") or "").strip()[:200] or None, officer_name=(data.get("officer_name") or "").strip()[:150] or None, agenda=(data.get("agenda") or "").strip() or None, outcome=(data.get("outcome") or "").strip() or None, status=normalize_choice(data.get("status"), HEARING_STATUSES, "scheduled"), created_by_user_id=user.id, ) case.status = "hearing_scheduled" if row.status == "scheduled" else case.status case.due_date = row.hearing_date case.updated_by_user_id = user.id db.add(row) return row def add_order(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCaseOrder: row = NoticeCaseOrder( tenant_id=case.tenant_id, branch_id=case.branch_id, case_id=case.id, order_type=normalize_choice(data.get("order_type"), ORDER_TYPES, "Other"), order_no=(data.get("order_no") or "").strip()[:150] or None, order_date=parse_date(data.get("order_date")) or date.today(), demand_amount=int(data.get("demand_amount") or 0), tax_amount=int(data.get("tax_amount") or 0), interest_amount=int(data.get("interest_amount") or 0), penalty_amount=int(data.get("penalty_amount") or 0), summary=(data.get("summary") or "").strip() or None, appeal_due_date=parse_date(data.get("appeal_due_date")), appeal_filed=bool(data.get("appeal_filed")), created_by_user_id=user.id, ) case.status = "appeal_filed" if row.appeal_filed else "order_received" case.due_date = row.appeal_due_date case.updated_by_user_id = user.id db.add(row) return row class UploadValidationError(ValueError): """Raised when a notice/case document upload violates branch file policy.""" DANGEROUS_DOCUMENT_EXTENSIONS = { ".exe", ".bat", ".cmd", ".com", ".scr", ".pif", ".sh", ".bash", ".zsh", ".ps1", ".psm1", ".js", ".jse", ".vbs", ".vbe", ".wsf", ".msi", ".dll", ".jar", ".apk", } def _branch_document_settings(db: Session, branch_id: int | None) -> BranchSettings | None: if not branch_id: return None return db.execute( select(BranchSettings).where(BranchSettings.branch_id == int(branch_id)) ).scalar_one_or_none() def _normalise_allowed_extensions(raw: str | None) -> set[str]: values = raw or "pdf,jpg,jpeg,png,xlsx,xls,docx,zip" return { "." + item.strip().lower().lstrip(".") for item in values.split(",") if item.strip() } def notice_case_upload_policy(db: Session, *, branch_id: int | None) -> dict[str, object]: settings = _branch_document_settings(db, branch_id) allowed_exts = _normalise_allowed_extensions(getattr(settings, "allowed_ext_csv", None)) max_file_mb = int(getattr(settings, "max_file_mb", 25) or 25) if max_file_mb <= 0: max_file_mb = 25 return { "allowed_exts": sorted(allowed_exts), "allowed_ext_csv": ",".join(ext.lstrip(".") for ext in sorted(allowed_exts)), "accept": ",".join(sorted(allowed_exts)), "max_file_mb": max_file_mb, "max_file_bytes": max_file_mb * 1024 * 1024, } def _validate_notice_case_upload_policy(db: Session, *, case: NoticeCase, upload_file: UploadFile, original: str) -> bytes: policy = notice_case_upload_policy(db, branch_id=case.branch_id) allowed_exts = set(policy["allowed_exts"] or []) max_file_bytes = int(policy["max_file_bytes"] or (25 * 1024 * 1024)) if not original or original in {".", ".."}: raise UploadValidationError("Invalid filename.") ext = Path(original).suffix.lower() if not ext: raise UploadValidationError("File extension is required.") if ext in DANGEROUS_DOCUMENT_EXTENSIONS: raise UploadValidationError("Executable or dangerous file types are not allowed.") if allowed_exts and ext not in allowed_exts: raise UploadValidationError("File type not allowed. Allowed types: " + ", ".join(sorted(allowed_exts))) data = upload_file.file.read(max_file_bytes + 1) if len(data) > max_file_bytes: raise UploadValidationError(f"File too large. Maximum allowed size is {policy['max_file_mb']} MB.") return data def _storage_root() -> Path: settings = get_settings() base = getattr(settings, "LOCAL_STORAGE_ROOT", None) or getattr(settings, "DOCUMENT_STORAGE_ROOT", None) or "data/storage" return Path(base) def case_document_absolute_path(document: NoticeCaseDocument) -> Path: return _storage_root() / document.local_relative_path def save_case_document(db: Session, *, case: NoticeCase, upload_file: UploadFile, user, title: str, document_type: str, description: str | None, event_id: int | None = None) -> NoticeCaseDocument: original = Path(upload_file.filename or "").name data = _validate_notice_case_upload_policy(db, case=case, upload_file=upload_file, original=original) ext = Path(original).suffix.lower() stored = f"case_{case.id}_{uuid4().hex}{ext}" fy_folder = f"FY{case.financial_year}" if case.financial_year else "FY_UNASSIGNED" rel = Path("notice_cases") / fy_folder / str(case.tenant_id) / str(case.client_id) / case.case_code / stored absolute = _storage_root() / rel absolute.parent.mkdir(parents=True, exist_ok=True) absolute.write_bytes(data) latest_version = db.execute(select(func.max(NoticeCaseDocument.version_no)).where(NoticeCaseDocument.case_id == case.id, NoticeCaseDocument.title == (title or original))).scalar_one() or 0 row = NoticeCaseDocument( tenant_id=case.tenant_id, branch_id=case.branch_id, case_id=case.id, event_id=event_id, document_type=normalize_choice(document_type, CASE_DOCUMENT_TYPES, "OTHER"), title=(title or original).strip()[:255], description=(description or "").strip() or None, original_filename=original, stored_filename=stored, content_type=upload_file.content_type, file_size_bytes=len(data), local_relative_path=str(rel).replace("\\", "/"), version_no=int(latest_version) + 1, uploaded_by_user_id=user.id, ) db.add(row) case.updated_by_user_id = user.id return row