Fix SMTP defaults and upload validation

This commit is contained in:
A R R R Associates
2026-06-28 09:10:49 +05:30
parent f5174575c5
commit df4fd17211
7 changed files with 220 additions and 26 deletions
+19 -4
View File
@@ -252,18 +252,33 @@ def get_or_create_email_setting(db: Session, tenant_id: int, branch_id: int | No
).scalar_one_or_none() ).scalar_one_or_none()
if row: if row:
return row return row
settings = get_settings()
row = EmailSetting( row = EmailSetting(
tenant_id=tenant_id, tenant_id=tenant_id,
branch_id=branch_id, branch_id=branch_id,
smtp_host="smtp.hostinger.com",
smtp_port=465, # SMTP defaults from Coolify / environment
smtp_host=getattr(settings, "SMTP_HOST", None) or "mail.vavalam.com",
smtp_port=int(getattr(settings, "SMTP_PORT", 465) or 465),
smtp_security="SSL", smtp_security="SSL",
imap_host="imap.hostinger.com", smtp_username=getattr(settings, "SMTP_USERNAME", None) or "no-reply@vavalam.com",
imap_port=993, smtp_password=getattr(settings, "SMTP_PASSWORD", None),
# Sender defaults
from_email=getattr(settings, "SMTP_FROM_EMAIL", None) or "no-reply@vavalam.com",
from_name=getattr(settings, "SMTP_FROM_NAME", None) or "ARRR ERP",
# IMAP defaults from Coolify / environment
imap_host=getattr(settings, "IMAP_HOST", None) or "mail.vavalam.com",
imap_port=int(getattr(settings, "IMAP_PORT", 993) or 993),
imap_security="SSL", imap_security="SSL",
created_by_user_id=actor_user_id, created_by_user_id=actor_user_id,
updated_by_user_id=actor_user_id, updated_by_user_id=actor_user_id,
) )
db.add(row) db.add(row)
db.flush() db.flush()
seed_default_email_templates(db, tenant_id=tenant_id, branch_id=branch_id) seed_default_email_templates(db, tenant_id=tenant_id, branch_id=branch_id)
@@ -14,7 +14,7 @@
<div><label class="text-xs font-semibold text-slate-500">Employee</label><select id="upload_employee_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" required>{% for emp in employees %}<option value="{{ emp.id }}" {% if selected_employee_id==emp.id %}selected{% endif %}>{{ emp.employee_code }} - {{ emp.full_name }}</option>{% endfor %}</select></div> <div><label class="text-xs font-semibold text-slate-500">Employee</label><select id="upload_employee_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" required>{% for emp in employees %}<option value="{{ emp.id }}" {% if selected_employee_id==emp.id %}selected{% endif %}>{{ emp.employee_code }} - {{ emp.full_name }}</option>{% endfor %}</select></div>
<div><label class="text-xs font-semibold text-slate-500">Type</label><select name="document_type_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">General</option>{% for dt in document_types %}<option value="{{ dt.id }}">{{ dt.code }} - {{ dt.name }}</option>{% endfor %}</select></div> <div><label class="text-xs font-semibold text-slate-500">Type</label><select name="document_type_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">General</option>{% for dt in document_types %}<option value="{{ dt.id }}">{{ dt.code }} - {{ dt.name }}</option>{% endfor %}</select></div>
<div><label class="text-xs font-semibold text-slate-500">Title</label><input name="title" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div> <div><label class="text-xs font-semibold text-slate-500">Title</label><input name="title" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
<div><label class="text-xs font-semibold text-slate-500">File</label><input type="file" name="document_file" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" required></div> <div><label class="text-xs font-semibold text-slate-500">File</label><input type="file" name="document_file" accept="{{ employee_upload_accept }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" required><p class="mt-1 text-xs text-slate-500">{{ employee_upload_help }}</p></div>
<div><label class="text-xs font-semibold text-slate-500">Document No</label><input name="document_no" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div> <div><label class="text-xs font-semibold text-slate-500">Document No</label><input name="document_no" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
<div><label class="text-xs font-semibold text-slate-500">Issue Date</label><input type="date" name="issue_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div> <div><label class="text-xs font-semibold text-slate-500">Issue Date</label><input type="date" name="issue_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
<div><label class="text-xs font-semibold text-slate-500">Expiry Date</label><input type="date" name="expiry_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div> <div><label class="text-xs font-semibold text-slate-500">Expiry Date</label><input type="date" name="expiry_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
@@ -14,7 +14,7 @@
<div><label class="text-xs font-semibold text-slate-500">Type</label><select name="document_type_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">General</option>{% for dt in document_types %}<option value="{{ dt.id }}">{{ dt.code }} - {{ dt.name }}</option>{% endfor %}</select></div> <div><label class="text-xs font-semibold text-slate-500">Type</label><select name="document_type_id" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">General</option>{% for dt in document_types %}<option value="{{ dt.id }}">{{ dt.code }} - {{ dt.name }}</option>{% endfor %}</select></div>
<div><label class="text-xs font-semibold text-slate-500">Title</label><input name="title" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div> <div><label class="text-xs font-semibold text-slate-500">Title</label><input name="title" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
<div><label class="text-xs font-semibold text-slate-500">Document No</label><input name="document_no" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div> <div><label class="text-xs font-semibold text-slate-500">Document No</label><input name="document_no" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
<div><label class="text-xs font-semibold text-slate-500">File</label><input type="file" name="document_file" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" required></div> <div><label class="text-xs font-semibold text-slate-500">File</label><input type="file" name="document_file" accept="{{ employee_upload_accept }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2" required><p class="mt-1 text-xs text-slate-500">{{ employee_upload_help }}</p></div>
<div><label class="text-xs font-semibold text-slate-500">Issue Date</label><input type="date" name="issue_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div> <div><label class="text-xs font-semibold text-slate-500">Issue Date</label><input type="date" name="issue_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
<div><label class="text-xs font-semibold text-slate-500">Expiry Date</label><input type="date" name="expiry_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div> <div><label class="text-xs font-semibold text-slate-500">Expiry Date</label><input type="date" name="expiry_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
<div class="md:col-span-2"><label class="text-xs font-semibold text-slate-500">Remarks</label><input name="remarks" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div> <div class="md:col-span-2"><label class="text-xs font-semibold text-slate-500">Remarks</label><input name="remarks" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></div>
+90 -13
View File
@@ -6,6 +6,7 @@ from uuid import uuid4
from fastapi import APIRouter, File, Form, Request, UploadFile from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import RedirectResponse, StreamingResponse from fastapi.responses import RedirectResponse, StreamingResponse
from sqlalchemy import select
from app.core.db.common import CommonSessionLocal from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
@@ -13,6 +14,7 @@ from app.core.security.session_auth import get_current_user
from app.core.templating import templates from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission from app.modules.core.rbac.permission_guard import require_permission
from app.modules.core.tenancy.settings_models import BranchSettings
from app.modules.core.iam.profile_service import save_user_profile_photo, update_user_public_profile from app.modules.core.iam.profile_service import save_user_profile_photo, update_user_public_profile
from app.modules.employees.import_service import ( from app.modules.employees.import_service import (
build_template_workbook, build_template_workbook,
@@ -173,6 +175,7 @@ def _base_ctx(request: Request, db, current_user, **ctx):
"offboarding_task_statuses": OFFBOARDING_TASK_STATUS, "offboarding_task_statuses": OFFBOARDING_TASK_STATUS,
"payroll_run_statuses": PAYROLL_RUN_STATUS, "payroll_run_statuses": PAYROLL_RUN_STATUS,
"payslip_statuses": PAYSLIP_STATUS, "payslip_statuses": PAYSLIP_STATUS,
**_upload_policy_context(),
} }
base.update(ctx) base.update(ctx)
return base return base
@@ -254,6 +257,56 @@ def _form_options(db, current_user, scope, *, include_user_id: int | None = None
UPLOAD_ROOT = Path("data/uploads/employee_documents") UPLOAD_ROOT = Path("data/uploads/employee_documents")
ALLOWED_DOCUMENT_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".doc", ".docx", ".xls", ".xlsx"} ALLOWED_DOCUMENT_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".doc", ".docx", ".xls", ".xlsx"}
DANGEROUS_DOCUMENT_EXTENSIONS = {
".exe", ".bat", ".cmd", ".com", ".scr", ".pif",
".sh", ".bash", ".zsh", ".ps1", ".psm1",
".js", ".jse", ".vbs", ".vbe", ".wsf",
".msi", ".dll", ".jar", ".apk",
}
DEFAULT_EMPLOYEE_UPLOAD_MAX_MB = 10
def _normalise_extension(ext: str) -> str:
ext = (ext or "").strip().lower()
if not ext:
return ""
return ext if ext.startswith(".") else f".{ext}"
def _extensions_from_csv(raw: str | None, *, fallback: set[str] | None = None) -> set[str]:
values = {_normalise_extension(item) for item in (raw or "").split(",") if item.strip()}
values = {item for item in values if item}
return values or set(fallback or ALLOWED_DOCUMENT_EXTENSIONS)
def _employee_upload_accept(allowed_exts: set[str] | None = None) -> str:
exts = allowed_exts or ALLOWED_DOCUMENT_EXTENSIONS
return ",".join(sorted(exts))
def _employee_upload_help(max_file_mb: int | None = None, allowed_exts: set[str] | None = None) -> str:
exts = ", ".join(ext.lstrip(".").upper() for ext in sorted(allowed_exts or ALLOWED_DOCUMENT_EXTENSIONS))
max_mb = max_file_mb or DEFAULT_EMPLOYEE_UPLOAD_MAX_MB
return f"Allowed file types: {exts}. Maximum size: {max_mb} MB."
def _branch_upload_settings(db, branch_id: int | None) -> tuple[set[str], int]:
if branch_id:
settings = db.execute(select(BranchSettings).where(BranchSettings.branch_id == int(branch_id))).scalar_one_or_none()
if settings:
allowed = _extensions_from_csv(getattr(settings, "allowed_ext_csv", None), fallback=ALLOWED_DOCUMENT_EXTENSIONS)
max_mb = int(getattr(settings, "max_file_mb", DEFAULT_EMPLOYEE_UPLOAD_MAX_MB) or DEFAULT_EMPLOYEE_UPLOAD_MAX_MB)
return allowed, max(1, max_mb)
return set(ALLOWED_DOCUMENT_EXTENSIONS), DEFAULT_EMPLOYEE_UPLOAD_MAX_MB
def _upload_policy_context(db=None, branch_id: int | None = None) -> dict:
allowed, max_mb = _branch_upload_settings(db, branch_id) if db is not None else (set(ALLOWED_DOCUMENT_EXTENSIONS), DEFAULT_EMPLOYEE_UPLOAD_MAX_MB)
return {
"employee_upload_accept": _employee_upload_accept(allowed),
"employee_upload_help": _employee_upload_help(max_mb, allowed),
"employee_upload_max_file_mb": max_mb,
}
def _document_type_payload(form): def _document_type_payload(form):
@@ -301,17 +354,39 @@ def _offboarding_payload(form):
} }
async def _save_employee_upload(upload: UploadFile, *, tenant_id: int, employee_id: int) -> tuple[str, str, str, int | None, str | None]: async def _save_employee_upload(
original = _safe_upload_filename(upload.filename or "document") upload: UploadFile,
*,
tenant_id: int,
employee_id: int,
allowed_exts: set[str] | None = None,
max_file_mb: int | None = None,
) -> tuple[str, str, str, int | None, str | None]:
original = _safe_upload_filename(upload.filename or "")
if not original:
raise ValueError("Invalid file name. Please choose a valid document to upload.")
suffix = Path(original).suffix.lower() suffix = Path(original).suffix.lower()
if suffix not in ALLOWED_DOCUMENT_EXTENSIONS: if not suffix:
raise ValueError("Unsupported file type. Allowed: PDF, images, Word and Excel files.") raise ValueError("Invalid file type. File extension is required.")
if suffix in DANGEROUS_DOCUMENT_EXTENSIONS:
raise ValueError(f"File type not allowed. Extension {suffix} is blocked for employee document uploads.")
allowed = allowed_exts or ALLOWED_DOCUMENT_EXTENSIONS
if suffix not in allowed:
allowed_label = ", ".join(ext.lstrip(".").upper() for ext in sorted(allowed))
raise ValueError(f"File type not allowed. Extension {suffix} is not permitted. Allowed: {allowed_label}.")
data = await upload.read() data = await upload.read()
if not data: if not data:
raise ValueError("Please choose a non-empty file to upload.") raise ValueError("Invalid upload. Please choose a non-empty file to upload.")
max_size = 10 * 1024 * 1024
max_mb = max_file_mb or DEFAULT_EMPLOYEE_UPLOAD_MAX_MB
max_size = int(max_mb) * 1024 * 1024
if len(data) > max_size: if len(data) > max_size:
raise ValueError("File size should not exceed 10 MB.") raise ValueError(f"Upload failed. File size should not exceed {max_mb} MB.")
folder = UPLOAD_ROOT / str(tenant_id) / str(employee_id) folder = UPLOAD_ROOT / str(tenant_id) / str(employee_id)
folder.mkdir(parents=True, exist_ok=True) folder.mkdir(parents=True, exist_ok=True)
stored = f"{uuid4().hex}{suffix}" stored = f"{uuid4().hex}{suffix}"
@@ -1295,7 +1370,7 @@ def employee_documents(request: Request, employee_id: int | None = None, status:
tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id
branch_id = request.session.get("active_branch_id") branch_id = request.session.get("active_branch_id")
scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id)
return _render(request, "modules/employees/templates/employees/documents.html", db, current_user, title="Employee Documents", rows=list_employee_documents(db, scope, employee_id=employee_id, status=status), employees=list_employees(db, scope, include_inactive=True), document_types=list_document_types(db, scope), scope=scope, selected_employee_id=employee_id, selected_status=status, errors=[]) return _render(request, "modules/employees/templates/employees/documents.html", db, current_user, title="Employee Documents", rows=list_employee_documents(db, scope, employee_id=employee_id, status=status), employees=list_employees(db, scope, include_inactive=True), document_types=list_document_types(db, scope), scope=scope, selected_employee_id=employee_id, selected_status=status, errors=[], **_upload_policy_context(db, branch_id))
finally: finally:
db.close() db.close()
@@ -1320,8 +1395,9 @@ async def employee_document_upload(request: Request, employee_id: int, document_
branch_id = request.session.get("active_branch_id") branch_id = request.session.get("active_branch_id")
scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id)
employee = get_employee_or_404(db, employee_id, scope) employee = get_employee_or_404(db, employee_id, scope)
allowed_exts, max_file_mb = _branch_upload_settings(db, employee.branch_id)
try: try:
original, stored, path, size, content_type = await _save_employee_upload(document_file, tenant_id=employee.tenant_id, employee_id=employee.id) original, stored, path, size, content_type = await _save_employee_upload(document_file, tenant_id=employee.tenant_id, employee_id=employee.id, allowed_exts=allowed_exts, max_file_mb=max_file_mb)
create_employee_document_record( create_employee_document_record(
db, db,
current_user, current_user,
@@ -1341,7 +1417,7 @@ async def employee_document_upload(request: Request, employee_id: int, document_
) )
return RedirectResponse(url=f"/employees/documents?employee_id={employee.id}", status_code=303) return RedirectResponse(url=f"/employees/documents?employee_id={employee.id}", status_code=303)
except Exception as exc: except Exception as exc:
return _render(request, "modules/employees/templates/employees/documents.html", db, current_user, title="Employee Documents", rows=list_employee_documents(db, scope, employee_id=employee.id), employees=list_employees(db, scope, include_inactive=True), document_types=list_document_types(db, scope), scope=scope, selected_employee_id=employee.id, selected_status=None, errors=[getattr(exc, "detail", str(exc))]) return _render(request, "modules/employees/templates/employees/documents.html", db, current_user, title="Employee Documents", rows=list_employee_documents(db, scope, employee_id=employee.id), employees=list_employees(db, scope, include_inactive=True), document_types=list_document_types(db, scope), scope=scope, selected_employee_id=employee.id, selected_status=None, errors=[getattr(exc, "detail", str(exc))], **_upload_policy_context(db, employee.branch_id))
finally: finally:
db.close() db.close()
@@ -1412,7 +1488,7 @@ def employee_self_documents(request: Request):
if employee: if employee:
scope = build_employee_scope(db, current_user, tenant_id=employee.tenant_id, branch_id=employee.branch_id) scope = build_employee_scope(db, current_user, tenant_id=employee.tenant_id, branch_id=employee.branch_id)
document_types = [dt for dt in list_document_types(db, scope) if dt.allow_employee_upload] document_types = [dt for dt in list_document_types(db, scope) if dt.allow_employee_upload]
return _render(request, "modules/employees/templates/employees/self_documents.html", db, current_user, title="My Documents", employee=employee, rows=list_own_employee_documents(db, current_user), document_types=document_types, errors=[]) return _render(request, "modules/employees/templates/employees/self_documents.html", db, current_user, title="My Documents", employee=employee, rows=list_own_employee_documents(db, current_user), document_types=document_types, errors=[], **_upload_policy_context(db, employee.branch_id if employee else None))
finally: finally:
db.close() db.close()
@@ -1436,8 +1512,9 @@ async def employee_self_document_upload(request: Request, document_file: UploadF
employee = get_employee_for_user(db, current_user) employee = get_employee_for_user(db, current_user)
if not employee: if not employee:
return RedirectResponse(url="/employee/register", status_code=303) return RedirectResponse(url="/employee/register", status_code=303)
allowed_exts, max_file_mb = _branch_upload_settings(db, employee.branch_id)
try: try:
original, stored, path, size, content_type = await _save_employee_upload(document_file, tenant_id=employee.tenant_id, employee_id=employee.id) original, stored, path, size, content_type = await _save_employee_upload(document_file, tenant_id=employee.tenant_id, employee_id=employee.id, allowed_exts=allowed_exts, max_file_mb=max_file_mb)
create_employee_document_record( create_employee_document_record(
db, db,
current_user, current_user,
@@ -1458,7 +1535,7 @@ async def employee_self_document_upload(request: Request, document_file: UploadF
return RedirectResponse(url="/employee/documents", status_code=303) return RedirectResponse(url="/employee/documents", status_code=303)
except Exception as exc: except Exception as exc:
scope = build_employee_scope(db, current_user, tenant_id=employee.tenant_id, branch_id=employee.branch_id) scope = build_employee_scope(db, current_user, tenant_id=employee.tenant_id, branch_id=employee.branch_id)
return _render(request, "modules/employees/templates/employees/self_documents.html", db, current_user, title="My Documents", employee=employee, rows=list_own_employee_documents(db, current_user), document_types=[dt for dt in list_document_types(db, scope) if dt.allow_employee_upload], errors=[getattr(exc, "detail", str(exc))]) return _render(request, "modules/employees/templates/employees/self_documents.html", db, current_user, title="My Documents", employee=employee, rows=list_own_employee_documents(db, current_user), document_types=[dt for dt in list_document_types(db, scope) if dt.allow_employee_upload], errors=[getattr(exc, "detail", str(exc))], **_upload_policy_context(db, employee.branch_id))
finally: finally:
db.close() db.close()
+72 -2
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session, joinedload
from app.core.settings import get_settings from app.core.settings import get_settings
from app.modules.clients.models import Client 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.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.notice_cases.models import ( from app.modules.notice_cases.models import (
NoticeCase, NoticeCase,
@@ -381,6 +382,75 @@ def add_order(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCaseO
return 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: def _storage_root() -> Path:
settings = get_settings() settings = get_settings()
base = getattr(settings, "LOCAL_STORAGE_ROOT", None) or getattr(settings, "DOCUMENT_STORAGE_ROOT", None) or "data/storage" base = getattr(settings, "LOCAL_STORAGE_ROOT", None) or getattr(settings, "DOCUMENT_STORAGE_ROOT", None) or "data/storage"
@@ -392,14 +462,14 @@ def case_document_absolute_path(document: NoticeCaseDocument) -> 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: 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 "case_document.bin").name 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() ext = Path(original).suffix.lower()
stored = f"case_{case.id}_{uuid4().hex}{ext}" stored = f"case_{case.id}_{uuid4().hex}{ext}"
fy_folder = f"FY{case.financial_year}" if case.financial_year else "FY_UNASSIGNED" 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 rel = Path("notice_cases") / fy_folder / str(case.tenant_id) / str(case.client_id) / case.case_code / stored
absolute = _storage_root() / rel absolute = _storage_root() / rel
absolute.parent.mkdir(parents=True, exist_ok=True) absolute.parent.mkdir(parents=True, exist_ok=True)
data = upload_file.file.read()
absolute.write_bytes(data) 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 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( row = NoticeCaseDocument(
@@ -27,6 +27,6 @@
<section class="rounded-xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><h2 class="font-bold">Orders</h2><form method="post" action="/notice-cases/{{ case.id }}/orders" class="mt-4 grid gap-3 md:grid-cols-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><select name="order_type" class="rounded-lg border px-3 py-2 text-sm">{% for o in order_types %}<option>{{ o }}</option>{% endfor %}</select><input name="order_no" placeholder="Order No" class="rounded-lg border px-3 py-2 text-sm"><input type="date" name="order_date" required class="rounded-lg border px-3 py-2 text-sm"><input type="date" name="appeal_due_date" class="rounded-lg border px-3 py-2 text-sm"><input name="tax_amount" placeholder="Tax" type="number" class="rounded-lg border px-3 py-2 text-sm"><input name="interest_amount" placeholder="Interest" type="number" class="rounded-lg border px-3 py-2 text-sm"><input name="penalty_amount" placeholder="Penalty" type="number" class="rounded-lg border px-3 py-2 text-sm"><input name="demand_amount" placeholder="Total Demand" type="number" class="rounded-lg border px-3 py-2 text-sm"><textarea name="summary" placeholder="Order summary" class="rounded-lg border px-3 py-2 text-sm md:col-span-2"></textarea><label class="text-sm md:col-span-2"><input type="checkbox" name="appeal_filed" value="1"> Appeal filed</label><button class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white md:col-span-2">Add Order</button></form><div class="mt-4 space-y-2 text-sm">{% for o in case.orders %}<div class="rounded-lg bg-slate-50 p-3"><b>{{ o.order_type }}</b> · {{ o.order_date }} · Demand: {{ o.demand_amount }}<br>{{ o.summary or '' }}</div>{% endfor %}</div></section> <section class="rounded-xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><h2 class="font-bold">Orders</h2><form method="post" action="/notice-cases/{{ case.id }}/orders" class="mt-4 grid gap-3 md:grid-cols-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><select name="order_type" class="rounded-lg border px-3 py-2 text-sm">{% for o in order_types %}<option>{{ o }}</option>{% endfor %}</select><input name="order_no" placeholder="Order No" class="rounded-lg border px-3 py-2 text-sm"><input type="date" name="order_date" required class="rounded-lg border px-3 py-2 text-sm"><input type="date" name="appeal_due_date" class="rounded-lg border px-3 py-2 text-sm"><input name="tax_amount" placeholder="Tax" type="number" class="rounded-lg border px-3 py-2 text-sm"><input name="interest_amount" placeholder="Interest" type="number" class="rounded-lg border px-3 py-2 text-sm"><input name="penalty_amount" placeholder="Penalty" type="number" class="rounded-lg border px-3 py-2 text-sm"><input name="demand_amount" placeholder="Total Demand" type="number" class="rounded-lg border px-3 py-2 text-sm"><textarea name="summary" placeholder="Order summary" class="rounded-lg border px-3 py-2 text-sm md:col-span-2"></textarea><label class="text-sm md:col-span-2"><input type="checkbox" name="appeal_filed" value="1"> Appeal filed</label><button class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white md:col-span-2">Add Order</button></form><div class="mt-4 space-y-2 text-sm">{% for o in case.orders %}<div class="rounded-lg bg-slate-50 p-3"><b>{{ o.order_type }}</b> · {{ o.order_date }} · Demand: {{ o.demand_amount }}<br>{{ o.summary or '' }}</div>{% endfor %}</div></section>
</div> </div>
<section class="rounded-xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><div class="flex items-center justify-between"><h2 class="font-bold">Case Documents</h2></div>{% if can_upload %}<form method="post" enctype="multipart/form-data" action="/notice-cases/{{ case.id }}/documents/upload" class="mt-4 grid gap-3 md:grid-cols-5"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input name="title" placeholder="Title" class="rounded-lg border px-3 py-2 text-sm"><select name="document_type" class="rounded-lg border px-3 py-2 text-sm">{% for d in case_document_types %}<option value="{{ d }}">{{ d }}</option>{% endfor %}</select><input name="description" placeholder="Description" class="rounded-lg border px-3 py-2 text-sm"><input type="file" name="file" required class="rounded-lg border px-3 py-2 text-sm"><button class="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Upload</button></form>{% endif %}<div class="mt-4 overflow-hidden rounded-lg border"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50"><tr><th class="px-3 py-2 text-left">Document</th><th class="px-3 py-2">Type</th><th class="px-3 py-2">Uploaded</th><th class="px-3 py-2 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for d in case.documents if not d.is_deleted %}<tr><td class="px-3 py-2"><div class="font-semibold">{{ d.title }}</div><div class="text-xs text-slate-500">{{ d.original_filename }}</div></td><td class="px-3 py-2 text-center">{{ d.document_type }}</td><td class="px-3 py-2 text-center">{{ d.uploaded_at_utc.date() if d.uploaded_at_utc else '' }}</td><td class="px-3 py-2 text-right"><a href="/notice-cases/documents/{{ d.id }}/download" class="font-semibold text-brand-700">Download</a>{% if can_delete_documents %}<form method="post" action="/notice-cases/documents/{{ d.id }}/delete" class="inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ml-3 text-red-600" onclick="return confirm('Delete this document?')">Delete</button></form>{% endif %}</td></tr>{% else %}<tr><td colspan="4" class="px-3 py-6 text-center text-slate-500">No documents uploaded.</td></tr>{% endfor %}</tbody></table></div></section> <section class="rounded-xl bg-white p-5 shadow-sm ring-1 ring-slate-200"><div class="flex items-center justify-between"><h2 class="font-bold">Case Documents</h2></div>{% if upload_error %}<div class="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">{{ upload_error }}</div>{% endif %}{% if can_upload %}<form method="post" enctype="multipart/form-data" action="/notice-cases/{{ case.id }}/documents/upload" class="mt-4 grid gap-3 md:grid-cols-5"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input name="title" placeholder="Title" class="rounded-lg border px-3 py-2 text-sm"><select name="document_type" class="rounded-lg border px-3 py-2 text-sm">{% for d in case_document_types %}<option value="{{ d }}">{{ d }}</option>{% endfor %}</select><input name="description" placeholder="Description" class="rounded-lg border px-3 py-2 text-sm"><input type="file" name="file" required accept="{{ upload_policy.accept if upload_policy else '.pdf,.jpg,.jpeg,.png,.xlsx,.xls,.docx,.zip' }}" class="rounded-lg border px-3 py-2 text-sm"><button class="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Upload</button><p class="text-xs text-slate-500 md:col-span-5">Allowed file types: {{ upload_policy.allowed_ext_csv if upload_policy else 'pdf,jpg,jpeg,png,xlsx,xls,docx,zip' }}. Maximum size: {{ upload_policy.max_file_mb if upload_policy else 25 }} MB.</p></form>{% endif %}<div class="mt-4 overflow-hidden rounded-lg border"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50"><tr><th class="px-3 py-2 text-left">Document</th><th class="px-3 py-2">Type</th><th class="px-3 py-2">Uploaded</th><th class="px-3 py-2 text-right">Action</th></tr></thead><tbody class="divide-y divide-slate-100">{% for d in case.documents if not d.is_deleted %}<tr><td class="px-3 py-2"><div class="font-semibold">{{ d.title }}</div><div class="text-xs text-slate-500">{{ d.original_filename }}</div></td><td class="px-3 py-2 text-center">{{ d.document_type }}</td><td class="px-3 py-2 text-center">{{ d.uploaded_at_utc.date() if d.uploaded_at_utc else '' }}</td><td class="px-3 py-2 text-right"><a href="/notice-cases/documents/{{ d.id }}/download" class="font-semibold text-brand-700">Download</a>{% if can_delete_documents %}<form method="post" action="/notice-cases/documents/{{ d.id }}/delete" class="inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ml-3 text-red-600" onclick="return confirm('Delete this document?')">Delete</button></form>{% endif %}</td></tr>{% else %}<tr><td colspan="4" class="px-3 py-6 text-center text-slate-500">No documents uploaded.</td></tr>{% endfor %}</tbody></table></div></section>
</div> </div>
{% endblock %} {% endblock %}
+35 -3
View File
@@ -36,6 +36,8 @@ from app.modules.notice_cases.service import (
list_clients_for_case, list_clients_for_case,
list_engagements_for_client, list_engagements_for_client,
save_case_document, save_case_document,
notice_case_upload_policy,
UploadValidationError,
update_case, update_case,
user_can_access_case, user_can_access_case,
) )
@@ -72,8 +74,8 @@ def _base_ctx(request: Request, user, db, **ctx):
return base return base
def _render(request: Request, template_name: str, db, user, **ctx): def _render(request: Request, template_name: str, db, user, status_code: int = 200, **ctx):
return templates.TemplateResponse(template_name, _base_ctx(request, user, db, **ctx)) return templates.TemplateResponse(template_name, _base_ctx(request, user, db, **ctx), status_code=status_code)
def _redirect_denied(): def _redirect_denied():
@@ -298,7 +300,19 @@ def case_detail(request: Request, case_id: int):
case = get_case(db, case_id) case = get_case(db, case_id)
if not case or not _case_access_allowed(request, db, user, case): if not case or not _case_access_allowed(request, db, user, case):
return _redirect_denied() return _redirect_denied()
return _render(request, "modules/notice_cases/templates/notice_cases/detail.html", db, user, title=case.case_code, case=case, can_manage=can_manage_notice_cases(db, user), can_upload=can_upload_notice_case_documents(db, user), can_delete_documents=can_delete_notice_case_documents(db, user)) upload_policy = notice_case_upload_policy(db, branch_id=case.branch_id)
return _render(
request,
"modules/notice_cases/templates/notice_cases/detail.html",
db,
user,
title=case.case_code,
case=case,
can_manage=can_manage_notice_cases(db, user),
can_upload=can_upload_notice_case_documents(db, user),
can_delete_documents=can_delete_notice_case_documents(db, user),
upload_policy=upload_policy,
)
finally: finally:
db.close() db.close()
@@ -445,7 +459,25 @@ def case_document_upload(request: Request, case_id: int, title: str = Form(""),
return RedirectResponse(url=f"/notice-cases/{case.id}?year_locked=1", status_code=303) return RedirectResponse(url=f"/notice-cases/{case.id}?year_locked=1", status_code=303)
if not file or not file.filename: if not file or not file.filename:
return RedirectResponse(url=f"/notice-cases/{case_id}?error=missing_file", status_code=303) return RedirectResponse(url=f"/notice-cases/{case_id}?error=missing_file", status_code=303)
try:
save_case_document(db, case=case, upload_file=file, user=user, title=title, document_type=document_type, description=description, event_id=int(event_id) if event_id else None) save_case_document(db, case=case, upload_file=file, user=user, title=title, document_type=document_type, description=description, event_id=int(event_id) if event_id else None)
except UploadValidationError as exc:
db.rollback()
upload_policy = notice_case_upload_policy(db, branch_id=case.branch_id)
return _render(
request,
"modules/notice_cases/templates/notice_cases/detail.html",
db,
user,
status_code=400,
title=case.case_code,
case=case,
can_manage=can_manage_notice_cases(db, user),
can_upload=can_upload_notice_case_documents(db, user),
can_delete_documents=can_delete_notice_case_documents(db, user),
upload_policy=upload_policy,
upload_error=str(exc),
)
db.commit() db.commit()
return RedirectResponse(url=f"/notice-cases/{case_id}?uploaded=1", status_code=303) return RedirectResponse(url=f"/notice-cases/{case_id}?uploaded=1", status_code=303)
finally: finally: