Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Notification and escalation automation package for Phase 7O."""
|
||||
@@ -0,0 +1,416 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time as dt_time, timedelta, timezone
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db.common import CommonEngine, CommonSessionLocal
|
||||
from app.modules.alerts.models import UserAlert
|
||||
from app.modules.alerts.service import create_alert
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.employees.models import Employee, EmployeeAttendance
|
||||
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance
|
||||
|
||||
logger = logging.getLogger("audit_firm.notifications")
|
||||
|
||||
_COMPLETED_STATUSES = {
|
||||
"completed",
|
||||
"complete",
|
||||
"done",
|
||||
"approved",
|
||||
"closed",
|
||||
"filed",
|
||||
"cancelled",
|
||||
"inactive",
|
||||
}
|
||||
|
||||
_STARTED = False
|
||||
_START_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AutomationResult:
|
||||
task_due_today: int = 0
|
||||
task_overdue_staff: int = 0
|
||||
task_overdue_manager: int = 0
|
||||
task_overdue_partner: int = 0
|
||||
engagement_due: int = 0
|
||||
engagement_overdue: int = 0
|
||||
attendance_missing_punchout: int = 0
|
||||
|
||||
@property
|
||||
def total_alerts(self) -> int:
|
||||
return (
|
||||
self.task_due_today
|
||||
+ self.task_overdue_staff
|
||||
+ self.task_overdue_manager
|
||||
+ self.task_overdue_partner
|
||||
+ self.engagement_due
|
||||
+ self.engagement_overdue
|
||||
+ self.attendance_missing_punchout
|
||||
)
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
try:
|
||||
return CommonEngine.dialect.has_table(CommonEngine.connect(), table_name)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _today_bounds_utc(now_utc: datetime) -> tuple[datetime, datetime]:
|
||||
start = datetime.combine(now_utc.date(), dt_time.min, tzinfo=timezone.utc)
|
||||
end = start + timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
|
||||
def _normalize_status(value: str | None) -> str:
|
||||
return (value or "").strip().lower().replace(" ", "_")
|
||||
|
||||
|
||||
def _is_open_status(value: str | None) -> bool:
|
||||
return _normalize_status(value) not in _COMPLETED_STATUSES
|
||||
|
||||
|
||||
def _alert_exists_today(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int,
|
||||
alert_type: str,
|
||||
title: str,
|
||||
target_url: str | None,
|
||||
today_start_utc: datetime,
|
||||
) -> bool:
|
||||
q = select(UserAlert.id).where(
|
||||
UserAlert.user_id == user_id,
|
||||
UserAlert.alert_type == alert_type,
|
||||
UserAlert.title == title[:255],
|
||||
UserAlert.created_at_utc >= today_start_utc,
|
||||
)
|
||||
if target_url:
|
||||
q = q.where(UserAlert.target_url == target_url)
|
||||
else:
|
||||
q = q.where(UserAlert.target_url.is_(None))
|
||||
return db.execute(q.limit(1)).scalar_one_or_none() is not None
|
||||
|
||||
|
||||
def _create_alert_once(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int | None,
|
||||
title: str,
|
||||
message: str | None,
|
||||
tenant_id: int | None,
|
||||
branch_id: int | None,
|
||||
role_context: str | None,
|
||||
alert_type: str,
|
||||
priority: str,
|
||||
target_url: str | None,
|
||||
today_start_utc: datetime,
|
||||
) -> bool:
|
||||
if not user_id:
|
||||
return False
|
||||
if _alert_exists_today(
|
||||
db,
|
||||
user_id=int(user_id),
|
||||
alert_type=alert_type,
|
||||
title=title,
|
||||
target_url=target_url,
|
||||
today_start_utc=today_start_utc,
|
||||
):
|
||||
return False
|
||||
create_alert(
|
||||
db,
|
||||
user_id=int(user_id),
|
||||
title=title,
|
||||
message=message,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
role_context=role_context,
|
||||
alert_type=alert_type,
|
||||
priority=priority,
|
||||
target_url=target_url,
|
||||
commit=False,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _service_label(subscription: ClientServiceSubscription | None, client: Client | None = None) -> str:
|
||||
if not subscription:
|
||||
return "work item"
|
||||
service_name = getattr(getattr(subscription, "catalogue", None), "service_name", None)
|
||||
client_name = getattr(client or getattr(subscription, "client", None), "client_name", None)
|
||||
period = getattr(subscription, "financial_year", None) or getattr(subscription, "assessment_year", None)
|
||||
parts = [p for p in [service_name, client_name, period] if p]
|
||||
return " - ".join(parts) if parts else f"Engagement #{subscription.id}"
|
||||
|
||||
|
||||
def run_notification_escalation_once(*, db: Session | None = None, now: datetime | None = None) -> AutomationResult:
|
||||
"""Create daily alerts for due work, overdue work and basic escalations.
|
||||
|
||||
This function is intentionally idempotent for the current UTC day. It checks
|
||||
the existing user_alerts table before inserting, so repeated scheduler runs
|
||||
do not flood users with duplicate alerts.
|
||||
"""
|
||||
|
||||
owns_session = db is None
|
||||
session = db or CommonSessionLocal()
|
||||
now_utc = now.astimezone(timezone.utc) if now else datetime.now(timezone.utc)
|
||||
today = now_utc.date()
|
||||
today_start_utc, _ = _today_bounds_utc(now_utc)
|
||||
result = AutomationResult()
|
||||
|
||||
try:
|
||||
if not _table_exists("user_alerts"):
|
||||
logger.warning("Phase 7O skipped: user_alerts table is not available. Apply Phase 7H first.")
|
||||
return result
|
||||
|
||||
# 1) Staff task due today and overdue alerts.
|
||||
task_rows = session.execute(
|
||||
select(ClientServiceTaskInstance, ClientServiceSubscription, Client)
|
||||
.join(ClientServiceSubscription, ClientServiceTaskInstance.subscription_id == ClientServiceSubscription.id)
|
||||
.join(Client, ClientServiceTaskInstance.client_id == Client.id)
|
||||
.where(
|
||||
ClientServiceTaskInstance.is_active.is_(True),
|
||||
ClientServiceTaskInstance.assigned_to_user_id.is_not(None),
|
||||
ClientServiceTaskInstance.internal_target_date.is_not(None),
|
||||
ClientServiceTaskInstance.internal_target_date <= today,
|
||||
)
|
||||
.order_by(ClientServiceTaskInstance.internal_target_date.asc(), ClientServiceTaskInstance.id.asc())
|
||||
).all()
|
||||
|
||||
for task, subscription, client in task_rows:
|
||||
if not _is_open_status(task.status):
|
||||
continue
|
||||
target_url = f"/work/engagements/{subscription.id}"
|
||||
label = _service_label(subscription, client)
|
||||
task_due = task.internal_target_date
|
||||
if task_due == today:
|
||||
title = f"Task due today: {task.task_name}"
|
||||
if _create_alert_once(
|
||||
session,
|
||||
user_id=task.assigned_to_user_id,
|
||||
title=title,
|
||||
message=f"{label} has a task due today. Please open the work details and update the task status.",
|
||||
tenant_id=task.tenant_id,
|
||||
branch_id=task.branch_id,
|
||||
role_context="staff",
|
||||
alert_type="task_due",
|
||||
priority="high" if task.priority in {"high", "critical"} else "normal",
|
||||
target_url=target_url,
|
||||
today_start_utc=today_start_utc,
|
||||
):
|
||||
result.task_due_today += 1
|
||||
continue
|
||||
|
||||
overdue_days = max(1, (today - task_due).days)
|
||||
title = f"Task overdue: {task.task_name}"
|
||||
if _create_alert_once(
|
||||
session,
|
||||
user_id=task.assigned_to_user_id,
|
||||
title=title,
|
||||
message=f"{label} is overdue by {overdue_days} day(s). Please complete or update the task status.",
|
||||
tenant_id=task.tenant_id,
|
||||
branch_id=task.branch_id,
|
||||
role_context="staff",
|
||||
alert_type="task_overdue",
|
||||
priority="critical" if overdue_days >= 3 else "high",
|
||||
target_url=target_url,
|
||||
today_start_utc=today_start_utc,
|
||||
):
|
||||
result.task_overdue_staff += 1
|
||||
|
||||
if subscription.assigned_manager_user_id and overdue_days >= 1:
|
||||
if _create_alert_once(
|
||||
session,
|
||||
user_id=subscription.assigned_manager_user_id,
|
||||
title=f"Team task overdue: {task.task_name}",
|
||||
message=f"{label} has an overdue task assigned to staff. Overdue by {overdue_days} day(s).",
|
||||
tenant_id=task.tenant_id,
|
||||
branch_id=task.branch_id,
|
||||
role_context="manager",
|
||||
alert_type="task_overdue",
|
||||
priority="high",
|
||||
target_url=target_url,
|
||||
today_start_utc=today_start_utc,
|
||||
):
|
||||
result.task_overdue_manager += 1
|
||||
|
||||
partner_user_id = subscription.assigned_partner_user_id or subscription.review_partner_user_id
|
||||
if partner_user_id and overdue_days >= 3:
|
||||
if _create_alert_once(
|
||||
session,
|
||||
user_id=partner_user_id,
|
||||
title=f"Escalation: task overdue for {overdue_days} days",
|
||||
message=f"{label} has a task pending beyond escalation threshold: {task.task_name}.",
|
||||
tenant_id=task.tenant_id,
|
||||
branch_id=task.branch_id,
|
||||
role_context="partner",
|
||||
alert_type="task_overdue",
|
||||
priority="critical",
|
||||
target_url=target_url,
|
||||
today_start_utc=today_start_utc,
|
||||
):
|
||||
result.task_overdue_partner += 1
|
||||
|
||||
# 2) Engagement/service due and overdue alerts for responsible users and client portal user.
|
||||
sub_rows = session.execute(
|
||||
select(ClientServiceSubscription, Client)
|
||||
.join(Client, ClientServiceSubscription.client_id == Client.id)
|
||||
.where(
|
||||
ClientServiceSubscription.is_active.is_(True),
|
||||
ClientServiceSubscription.current_due_date.is_not(None),
|
||||
ClientServiceSubscription.current_due_date <= today + timedelta(days=2),
|
||||
)
|
||||
.order_by(ClientServiceSubscription.current_due_date.asc(), ClientServiceSubscription.id.asc())
|
||||
).all()
|
||||
|
||||
for subscription, client in sub_rows:
|
||||
if not _is_open_status(subscription.status):
|
||||
continue
|
||||
due_date = subscription.current_due_date
|
||||
target_url = f"/work/engagements/{subscription.id}"
|
||||
label = _service_label(subscription, client)
|
||||
recipient_map = [
|
||||
(subscription.assigned_staff_user_id, "staff"),
|
||||
(subscription.assigned_manager_user_id, "manager"),
|
||||
(subscription.assigned_partner_user_id or subscription.review_partner_user_id, "partner"),
|
||||
(getattr(client, "portal_user_id", None), "client"),
|
||||
]
|
||||
seen: set[int] = set()
|
||||
if due_date < today:
|
||||
overdue_days = (today - due_date).days
|
||||
title = f"Engagement overdue: {label}"
|
||||
message = f"Due date was {due_date.isoformat()} and is overdue by {overdue_days} day(s)."
|
||||
alert_type = "task_overdue"
|
||||
priority = "critical" if overdue_days >= 3 else "high"
|
||||
else:
|
||||
days_left = (due_date - today).days
|
||||
title = f"Due date approaching: {label}"
|
||||
message = "Due today." if days_left == 0 else f"Due in {days_left} day(s), on {due_date.isoformat()}."
|
||||
alert_type = "task_due"
|
||||
priority = "high" if days_left == 0 else "normal"
|
||||
for user_id, role_context in recipient_map:
|
||||
if not user_id or int(user_id) in seen:
|
||||
continue
|
||||
seen.add(int(user_id))
|
||||
if _create_alert_once(
|
||||
session,
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
tenant_id=subscription.tenant_id,
|
||||
branch_id=subscription.branch_id,
|
||||
role_context=role_context,
|
||||
alert_type=alert_type,
|
||||
priority=priority,
|
||||
target_url=target_url,
|
||||
today_start_utc=today_start_utc,
|
||||
):
|
||||
if due_date < today:
|
||||
result.engagement_overdue += 1
|
||||
else:
|
||||
result.engagement_due += 1
|
||||
|
||||
# 3) Missing punch-out reminders from yesterday.
|
||||
yesterday = today - timedelta(days=1)
|
||||
attendance_rows = session.execute(
|
||||
select(EmployeeAttendance, Employee)
|
||||
.join(Employee, EmployeeAttendance.employee_id == Employee.id)
|
||||
.where(
|
||||
EmployeeAttendance.attendance_date == yesterday,
|
||||
EmployeeAttendance.punch_in_utc.is_not(None),
|
||||
EmployeeAttendance.punch_out_utc.is_(None),
|
||||
Employee.user_id.is_not(None),
|
||||
)
|
||||
).all()
|
||||
for attendance, employee in attendance_rows:
|
||||
title = "Attendance punch-out missing"
|
||||
target_url = "/employee/attendance"
|
||||
if _create_alert_once(
|
||||
session,
|
||||
user_id=employee.user_id,
|
||||
title=title,
|
||||
message=f"Punch-out is missing for {yesterday.isoformat()}. Please regularise or contact your manager.",
|
||||
tenant_id=attendance.tenant_id,
|
||||
branch_id=attendance.branch_id,
|
||||
role_context="staff",
|
||||
alert_type="attendance",
|
||||
priority="normal",
|
||||
target_url=target_url,
|
||||
today_start_utc=today_start_utc,
|
||||
):
|
||||
result.attendance_missing_punchout += 1
|
||||
if employee.reporting_manager_user_id:
|
||||
if _create_alert_once(
|
||||
session,
|
||||
user_id=employee.reporting_manager_user_id,
|
||||
title=f"Team attendance punch-out missing: {employee.full_name}",
|
||||
message=f"{employee.full_name} has no punch-out for {yesterday.isoformat()}.",
|
||||
tenant_id=attendance.tenant_id,
|
||||
branch_id=attendance.branch_id,
|
||||
role_context="manager",
|
||||
alert_type="attendance",
|
||||
priority="normal",
|
||||
target_url="/employees/attendance",
|
||||
today_start_utc=today_start_utc,
|
||||
):
|
||||
result.attendance_missing_punchout += 1
|
||||
|
||||
session.commit()
|
||||
logger.info("Phase 7O notification automation completed: %s alert(s) created", result.total_alerts)
|
||||
return result
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.exception("Phase 7O notification automation failed")
|
||||
return result
|
||||
finally:
|
||||
if owns_session:
|
||||
session.close()
|
||||
|
||||
|
||||
def _scheduler_loop(interval_seconds: int, initial_delay_seconds: int) -> None:
|
||||
if initial_delay_seconds > 0:
|
||||
time.sleep(initial_delay_seconds)
|
||||
while True:
|
||||
run_notification_escalation_once()
|
||||
time.sleep(interval_seconds)
|
||||
|
||||
|
||||
def start_notification_scheduler() -> None:
|
||||
"""Start the lightweight Phase 7O background scheduler once per process.
|
||||
|
||||
Environment variables:
|
||||
AF_ALERT_AUTOMATION_ENABLED=false disables the scheduler
|
||||
AF_ALERT_AUTOMATION_INTERVAL_SECONDS=3600 controls repeat interval
|
||||
AF_ALERT_AUTOMATION_INITIAL_DELAY_SECONDS=20 controls first run delay
|
||||
"""
|
||||
|
||||
global _STARTED
|
||||
enabled = (os.getenv("AF_ALERT_AUTOMATION_ENABLED", "true") or "true").strip().lower()
|
||||
if enabled in {"0", "false", "no", "off"}:
|
||||
logger.info("Phase 7O notification automation disabled by environment setting")
|
||||
return
|
||||
|
||||
with _START_LOCK:
|
||||
if _STARTED:
|
||||
return
|
||||
interval = int(os.getenv("AF_ALERT_AUTOMATION_INTERVAL_SECONDS", "3600") or "3600")
|
||||
initial_delay = int(os.getenv("AF_ALERT_AUTOMATION_INITIAL_DELAY_SECONDS", "20") or "20")
|
||||
interval = max(300, interval)
|
||||
initial_delay = max(0, initial_delay)
|
||||
thread = threading.Thread(
|
||||
target=_scheduler_loop,
|
||||
args=(interval, initial_delay),
|
||||
name="phase7o-notification-escalation",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
_STARTED = True
|
||||
logger.info("Phase 7O notification automation started with interval=%s seconds", interval)
|
||||
Reference in New Issue
Block a user