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
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
class AuditLog(CommonBase):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True, nullable=True)
actor_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
actor_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
actor_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
action: Mapped[str] = mapped_column(String(120), index=True)
entity_type: Mapped[str] = mapped_column(String(120), index=True)
entity_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
entity_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(String(30), default="success", index=True)
target_tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
target_branch_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
ip_address: Mapped[str | None] = mapped_column(String(100), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
details_json: Mapped[str] = mapped_column(Text, default="{}")
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
from fastapi import Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.core.audit.models import AuditLog
from app.modules.core.iam.models import User
from app.modules.core.iam.scope import UserScope
def _safe_json(value: Any) -> str:
return json.dumps(value or {}, ensure_ascii=False, default=_json_default, sort_keys=True)
def _json_default(value: Any):
if isinstance(value, datetime):
return value.isoformat()
if hasattr(value, "isoformat"):
try:
return value.isoformat()
except Exception:
pass
return str(value)
def _request_meta(request: Request | None) -> tuple[str | None, str | None]:
if not request:
return None, None
ip = request.client.host if request.client else None
user_agent = request.headers.get("user-agent")
return ip, user_agent
def write_audit_log(
db: Session,
*,
action: str,
entity_type: str,
actor: User | None = None,
request: Request | None = None,
entity_id: str | int | None = None,
entity_name: str | None = None,
status: str = "success",
target_tenant_id: int | None = None,
target_branch_id: int | None = None,
details: dict[str, Any] | None = None,
actor_email: str | None = None,
) -> AuditLog:
ip_address, user_agent = _request_meta(request)
log = AuditLog(
actor_user_id=actor.id if actor else None,
actor_email=(actor.email if actor else actor_email),
actor_tenant_id=(actor.tenant_id if actor else None),
actor_branch_id=(actor.branch_id if actor else None),
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id is not None else None,
entity_name=entity_name,
status=status,
target_tenant_id=target_tenant_id,
target_branch_id=target_branch_id,
ip_address=ip_address,
user_agent=user_agent,
details_json=_safe_json(details),
)
db.add(log)
db.commit()
db.refresh(log)
return log
def model_snapshot(obj: Any, fields: list[str]) -> dict[str, Any]:
return {field: getattr(obj, field, None) for field in fields}
def pair_before_after(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
return {"before": before, "after": after}
def list_audit_logs(db: Session, scope: UserScope, limit: int = 200) -> list[AuditLog]:
q = select(AuditLog)
if not scope.is_system_admin:
q = q.where(AuditLog.target_tenant_id == scope.actor.tenant_id)
if scope.branch_scoped:
q = q.where(AuditLog.target_branch_id == scope.actor.branch_id)
return db.execute(q.order_by(AuditLog.created_at_utc.desc(), AuditLog.id.desc()).limit(limit)).scalars().all()
def parse_details(log: AuditLog) -> dict[str, Any]:
try:
return json.loads(log.details_json or "{}")
except Exception:
return {"raw": log.details_json}
def search_audit_logs(db: Session, scope: UserScope, q: str | None = None) -> list[AuditLog]:
rows = list_audit_logs(db, scope, limit=1000)
query = (q or "").strip().lower()
if not query:
return rows
result = []
for row in rows:
hay = " ".join([str(row.action or ""), str(row.entity_type or ""), str(row.entity_name or ""), str(row.actor_email or ""), str(row.details_json or "")]).lower()
if query in hay:
result.append(row)
return result
@@ -0,0 +1,14 @@
{% extends "ui/templates/base/layout.html" %}
{% import "ui/templates/components/macros.html" as ui %}
{% block content %}
<div class="space-y-6">
{{ ui.page_shell('Audit Logs', 'Latest security and admin changes captured from IAM, RBAC, login, audit firm and branch operations.') }}
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
{{ ui.search_bar('/system-settings/audit-logs', filters.q, filters.per_page) }}
{% if logs %}
<div class="overflow-x-auto"><table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50 text-slate-600"><tr><th class="px-4 py-3 text-left font-semibold">When</th><th class="px-4 py-3 text-left font-semibold">Action</th><th class="px-4 py-3 text-left font-semibold">Entity</th><th class="px-4 py-3 text-left font-semibold">Actor</th><th class="px-4 py-3 text-left font-semibold">Target Scope</th><th class="px-4 py-3 text-left font-semibold">Details</th><th class="px-4 py-3 text-left font-semibold">Status</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in logs %}<tr class="align-top"><td class="px-4 py-3 whitespace-nowrap text-slate-600">{{ row.created_at_utc }}</td><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.action }}</div><div class="text-xs text-slate-500">IP {{ row.ip_address or '-' }}</div></td><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.entity_type }}</div><div class="text-xs text-slate-500">{{ row.entity_name or row.entity_id or '-' }}</div></td><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.actor_email or 'System' }}</div><div class="text-xs text-slate-500">Audit Firm {{ row.actor_tenant_id or '-' }} • Branch {{ row.actor_branch_id or '-' }}</div></td><td class="px-4 py-3 text-slate-600">Audit Firm {{ row.target_tenant_id or '-' }} • Branch {{ row.target_branch_id or '-' }}</td><td class="px-4 py-3 max-w-[28rem]"><pre class="whitespace-pre-wrap break-words rounded-xl bg-slate-50 p-3 text-xs text-slate-700">{{ row.pretty_details }}</pre></td><td class="px-4 py-3">{% if row.status == 'success' %}{{ ui.badge(row.status, 'emerald') }}{% elif row.status == 'denied' %}{{ ui.badge(row.status, 'amber') }}{% else %}{{ ui.badge(row.status, 'rose') }}{% endif %}</td></tr>{% endfor %}</tbody></table></div>
{{ ui.pagination(logs_page, '/system-settings/audit-logs', request.url.query) }}
{% else %}<div class="p-6">{{ ui.empty_state('No audit entries found for the current filter.') }}</div>{% endif %}
</div>
</div>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from app.core.templating import templates
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token
from app.core.security.session_auth import get_current_user
from app.modules.core.audit.service import parse_details, search_audit_logs
from app.modules.core.iam.scope import build_scope
from app.modules.core.iam.services import paginate_list
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission
router = APIRouter(prefix="/system-settings/audit-logs", tags=["audit-ui"])
def _is_system_admin(db, user) -> bool:
return "System Admin" in get_user_roles(db, user.id)
def _is_firm_admin(db, user) -> bool:
return "Firm Admin" in get_user_roles(db, user.id)
@router.get("")
def logs(request: Request, q: str = "", page: int = 1, per_page: int = 20):
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, "audit.view")
except Exception:
return RedirectResponse(url="/system-settings", status_code=303)
if not _is_system_admin(db, user) and not _is_firm_admin(db, user):
return RedirectResponse(url="/system-settings", status_code=303)
scope = build_scope(db, user)
rows = search_audit_logs(db, scope, q=q)
# Enforce final matrix explicitly:
# - System Admin: all logs
# - Firm Admin: own tenant logs only
# - others: none
if _is_firm_admin(db, user) and not _is_system_admin(db, user):
rows = [r for r in rows if getattr(r, "target_tenant_id", None) == user.tenant_id]
paged = paginate_list(rows, page=page, per_page=per_page)
decorated = []
for row in paged.items:
pretty = json.dumps(parse_details(row), indent=2, ensure_ascii=False, default=str)
setattr(row, "pretty_details", pretty)
decorated.append(row)
return templates.TemplateResponse(
"modules/core/audit/templates/logs.html",
{
"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),
"title": "Audit Logs",
"logs": decorated,
"logs_page": paged,
"filters": {"q": (q or "").strip(), "per_page": paged.per_page},
},
)
finally:
db.close()