Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+41
View File
@@ -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])
+174
View File
@@ -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 %}
+127
View File
@@ -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()