Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class UserAlert(CommonBase):
|
||||
"""Common role-aware alert table for all dashboards and portals.
|
||||
|
||||
Phase 7H foundation only stores and displays alerts. Later phases can call
|
||||
app.modules.alerts.service.create_alert() from task, document, attendance,
|
||||
client and consultant workflows without changing this schema.
|
||||
"""
|
||||
|
||||
__tablename__ = "user_alerts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
role_context: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||
alert_type: Mapped[str] = mapped_column(String(80), nullable=False, default="general", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal", index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
target_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
read_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True
|
||||
)
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import Select, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.alerts.models import UserAlert
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.email_integration.event_service import send_alert_created_email
|
||||
|
||||
ALERT_PRIORITIES = ("low", "normal", "high", "critical")
|
||||
ALERT_TYPES = (
|
||||
"general",
|
||||
"task_assigned",
|
||||
"task_due",
|
||||
"task_overdue",
|
||||
"task_review",
|
||||
"document_uploaded",
|
||||
"clarification",
|
||||
"attendance",
|
||||
"leave",
|
||||
"payroll",
|
||||
"consultant",
|
||||
"client",
|
||||
)
|
||||
|
||||
|
||||
def normalize_priority(priority: str | None) -> str:
|
||||
value = (priority or "normal").strip().lower()
|
||||
return value if value in ALERT_PRIORITIES else "normal"
|
||||
|
||||
|
||||
def normalize_alert_type(alert_type: str | None) -> str:
|
||||
value = (alert_type or "general").strip().lower()
|
||||
return value or "general"
|
||||
|
||||
|
||||
def create_alert(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message: str | None = None,
|
||||
tenant_id: int | None = None,
|
||||
branch_id: int | None = None,
|
||||
role_context: str | None = None,
|
||||
alert_type: str = "general",
|
||||
priority: str = "normal",
|
||||
target_url: str | None = None,
|
||||
created_by_user_id: int | None = None,
|
||||
commit: bool = True,
|
||||
) -> UserAlert:
|
||||
alert = UserAlert(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
user_id=user_id,
|
||||
role_context=(role_context or None),
|
||||
alert_type=normalize_alert_type(alert_type),
|
||||
priority=normalize_priority(priority),
|
||||
title=(title or "Alert").strip()[:255],
|
||||
message=(message or None),
|
||||
target_url=(target_url or None),
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
db.add(alert)
|
||||
db.flush()
|
||||
try:
|
||||
send_alert_created_email(db, alert)
|
||||
except Exception:
|
||||
# Email notification must never block in-app alert creation.
|
||||
pass
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
return alert
|
||||
|
||||
|
||||
def create_bulk_alerts(
|
||||
db: Session,
|
||||
*,
|
||||
user_ids: Iterable[int],
|
||||
title: str,
|
||||
message: str | None = None,
|
||||
tenant_id: int | None = None,
|
||||
branch_id: int | None = None,
|
||||
role_context: str | None = None,
|
||||
alert_type: str = "general",
|
||||
priority: str = "normal",
|
||||
target_url: str | None = None,
|
||||
created_by_user_id: int | None = None,
|
||||
) -> list[UserAlert]:
|
||||
rows: list[UserAlert] = []
|
||||
for user_id in sorted({int(uid) for uid in user_ids if uid}):
|
||||
rows.append(
|
||||
create_alert(
|
||||
db,
|
||||
user_id=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,
|
||||
created_by_user_id=created_by_user_id,
|
||||
commit=False,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
for row in rows:
|
||||
db.refresh(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _user_alert_query(current_user: User) -> Select:
|
||||
return select(UserAlert).where(UserAlert.user_id == current_user.id)
|
||||
|
||||
|
||||
def list_my_alerts(
|
||||
db: Session,
|
||||
current_user: User,
|
||||
*,
|
||||
status: str = "all",
|
||||
priority: str = "all",
|
||||
limit: int = 100,
|
||||
) -> list[UserAlert]:
|
||||
q = _user_alert_query(current_user)
|
||||
if status == "unread":
|
||||
q = q.where(UserAlert.is_read.is_(False))
|
||||
elif status == "read":
|
||||
q = q.where(UserAlert.is_read.is_(True))
|
||||
if priority in ALERT_PRIORITIES:
|
||||
q = q.where(UserAlert.priority == priority)
|
||||
q = q.order_by(UserAlert.is_read.asc(), UserAlert.created_at_utc.desc()).limit(max(1, min(limit, 500)))
|
||||
return list(db.execute(q).scalars().all())
|
||||
|
||||
|
||||
def count_unread_alerts(db: Session, current_user: User | None) -> int:
|
||||
if not current_user:
|
||||
return 0
|
||||
value = db.execute(
|
||||
select(func.count(UserAlert.id)).where(UserAlert.user_id == current_user.id, UserAlert.is_read.is_(False))
|
||||
).scalar_one()
|
||||
return int(value or 0)
|
||||
|
||||
|
||||
def get_my_alert_or_404(db: Session, current_user: User, alert_id: int) -> UserAlert | None:
|
||||
return db.execute(
|
||||
select(UserAlert).where(UserAlert.id == alert_id, UserAlert.user_id == current_user.id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def mark_alert_read(db: Session, current_user: User, alert_id: int) -> bool:
|
||||
alert = get_my_alert_or_404(db, current_user, alert_id)
|
||||
if not alert:
|
||||
return False
|
||||
if not alert.is_read:
|
||||
alert.is_read = True
|
||||
alert.read_at_utc = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def mark_all_alerts_read(db: Session, current_user: User) -> int:
|
||||
result = db.execute(
|
||||
update(UserAlert)
|
||||
.where(UserAlert.user_id == current_user.id, UserAlert.is_read.is_(False))
|
||||
.values(is_read=True, read_at_utc=datetime.now(timezone.utc))
|
||||
)
|
||||
db.commit()
|
||||
return int(result.rowcount or 0)
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{% set _role_text = (current_user_roles or [])|join('|')|lower %}
|
||||
{% if 'partner' in _role_text %}
|
||||
{% include "modules/partners/templates/partners/_partner_tabs.html" %}
|
||||
{% elif 'manager' in _role_text %}
|
||||
{% include "modules/managers/templates/managers/_manager_tabs.html" %}
|
||||
{% else %}
|
||||
{% include "modules/employees/templates/employees/_my_workspace_tabs.html" %}
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">My Alerts</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Role-wise alerts for tasks, documents, attendance, leave, payroll, client and consultant workflows.</p>
|
||||
</div>
|
||||
<form method="post" action="/alerts/read-all">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" {% if unread_count == 0 %}disabled{% endif %}>Mark all as read</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Unread</div><div class="mt-1 text-2xl font-semibold">{{ unread_count }}</div></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Showing</div><div class="mt-1 text-2xl font-semibold">{{ alerts|length }}</div></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Filter</div><div class="mt-1 text-sm text-slate-600">{{ status.replace('_',' ').title() }} · {{ priority.title() }}</div></div>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/alerts" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 md:grid-cols-[220px_220px_auto]">
|
||||
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>All alerts</option>
|
||||
<option value="unread" {% if status == 'unread' %}selected{% endif %}>Unread only</option>
|
||||
<option value="read" {% if status == 'read' %}selected{% endif %}>Read only</option>
|
||||
</select>
|
||||
<select name="priority" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="all" {% if priority == 'all' %}selected{% endif %}>All priorities</option>
|
||||
{% for p in priorities %}<option value="{{ p }}" {% if priority == p %}selected{% endif %}>{{ p.title() }}</option>{% endfor %}
|
||||
</select>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Apply Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="space-y-3">
|
||||
{% for alert in alerts %}
|
||||
<div class="rounded-2xl border {% if alert.is_read %}border-slate-200 bg-white{% else %}border-brand-100 bg-brand-50{% endif %} p-5 shadow-soft">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-semibold text-slate-900">{{ alert.title }}</h3>
|
||||
<span class="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-slate-600">{{ alert.priority }}</span>
|
||||
{% if not alert.is_read %}<span class="rounded-full bg-brand-600 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white">Unread</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ alert.alert_type.replace('_',' ').title() }}{% if alert.role_context %} · {{ alert.role_context }}{% endif %} · {{ alert.created_at_utc.strftime('%d-%m-%Y %H:%M') if alert.created_at_utc else '-' }}</div>
|
||||
{% if alert.message %}<p class="mt-3 text-sm text-slate-700">{{ alert.message }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
{% if alert.target_url %}<a href="{{ alert.target_url }}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Open</a>{% endif %}
|
||||
{% if not alert.is_read %}
|
||||
<form method="post" action="/alerts/{{ alert.id }}/read">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Mark read</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-8 text-center text-sm text-slate-500 shadow-soft">No alerts found for the selected filter.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
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.alerts.service import ALERT_PRIORITIES, count_unread_alerts, list_my_alerts, mark_alert_read, mark_all_alerts_read
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
|
||||
router = APIRouter(prefix="/alerts", tags=["alerts-ui"])
|
||||
|
||||
|
||||
def _redirect_login():
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
def _base_ctx(request: Request, db, current_user, **ctx):
|
||||
base = {
|
||||
"request": request,
|
||||
"current_user": current_user,
|
||||
"current_user_roles": get_user_roles(db, current_user.id),
|
||||
"current_user_permissions": get_user_permissions(db, current_user.id),
|
||||
"csrf_token": get_or_create_csrf_token(request),
|
||||
}
|
||||
base.update(ctx)
|
||||
return base
|
||||
|
||||
|
||||
@router.get("/poll")
|
||||
def poll_unread_alerts(request: Request, limit: int = 5):
|
||||
"""Lightweight polling endpoint used by the base layout toast popup.
|
||||
|
||||
Returns a small list of unread alerts for the logged-in user. It does not
|
||||
mark alerts as read; the normal /alerts page and existing read actions
|
||||
continue to control read status.
|
||||
"""
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db)
|
||||
if not current_user:
|
||||
return JSONResponse({"authenticated": False, "unread_count": 0, "alerts": []}, status_code=401)
|
||||
|
||||
safe_limit = max(1, min(int(limit or 5), 10))
|
||||
rows = list_my_alerts(db, current_user, status="unread", priority="all", limit=safe_limit)
|
||||
payload = []
|
||||
for row in rows:
|
||||
created_at = getattr(row, "created_at_utc", None)
|
||||
payload.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"title": row.title or "Alert",
|
||||
"message": row.message or "",
|
||||
"priority": row.priority or "normal",
|
||||
"alert_type": row.alert_type or "general",
|
||||
"target_url": row.target_url or "/alerts",
|
||||
"created_at_utc": created_at.isoformat() if created_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"authenticated": True,
|
||||
"unread_count": count_unread_alerts(db, current_user),
|
||||
"alerts": payload,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def alerts_list(request: Request, status: str = "all", priority: str = "all"):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
status = status if status in {"all", "unread", "read"} else "all"
|
||||
priority = priority if priority in ALERT_PRIORITIES else "all"
|
||||
rows = list_my_alerts(db, current_user, status=status, priority=priority, limit=150)
|
||||
return templates.TemplateResponse(
|
||||
"modules/alerts/templates/alerts/list.html",
|
||||
_base_ctx(
|
||||
request,
|
||||
db,
|
||||
current_user,
|
||||
title="My Alerts",
|
||||
alerts=rows,
|
||||
status=status,
|
||||
priority=priority,
|
||||
priorities=ALERT_PRIORITIES,
|
||||
unread_count=count_unread_alerts(db, current_user),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{alert_id}/read")
|
||||
def mark_read(request: Request, alert_id: int, csrf_token: str = Form(...)):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
validate_csrf(request, csrf_token)
|
||||
mark_alert_read(db, current_user, alert_id)
|
||||
return RedirectResponse(url="/alerts", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/read-all")
|
||||
def mark_all_read(request: Request, csrf_token: str = Form(...)):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
current_user = get_current_user(request, db)
|
||||
if not current_user:
|
||||
return _redirect_login()
|
||||
validate_csrf(request, csrf_token)
|
||||
mark_all_alerts_read(db, current_user)
|
||||
return RedirectResponse(url="/alerts", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user