Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,532 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
||||
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.iam.models import User
|
||||
from app.modules.core.rbac.permission_guard import require_permission
|
||||
from app.modules.services.execution import (
|
||||
TASK_COMMENT_TYPES,
|
||||
TASK_COMMENT_VISIBILITIES,
|
||||
TASK_PRIORITIES,
|
||||
TASK_STATUSES,
|
||||
add_task_comment,
|
||||
apply_bulk_task_update,
|
||||
apply_task_update,
|
||||
dashboard_stats,
|
||||
generate_tasks_for_subscription,
|
||||
get_subscription_for_execution,
|
||||
get_task,
|
||||
get_tasks_for_bulk_update,
|
||||
list_assignees_for_execution,
|
||||
list_task_comments,
|
||||
list_subscription_execution_payload,
|
||||
list_tasks_payload,
|
||||
parse_date_value,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/services/work-tracker", tags=["services-work-tracker-ui"])
|
||||
|
||||
|
||||
def _base_ctx(request: Request, user, db, **ctx):
|
||||
base = {
|
||||
"request": request,
|
||||
"current_user": user,
|
||||
"current_user_roles": get_user_roles(db, user.id),
|
||||
"current_user_permissions": get_user_permissions(db, user.id),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
"task_statuses": TASK_STATUSES,
|
||||
"task_priorities": TASK_PRIORITIES,
|
||||
"task_comment_types": TASK_COMMENT_TYPES,
|
||||
"task_comment_visibilities": TASK_COMMENT_VISIBILITIES,
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
def _render(request: Request, template: str, db, user, **ctx):
|
||||
return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx))
|
||||
|
||||
|
||||
def _redirect_denied():
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
|
||||
def _has_perm(db, user, code: str) -> bool:
|
||||
try:
|
||||
require_permission(db, user, code)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
def _role_names(db, user) -> set[str]:
|
||||
return set(get_user_roles(db, user.id))
|
||||
|
||||
|
||||
def _is_firm_admin(db, user) -> bool:
|
||||
return "Firm Admin" in _role_names(db, user)
|
||||
|
||||
|
||||
def _is_partner(db, user) -> bool:
|
||||
return "Partner" in _role_names(db, user)
|
||||
|
||||
|
||||
def _is_staff(db, user) -> bool:
|
||||
return "Staff" in _role_names(db, user)
|
||||
|
||||
|
||||
def _can_bulk_manage_tasks(db, user) -> bool:
|
||||
# Operational bulk task management is intentionally limited to Firm Admin and Partner.
|
||||
# Firm Admin keeps the normal edit-permission gate.
|
||||
# Partner is allowed with service_tasks.view because partner visibility is enforced again
|
||||
# on every submitted task id in get_tasks_for_bulk_update(..., partner_user_id=user.id).
|
||||
if _is_firm_admin(db, user):
|
||||
return _has_perm(db, user, "service_tasks.edit")
|
||||
if _is_partner(db, user):
|
||||
return _has_perm(db, user, "service_tasks.view")
|
||||
return False
|
||||
|
||||
|
||||
def _can_assign_staff(db, user) -> bool:
|
||||
return _can_bulk_manage_tasks(db, user)
|
||||
|
||||
|
||||
def _partner_visibility_user_id(db, user) -> int | None:
|
||||
return int(user.id) if _is_partner(db, user) else None
|
||||
|
||||
|
||||
def _staff_own_task_user_id(db, user) -> int | None:
|
||||
return int(user.id) if _is_staff(db, user) else None
|
||||
|
||||
|
||||
def _can_staff_update_own_task(db, user, task) -> bool:
|
||||
return _is_staff(db, user) and int(task.assigned_to_user_id or 0) == int(user.id) and _has_perm(db, user, "service_tasks.view")
|
||||
|
||||
|
||||
def _can_update_task_status(db, user, task) -> bool:
|
||||
return _can_bulk_manage_tasks(db, user) or _can_staff_update_own_task(db, user, task)
|
||||
|
||||
|
||||
def _allowed_comment_type_codes(db, user) -> set[str]:
|
||||
if _can_bulk_manage_tasks(db, user):
|
||||
return {"internal_note", "client_clarification", "consultant_clarification", "partner_review_note"}
|
||||
if _is_staff(db, user):
|
||||
# Staff may communicate through consultants for clients where consultant is the communication channel.
|
||||
return {"internal_note", "client_clarification", "consultant_clarification"}
|
||||
return set()
|
||||
|
||||
|
||||
def _can_add_task_comment(db, user, task) -> bool:
|
||||
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
|
||||
return False
|
||||
return _can_update_task_status(db, user, task)
|
||||
|
||||
|
||||
def _comment_type_options_for_user(db, user):
|
||||
allowed = _allowed_comment_type_codes(db, user)
|
||||
return [(code, label) for code, label in TASK_COMMENT_TYPES if code in allowed]
|
||||
|
||||
|
||||
def _resolve_allowed_assignee_id(db, *, tenant_id: int, branch_id: int | None, assigned_to_user_id: int | None) -> int | None:
|
||||
if assigned_to_user_id is None:
|
||||
return None
|
||||
query = select(User.id).where(User.id == assigned_to_user_id, User.tenant_id == tenant_id, User.is_active.is_(True))
|
||||
if branch_id:
|
||||
query = query.where((User.branch_id == branch_id) | (User.branch_id.is_(None)))
|
||||
return assigned_to_user_id if db.execute(query).first() else None
|
||||
|
||||
def _active_tenant_id(request: Request, user) -> int:
|
||||
return int(
|
||||
request.session.get("active_tenant_id")
|
||||
or request.session.get("selected_tenant_id")
|
||||
or request.session.get("tenant_id")
|
||||
or user.tenant_id
|
||||
)
|
||||
|
||||
|
||||
def _active_branch_id(request: Request, user, db) -> int | None:
|
||||
value = request.session.get("active_branch_id")
|
||||
if value in (None, "", 0, "0"):
|
||||
if _has_perm(db, user, "clients.cross_branch"):
|
||||
return None
|
||||
return int(getattr(user, "branch_id", 0) or 0) or None
|
||||
return int(value)
|
||||
|
||||
|
||||
def _active_financial_year(request: Request) -> str | None:
|
||||
value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
|
||||
value = (value or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _assigned_user_filter(db, user) -> int | None:
|
||||
# Staff users see/update only their own assigned tasks. Partner visibility is handled separately.
|
||||
return _staff_own_task_user_id(db, user)
|
||||
|
||||
|
||||
def _can_manage_execution(db, user) -> bool:
|
||||
return _can_bulk_manage_tasks(db, user)
|
||||
|
||||
|
||||
def _safe_int_list(values: list[str] | None) -> list[int]:
|
||||
ids: list[int] = []
|
||||
for value in values or []:
|
||||
try:
|
||||
ids.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return ids
|
||||
|
||||
|
||||
@router.get("")
|
||||
def execution_dashboard(request: Request, q: str = "", status: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "service_tasks.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
assigned_to_user_id = _assigned_user_filter(db, user)
|
||||
partner_user_id = _partner_visibility_user_id(db, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
stats = dashboard_stats(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=assigned_to_user_id,
|
||||
partner_user_id=partner_user_id,
|
||||
financial_year=financial_year,
|
||||
)
|
||||
tasks = list_tasks_payload(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=assigned_to_user_id,
|
||||
partner_user_id=partner_user_id,
|
||||
status=status,
|
||||
q=q,
|
||||
financial_year=financial_year,
|
||||
)
|
||||
assignees = list_assignees_for_execution(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/work_tracker/dashboard.html",
|
||||
db,
|
||||
user,
|
||||
title="Work Tracker Dashboard",
|
||||
stats=stats,
|
||||
tasks=tasks,
|
||||
q=q,
|
||||
status=status,
|
||||
assignees=assignees,
|
||||
can_manage=_can_manage_execution(db, user),
|
||||
can_bulk_manage=_can_bulk_manage_tasks(db, user),
|
||||
can_assign_staff=_can_assign_staff(db, user),
|
||||
financial_year=financial_year,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/bulk-update")
|
||||
def task_bulk_update_submit(
|
||||
request: Request,
|
||||
task_ids: list[str] = Form(default=[]),
|
||||
bulk_status: str = Form(""),
|
||||
bulk_assigned_to_user_id: str = Form("__no_change__"),
|
||||
bulk_internal_target_date: str = Form(""),
|
||||
update_internal_target_date: str | None = Form(None),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not _can_bulk_manage_tasks(db, user):
|
||||
return _redirect_denied()
|
||||
|
||||
ids = _safe_int_list(task_ids)
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
partner_user_id = _partner_visibility_user_id(db, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
tasks = get_tasks_for_bulk_update(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
task_ids=ids,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=None,
|
||||
partner_user_id=partner_user_id,
|
||||
financial_year=financial_year,
|
||||
)
|
||||
update_assignee = bulk_assigned_to_user_id != "__no_change__"
|
||||
assigned_to_user_id = None
|
||||
if update_assignee and not _can_assign_staff(db, user):
|
||||
return _redirect_denied()
|
||||
if update_assignee and bulk_assigned_to_user_id.strip():
|
||||
try:
|
||||
requested_assignee_id = int(bulk_assigned_to_user_id)
|
||||
except ValueError:
|
||||
return _redirect_denied()
|
||||
assigned_to_user_id = _resolve_allowed_assignee_id(
|
||||
db, tenant_id=tenant_id, branch_id=branch_id, assigned_to_user_id=requested_assignee_id
|
||||
)
|
||||
if assigned_to_user_id != requested_assignee_id:
|
||||
return _redirect_denied()
|
||||
target_date = parse_date_value(bulk_internal_target_date) if update_internal_target_date is not None else None
|
||||
apply_bulk_task_update(
|
||||
tasks,
|
||||
status=bulk_status.strip() or None,
|
||||
assigned_to_user_id=assigned_to_user_id,
|
||||
update_assignee=update_assignee,
|
||||
internal_target_date=target_date,
|
||||
update_internal_target_date=update_internal_target_date is not None,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/subscriptions")
|
||||
def subscription_execution_list(request: Request, q: str = ""):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "clients.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
financial_year = _active_financial_year(request)
|
||||
rows = list_subscription_execution_payload(db, tenant_id=tenant_id, branch_id=branch_id, financial_year=financial_year, q=q)
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/work_tracker/subscriptions.html",
|
||||
db,
|
||||
user,
|
||||
title="Generate Service Tasks",
|
||||
rows=rows,
|
||||
q=q,
|
||||
can_generate=_has_perm(db, user, "service_tasks.create"),
|
||||
financial_year=financial_year,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/subscriptions/{subscription_id}/generate")
|
||||
def generate_subscription_tasks(request: Request, subscription_id: int, csrf_token: str = Form(...)):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "service_tasks.create")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
financial_year = _active_financial_year(request)
|
||||
subscription = get_subscription_for_execution(db, tenant_id=tenant_id, subscription_id=subscription_id)
|
||||
if subscription and financial_year and subscription.financial_year != financial_year:
|
||||
return RedirectResponse(url="/services/work-tracker/subscriptions", status_code=303)
|
||||
if not subscription or not subscription.is_active or subscription.status != "active":
|
||||
return RedirectResponse(url="/services/work-tracker/subscriptions", status_code=303)
|
||||
|
||||
generate_tasks_for_subscription(db, subscription=subscription, user_id=user.id)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/edit")
|
||||
def task_edit_page(request: Request, task_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
try:
|
||||
require_permission(db, user, "service_tasks.view")
|
||||
except Exception:
|
||||
return _redirect_denied()
|
||||
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
task = get_task(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=_assigned_user_filter(db, user),
|
||||
partner_user_id=_partner_visibility_user_id(db, user),
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
if not task:
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
|
||||
assignees = list_assignees_for_execution(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||
comments = list_task_comments(db, tenant_id=tenant_id, task_id=task.id)
|
||||
return _render(
|
||||
request,
|
||||
"modules/services/templates/services/work_tracker/task_form.html",
|
||||
db,
|
||||
user,
|
||||
title="Update Service Task",
|
||||
task=task,
|
||||
assignees=assignees,
|
||||
comments=comments,
|
||||
comment_type_options=_comment_type_options_for_user(db, user),
|
||||
can_add_comment=_can_add_task_comment(db, user, task),
|
||||
can_edit=_can_update_task_status(db, user, task),
|
||||
can_manage_fields=_can_bulk_manage_tasks(db, user),
|
||||
can_reassign=_can_assign_staff(db, user),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/edit")
|
||||
def task_edit_submit(
|
||||
request: Request,
|
||||
task_id: int,
|
||||
status: str = Form("pending"),
|
||||
priority: str = Form("normal"),
|
||||
assigned_to_user_id: str = Form(""),
|
||||
internal_target_date: str = Form(""),
|
||||
remarks: str = Form(""),
|
||||
is_active: str | None = Form(None),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
task = get_task(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=_assigned_user_filter(db, user),
|
||||
partner_user_id=_partner_visibility_user_id(db, user),
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
if not task:
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
if not _can_update_task_status(db, user, task):
|
||||
return _redirect_denied()
|
||||
|
||||
can_manage_fields = _can_bulk_manage_tasks(db, user)
|
||||
if can_manage_fields:
|
||||
if assigned_to_user_id.strip():
|
||||
try:
|
||||
requested_assignee_id = int(assigned_to_user_id)
|
||||
except ValueError:
|
||||
return _redirect_denied()
|
||||
resolved_assignee = _resolve_allowed_assignee_id(
|
||||
db, tenant_id=tenant_id, branch_id=branch_id, assigned_to_user_id=requested_assignee_id
|
||||
)
|
||||
if resolved_assignee != requested_assignee_id:
|
||||
return _redirect_denied()
|
||||
else:
|
||||
resolved_assignee = None
|
||||
resolved_priority = priority
|
||||
resolved_internal_target_date = parse_date_value(internal_target_date)
|
||||
resolved_is_active = is_active is not None
|
||||
else:
|
||||
# Staff can update only status and work note for their own task.
|
||||
resolved_assignee = task.assigned_to_user_id
|
||||
resolved_priority = task.priority
|
||||
resolved_internal_target_date = task.internal_target_date
|
||||
resolved_is_active = task.is_active
|
||||
|
||||
apply_task_update(
|
||||
task,
|
||||
status=status,
|
||||
priority=resolved_priority,
|
||||
assigned_to_user_id=resolved_assignee,
|
||||
internal_target_date=resolved_internal_target_date,
|
||||
remarks=remarks,
|
||||
is_active=resolved_is_active,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/comments")
|
||||
def task_comment_submit(
|
||||
request: Request,
|
||||
task_id: int,
|
||||
comment_type: str = Form("internal_note"),
|
||||
visibility: str = Form("internal"),
|
||||
message: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
tenant_id = _active_tenant_id(request, user)
|
||||
branch_id = _active_branch_id(request, user, db)
|
||||
task = get_task(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
branch_id=branch_id,
|
||||
assigned_to_user_id=_assigned_user_filter(db, user),
|
||||
partner_user_id=_partner_visibility_user_id(db, user),
|
||||
financial_year=_active_financial_year(request),
|
||||
)
|
||||
if not task:
|
||||
return RedirectResponse(url="/services/work-tracker", status_code=303)
|
||||
if not _can_add_task_comment(db, user, task):
|
||||
return _redirect_denied()
|
||||
if comment_type not in _allowed_comment_type_codes(db, user):
|
||||
return _redirect_denied()
|
||||
add_task_comment(
|
||||
db,
|
||||
task=task,
|
||||
comment_type=comment_type,
|
||||
visibility=visibility,
|
||||
message=message,
|
||||
user_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/services/work-tracker/tasks/{task_id}/edit", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user