diff --git a/app/modules/email_integration/services.py b/app/modules/email_integration/services.py
index 58537a9..84e2210 100644
--- a/app/modules/email_integration/services.py
+++ b/app/modules/email_integration/services.py
@@ -252,18 +252,33 @@ def get_or_create_email_setting(db: Session, tenant_id: int, branch_id: int | No
).scalar_one_or_none()
if row:
return row
+
+ settings = get_settings()
+
row = EmailSetting(
tenant_id=tenant_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",
- imap_host="imap.hostinger.com",
- imap_port=993,
+ smtp_username=getattr(settings, "SMTP_USERNAME", None) or "no-reply@vavalam.com",
+ 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",
+
created_by_user_id=actor_user_id,
updated_by_user_id=actor_user_id,
)
+
db.add(row)
db.flush()
seed_default_email_templates(db, tenant_id=tenant_id, branch_id=branch_id)
diff --git a/app/modules/employees/templates/employees/documents.html b/app/modules/employees/templates/employees/documents.html
index bf7625f..75eb0fd 100644
--- a/app/modules/employees/templates/employees/documents.html
+++ b/app/modules/employees/templates/employees/documents.html
@@ -14,7 +14,7 @@
-
+
diff --git a/app/modules/employees/templates/employees/self_documents.html b/app/modules/employees/templates/employees/self_documents.html
index e384452..63ba799 100644
--- a/app/modules/employees/templates/employees/self_documents.html
+++ b/app/modules/employees/templates/employees/self_documents.html
@@ -14,7 +14,7 @@
-
+
diff --git a/app/modules/employees/ui.py b/app/modules/employees/ui.py
index e35ff26..6cd840d 100644
--- a/app/modules/employees/ui.py
+++ b/app/modules/employees/ui.py
@@ -6,6 +6,7 @@ from uuid import uuid4
from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import RedirectResponse, StreamingResponse
+from sqlalchemy import select
from app.core.db.common import CommonSessionLocal
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.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.tenancy.settings_models import BranchSettings
from app.modules.core.iam.profile_service import save_user_profile_photo, update_user_public_profile
from app.modules.employees.import_service import (
build_template_workbook,
@@ -173,6 +175,7 @@ def _base_ctx(request: Request, db, current_user, **ctx):
"offboarding_task_statuses": OFFBOARDING_TASK_STATUS,
"payroll_run_statuses": PAYROLL_RUN_STATUS,
"payslip_statuses": PAYSLIP_STATUS,
+ **_upload_policy_context(),
}
base.update(ctx)
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")
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):
@@ -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]:
- original = _safe_upload_filename(upload.filename or "document")
+async def _save_employee_upload(
+ 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()
- if suffix not in ALLOWED_DOCUMENT_EXTENSIONS:
- raise ValueError("Unsupported file type. Allowed: PDF, images, Word and Excel files.")
+ if not suffix:
+ 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()
if not data:
- raise ValueError("Please choose a non-empty file to upload.")
- max_size = 10 * 1024 * 1024
+ raise ValueError("Invalid upload. Please choose a non-empty file to upload.")
+
+ max_mb = max_file_mb or DEFAULT_EMPLOYEE_UPLOAD_MAX_MB
+ max_size = int(max_mb) * 1024 * 1024
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.mkdir(parents=True, exist_ok=True)
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
branch_id = request.session.get("active_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:
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")
scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id)
employee = get_employee_or_404(db, employee_id, scope)
+ allowed_exts, max_file_mb = _branch_upload_settings(db, employee.branch_id)
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(
db,
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)
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:
db.close()
@@ -1412,7 +1488,7 @@ def employee_self_documents(request: Request):
if employee:
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]
- 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:
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)
if not employee:
return RedirectResponse(url="/employee/register", status_code=303)
+ allowed_exts, max_file_mb = _branch_upload_settings(db, employee.branch_id)
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(
db,
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)
except Exception as exc:
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:
db.close()
diff --git a/app/modules/notice_cases/service.py b/app/modules/notice_cases/service.py
index 1518c0e..ead9e79 100644
--- a/app/modules/notice_cases/service.py
+++ b/app/modules/notice_cases/service.py
@@ -10,6 +10,7 @@ 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,
@@ -381,6 +382,75 @@ def add_order(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCaseO
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"
@@ -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:
- 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()
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)
- data = upload_file.file.read()
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(
diff --git a/app/modules/notice_cases/templates/notice_cases/detail.html b/app/modules/notice_cases/templates/notice_cases/detail.html
index 0400e98..ed2db7d 100644
--- a/app/modules/notice_cases/templates/notice_cases/detail.html
+++ b/app/modules/notice_cases/templates/notice_cases/detail.html
@@ -27,6 +27,6 @@
Orders
{% for o in case.orders %}
{{ o.order_type }} · {{ o.order_date }} · Demand: {{ o.demand_amount }}
{{ o.summary or '' }}
{% endfor %}
-
+
{% endblock %}
diff --git a/app/modules/notice_cases/ui.py b/app/modules/notice_cases/ui.py
index 9058955..1f016e3 100644
--- a/app/modules/notice_cases/ui.py
+++ b/app/modules/notice_cases/ui.py
@@ -36,6 +36,8 @@ from app.modules.notice_cases.service import (
list_clients_for_case,
list_engagements_for_client,
save_case_document,
+ notice_case_upload_policy,
+ UploadValidationError,
update_case,
user_can_access_case,
)
@@ -72,8 +74,8 @@ def _base_ctx(request: Request, user, db, **ctx):
return base
-def _render(request: Request, template_name: str, db, user, **ctx):
- return templates.TemplateResponse(template_name, _base_ctx(request, user, db, **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), status_code=status_code)
def _redirect_denied():
@@ -298,7 +300,19 @@ def case_detail(request: Request, case_id: int):
case = get_case(db, case_id)
if not case or not _case_access_allowed(request, db, user, case):
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:
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)
if not file or not file.filename:
return RedirectResponse(url=f"/notice-cases/{case_id}?error=missing_file", status_code=303)
- 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)
+ 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)
+ 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()
return RedirectResponse(url=f"/notice-cases/{case_id}?uploaded=1", status_code=303)
finally: