1013 lines
34 KiB
Python
1013 lines
34 KiB
Python
from __future__ import annotations
|
|
|
|
import secrets
|
|
from datetime import date, datetime, timezone
|
|
|
|
from fastapi import APIRouter, Form, Request
|
|
from fastapi.responses import JSONResponse, 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.passwords import hash_password
|
|
from app.core.security.session_auth import get_current_user
|
|
from app.core.templating import templates
|
|
from app.modules.core.audit.service import model_snapshot, pair_before_after, write_audit_log
|
|
from app.modules.core.iam.invite_service import issue_invite_token
|
|
from app.modules.email_integration.services import send_user_invite_email
|
|
from app.core.settings import get_settings
|
|
from app.modules.core.iam.lifecycle import (
|
|
LifecycleError,
|
|
activate_user,
|
|
deactivate_user,
|
|
disable_login,
|
|
enable_login,
|
|
ensure_manageable_lifecycle,
|
|
lock_user,
|
|
restore_user,
|
|
soft_delete_user,
|
|
unlock_user,
|
|
)
|
|
from app.modules.core.iam.models import User
|
|
from app.modules.core.iam.scope import (
|
|
build_scope,
|
|
ensure_assignable_roles,
|
|
ensure_manageable_existing_user,
|
|
ensure_users_manage_scope,
|
|
ensure_users_view_scope,
|
|
get_user_role_ids,
|
|
get_user_role_names,
|
|
list_scoped_users,
|
|
list_visible_branches,
|
|
list_visible_tenants,
|
|
resolve_target_tenant_branch,
|
|
)
|
|
from app.modules.core.iam.services import build_user_form_payload, build_user_listing_payload
|
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
|
from app.modules.core.rbac.models import UserRole
|
|
from app.modules.core.rbac.permission_guard import require_permission
|
|
from app.modules.core.tenancy.models import Branch
|
|
|
|
router = APIRouter(prefix="/system-settings/users", tags=["users-ui"])
|
|
|
|
|
|
def _public_invite_url(invite_token: str) -> str:
|
|
base = (get_settings().ERP_PUBLIC_BASE_URL or "").strip().rstrip("/") or "http://localhost:8000"
|
|
return f"{base}/invite/accept?token={invite_token}"
|
|
|
|
|
|
def _redirect_login():
|
|
return RedirectResponse(url="/login", status_code=303)
|
|
|
|
|
|
def _redirect_denied():
|
|
return RedirectResponse(url="/system-settings", status_code=303)
|
|
|
|
|
|
def _flash_redirect(url: str) -> RedirectResponse:
|
|
return RedirectResponse(url=url, status_code=303)
|
|
|
|
|
|
def _common_context(request: Request, db, current_user: User, extra: dict | None = None):
|
|
ctx = {
|
|
"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),
|
|
}
|
|
if extra:
|
|
ctx.update(extra)
|
|
return ctx
|
|
|
|
|
|
def _user_admin_roles(db, current_user: User) -> set[str]:
|
|
return set(get_user_roles(db, current_user.id))
|
|
|
|
|
|
def _ensure_user_admin(db, current_user: User):
|
|
roles = _user_admin_roles(db, current_user)
|
|
if "System Admin" in roles or "Firm Admin" in roles:
|
|
return
|
|
raise PermissionError("Only System Admin and Firm Admin can create or manage users.")
|
|
|
|
|
|
INTERNAL_EMPLOYEE_ROLE_NAMES = {"Firm Admin", "Partner", "Manager", "Branch Manager", "Staff"}
|
|
EMPLOYEE_EXCLUDED_ROLE_NAMES = {"System Admin", "Client", "Consultant"}
|
|
|
|
|
|
def _role_name_set(roles) -> set[str]:
|
|
return {getattr(role, "name", "") for role in roles if getattr(role, "name", "")}
|
|
|
|
|
|
def _requires_employee_profile(role_names: set[str]) -> bool:
|
|
"""Internal firm users must have an employee profile.
|
|
|
|
Platform-only users, clients and consultants are intentionally excluded.
|
|
If an account has at least one internal employee role, we create/link the
|
|
employee profile, unless it is also a System Admin account.
|
|
"""
|
|
if "System Admin" in role_names:
|
|
return False
|
|
return bool(role_names.intersection(INTERNAL_EMPLOYEE_ROLE_NAMES))
|
|
|
|
|
|
def _parse_optional_date(value: str | None) -> date | None:
|
|
value = (value or "").strip()
|
|
if not value:
|
|
return None
|
|
return date.fromisoformat(value)
|
|
|
|
|
|
def _next_employee_code(db, tenant_id: int) -> str:
|
|
from app.modules.employees.models import Employee
|
|
|
|
prefix = "EMP"
|
|
rows = db.execute(
|
|
select(Employee.employee_code)
|
|
.where(Employee.tenant_id == tenant_id, Employee.employee_code.ilike(f"{prefix}%"))
|
|
.order_by(Employee.employee_code.desc())
|
|
).all()
|
|
max_no = 0
|
|
for (code,) in rows:
|
|
suffix = "".join(ch for ch in str(code or "") if ch.isdigit())
|
|
if suffix:
|
|
max_no = max(max_no, int(suffix))
|
|
return f"{prefix}{max_no + 1:05d}"
|
|
|
|
|
|
def _ensure_employee_profile_for_internal_user(
|
|
db,
|
|
*,
|
|
actor: User,
|
|
user_obj: User,
|
|
role_names: set[str],
|
|
employee_code: str | None = None,
|
|
mobile: str | None = None,
|
|
department: str | None = None,
|
|
designation: str | None = None,
|
|
date_of_joining: str | None = None,
|
|
employment_type: str | None = None,
|
|
) -> dict:
|
|
"""Create/link an Employee profile for internal firm users.
|
|
|
|
This function is intentionally additive. It does not create employee
|
|
profiles for System Admin, Client or Consultant-only users, and it does not
|
|
overwrite an existing linked employee profile.
|
|
"""
|
|
if not _requires_employee_profile(role_names):
|
|
return {"required": False, "created": False, "linked_existing": False, "employee_id": None}
|
|
|
|
if not user_obj.tenant_id or not user_obj.branch_id:
|
|
raise ValueError("Tenant and Branch are mandatory for internal employee users.")
|
|
|
|
from app.modules.employees.models import Employee
|
|
|
|
existing_linked = db.execute(
|
|
select(Employee).where(Employee.tenant_id == user_obj.tenant_id, Employee.user_id == user_obj.id)
|
|
).scalar_one_or_none()
|
|
if existing_linked:
|
|
return {"required": True, "created": False, "linked_existing": True, "employee_id": existing_linked.id}
|
|
|
|
email = (user_obj.email or "").strip().lower()
|
|
existing_unlinked = None
|
|
if email:
|
|
existing_unlinked = db.execute(
|
|
select(Employee).where(
|
|
Employee.tenant_id == user_obj.tenant_id,
|
|
Employee.user_id.is_(None),
|
|
Employee.email == email,
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
if existing_unlinked:
|
|
existing_unlinked.user_id = user_obj.id
|
|
existing_unlinked.branch_id = user_obj.branch_id
|
|
existing_unlinked.full_name = user_obj.full_name or existing_unlinked.full_name
|
|
existing_unlinked.updated_by_user_id = actor.id
|
|
existing_unlinked.updated_at_utc = datetime.now(timezone.utc)
|
|
return {"required": True, "created": False, "linked_existing": True, "employee_id": existing_unlinked.id}
|
|
|
|
code = (employee_code or "").strip() or _next_employee_code(db, int(user_obj.tenant_id))
|
|
code_exists = db.execute(
|
|
select(Employee).where(Employee.tenant_id == user_obj.tenant_id, Employee.employee_code == code)
|
|
).scalar_one_or_none()
|
|
if code_exists:
|
|
raise ValueError(f"Employee code '{code}' already exists in this tenant.")
|
|
|
|
emp_type = (employment_type or "full_time").strip() or "full_time"
|
|
if emp_type not in {"full_time", "part_time", "article_assistant", "intern", "consultant", "contract"}:
|
|
emp_type = "full_time"
|
|
|
|
employee = Employee(
|
|
tenant_id=int(user_obj.tenant_id),
|
|
branch_id=int(user_obj.branch_id),
|
|
user_id=user_obj.id,
|
|
employee_code=code,
|
|
full_name=(user_obj.full_name or email or code).strip(),
|
|
email=email or None,
|
|
mobile=(mobile or "").strip() or None,
|
|
date_of_joining=_parse_optional_date(date_of_joining),
|
|
employment_type=emp_type,
|
|
status="active",
|
|
is_active=True,
|
|
department=(department or "").strip() or None,
|
|
designation=(designation or "").strip() or None,
|
|
created_by_user_id=actor.id,
|
|
updated_by_user_id=actor.id,
|
|
)
|
|
db.add(employee)
|
|
db.flush()
|
|
return {"required": True, "created": True, "linked_existing": False, "employee_id": employee.id}
|
|
|
|
|
|
def _form_context(
|
|
db,
|
|
request: Request,
|
|
current_user: User,
|
|
scope,
|
|
*,
|
|
user_obj=None,
|
|
assigned_role_ids=None,
|
|
flash=None,
|
|
title="Create User",
|
|
form_mode="create",
|
|
):
|
|
payload = build_user_form_payload(db, scope, current_user, user_obj=user_obj, assigned_role_ids=assigned_role_ids)
|
|
payload.update({"title": title, "flash": flash, "form_mode": form_mode})
|
|
return _common_context(request, db, current_user, payload)
|
|
|
|
|
|
@router.get("")
|
|
def users_list(request: Request, q: str = "", page: int = 1, per_page: int = 10):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
|
|
try:
|
|
require_permission(db, current_user, "users.view")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
|
|
scope = build_scope(db, current_user)
|
|
try:
|
|
ensure_users_view_scope(scope)
|
|
except Exception:
|
|
return _flash_redirect("/system-settings")
|
|
|
|
payload = build_user_listing_payload(db, scope, q=q, page=page, per_page=per_page)
|
|
payload.update({"title": "Users", "scope": scope})
|
|
|
|
return templates.TemplateResponse(
|
|
"modules/core/iam/templates/users_list.html",
|
|
_common_context(request, db, current_user, payload),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/branch-options")
|
|
def branch_options(request: Request, tenant_id: int):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return JSONResponse([], status_code=401)
|
|
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
_ensure_user_admin(db, current_user)
|
|
except Exception:
|
|
return JSONResponse([], status_code=403)
|
|
|
|
scope = build_scope(db, current_user)
|
|
|
|
try:
|
|
ensure_users_manage_scope(scope)
|
|
except Exception:
|
|
return JSONResponse([], status_code=403)
|
|
|
|
if scope.is_system_admin:
|
|
branch_rows = db.execute(
|
|
select(Branch)
|
|
.where(Branch.tenant_id == tenant_id)
|
|
.order_by(Branch.name)
|
|
).scalars().all()
|
|
else:
|
|
if tenant_id != current_user.tenant_id:
|
|
return JSONResponse([], status_code=403)
|
|
branch_rows = list_visible_branches(db, scope, tenant_id)
|
|
|
|
return JSONResponse(
|
|
[
|
|
{"id": b.id, "name": b.name, "code": b.code}
|
|
for b in branch_rows
|
|
]
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/new")
|
|
def user_create(request: Request):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
_ensure_user_admin(db, current_user)
|
|
except Exception:
|
|
return _redirect_denied()
|
|
|
|
scope = build_scope(db, current_user)
|
|
try:
|
|
ensure_users_manage_scope(scope)
|
|
except Exception:
|
|
return _flash_redirect("/system-settings/users")
|
|
|
|
return templates.TemplateResponse(
|
|
"modules/core/iam/templates/user_form.html",
|
|
_form_context(db, request, current_user, scope, title="Create User", form_mode="create"),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/new")
|
|
def user_create_submit(
|
|
request: Request,
|
|
email: str = Form(...),
|
|
full_name: str = Form(...),
|
|
password: str = Form(...),
|
|
tenant_id: int | None = Form(None),
|
|
branch_id: int | None = Form(None),
|
|
role_ids: list[int] = Form([]),
|
|
is_active: str | None = Form(None),
|
|
allow_login: str | None = Form(None),
|
|
invite_user: str | None = Form(None),
|
|
employee_code: str = Form(""),
|
|
employee_mobile: str = Form(""),
|
|
employee_department: str = Form(""),
|
|
employee_designation: str = Form(""),
|
|
employee_date_of_joining: str = Form(""),
|
|
employee_employment_type: str = Form("full_time"),
|
|
csrf_token: str = Form(...),
|
|
):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
_ensure_user_admin(db, current_user)
|
|
except Exception:
|
|
return _redirect_denied()
|
|
|
|
scope = build_scope(db, current_user)
|
|
|
|
try:
|
|
ensure_users_manage_scope(scope)
|
|
resolved_tenant_id, resolved_branch_id = resolve_target_tenant_branch(db, scope, tenant_id, branch_id)
|
|
roles = ensure_assignable_roles(db, scope, role_ids)
|
|
except Exception as exc:
|
|
return templates.TemplateResponse(
|
|
"modules/core/iam/templates/user_form.html",
|
|
_form_context(
|
|
db,
|
|
request,
|
|
current_user,
|
|
scope,
|
|
assigned_role_ids=role_ids,
|
|
flash=str(exc),
|
|
title="Create User",
|
|
form_mode="create",
|
|
),
|
|
status_code=403,
|
|
)
|
|
|
|
email = email.lower().strip()
|
|
if db.execute(select(User).where(User.email == email)).scalar_one_or_none():
|
|
return templates.TemplateResponse(
|
|
"modules/core/iam/templates/user_form.html",
|
|
_form_context(
|
|
db,
|
|
request,
|
|
current_user,
|
|
scope,
|
|
assigned_role_ids=role_ids,
|
|
flash="Email already exists.",
|
|
title="Create User",
|
|
form_mode="create",
|
|
),
|
|
status_code=400,
|
|
)
|
|
|
|
temp_password = password or secrets.token_urlsafe(12)
|
|
|
|
user = User(
|
|
email=email,
|
|
full_name=full_name.strip(),
|
|
password_hash=hash_password(temp_password),
|
|
tenant_id=resolved_tenant_id,
|
|
branch_id=resolved_branch_id,
|
|
is_active=is_active is not None,
|
|
allow_login=allow_login is not None,
|
|
is_locked=False,
|
|
deleted_at=None,
|
|
must_change_password=invite_user is not None,
|
|
password_changed_at_utc=None,
|
|
)
|
|
db.add(user)
|
|
db.flush()
|
|
|
|
for role in roles:
|
|
db.add(UserRole(user_id=user.id, role_id=role.id))
|
|
|
|
role_names = _role_name_set(roles)
|
|
try:
|
|
employee_link_result = _ensure_employee_profile_for_internal_user(
|
|
db,
|
|
actor=current_user,
|
|
user_obj=user,
|
|
role_names=role_names,
|
|
employee_code=employee_code,
|
|
mobile=employee_mobile,
|
|
department=employee_department,
|
|
designation=employee_designation,
|
|
date_of_joining=employee_date_of_joining,
|
|
employment_type=employee_employment_type,
|
|
)
|
|
except Exception as exc:
|
|
db.rollback()
|
|
return templates.TemplateResponse(
|
|
"modules/core/iam/templates/user_form.html",
|
|
_form_context(
|
|
db,
|
|
request,
|
|
current_user,
|
|
scope,
|
|
assigned_role_ids=role_ids,
|
|
flash=f"User was not created: {exc}",
|
|
title="Create User",
|
|
form_mode="create",
|
|
),
|
|
status_code=400,
|
|
)
|
|
|
|
db.commit()
|
|
db.refresh(user)
|
|
|
|
invite_url = None
|
|
if invite_user is not None:
|
|
invite_token = issue_invite_token(db, user)
|
|
invite_url = _public_invite_url(invite_token)
|
|
|
|
write_audit_log(
|
|
db,
|
|
action="user.create",
|
|
entity_type="user",
|
|
actor=current_user,
|
|
request=request,
|
|
entity_id=user.id,
|
|
entity_name=user.email,
|
|
target_tenant_id=user.tenant_id,
|
|
target_branch_id=user.branch_id,
|
|
details={
|
|
"after": model_snapshot(
|
|
user,
|
|
["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"],
|
|
),
|
|
"role_ids": [role.id for role in roles],
|
|
"employee_profile": employee_link_result,
|
|
},
|
|
)
|
|
|
|
if invite_url:
|
|
try:
|
|
send_user_invite_email(db, user=user, invite_token=invite_token)
|
|
db.commit()
|
|
except Exception as exc:
|
|
print(f"[EMAIL INVITE ERROR] user={user.email} error={exc}")
|
|
return templates.TemplateResponse(
|
|
"modules/core/iam/templates/invite_link.html",
|
|
_common_context(
|
|
request,
|
|
db,
|
|
current_user,
|
|
{"title": "Invite Link", "invite_url": invite_url, "invited_user": user},
|
|
),
|
|
)
|
|
|
|
return RedirectResponse(url="/system-settings/users", status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/{user_id}/invite")
|
|
def user_invite(request: Request, user_id: int, csrf_token: str = Form(...)):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
require_permission(db, current_user, "users.invite")
|
|
_ensure_user_admin(db, current_user)
|
|
except Exception:
|
|
return _redirect_denied()
|
|
|
|
scope = build_scope(db, current_user)
|
|
user_obj = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
|
if not user_obj:
|
|
return RedirectResponse(url="/system-settings/users", status_code=303)
|
|
|
|
ensure_manageable_existing_user(db, scope, user_obj)
|
|
|
|
invite_token = issue_invite_token(db, user_obj)
|
|
invite_url = _public_invite_url(invite_token)
|
|
try:
|
|
send_user_invite_email(db, user=user_obj, invite_token=invite_token)
|
|
db.commit()
|
|
except Exception as exc:
|
|
print(f"[EMAIL INVITE ERROR] user={user_obj.email} error={exc}")
|
|
|
|
write_audit_log(
|
|
db,
|
|
action="user.invited",
|
|
entity_type="user",
|
|
actor=current_user,
|
|
request=request,
|
|
entity_id=user_obj.id,
|
|
entity_name=user_obj.email,
|
|
target_tenant_id=user_obj.tenant_id,
|
|
target_branch_id=user_obj.branch_id,
|
|
details={"invite_url": invite_url},
|
|
)
|
|
|
|
return templates.TemplateResponse(
|
|
"modules/core/iam/templates/invite_link.html",
|
|
_common_context(
|
|
request,
|
|
db,
|
|
current_user,
|
|
{"title": "Invite Link", "invite_url": invite_url, "invited_user": user_obj},
|
|
),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/{user_id}/edit")
|
|
def user_edit(request: Request, user_id: int):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
_ensure_user_admin(db, current_user)
|
|
except Exception:
|
|
return _redirect_denied()
|
|
|
|
scope = build_scope(db, current_user)
|
|
user_obj = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
|
if not user_obj:
|
|
return RedirectResponse(url="/system-settings/users", status_code=303)
|
|
|
|
try:
|
|
ensure_manageable_existing_user(db, scope, user_obj)
|
|
ensure_users_manage_scope(scope)
|
|
except Exception:
|
|
return RedirectResponse(url="/system-settings/users", status_code=303)
|
|
|
|
assigned_role_ids = list(get_user_role_ids(db, user_id))
|
|
|
|
return templates.TemplateResponse(
|
|
"modules/core/iam/templates/user_form.html",
|
|
_form_context(
|
|
db,
|
|
request,
|
|
current_user,
|
|
scope,
|
|
title=f"Edit User - {user_obj.email}",
|
|
form_mode="edit",
|
|
user_obj=user_obj,
|
|
assigned_role_ids=assigned_role_ids,
|
|
),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/{user_id}/edit")
|
|
def user_edit_submit(
|
|
request: Request,
|
|
user_id: int,
|
|
full_name: str = Form(...),
|
|
password: str = Form(""),
|
|
tenant_id: int | None = Form(None),
|
|
branch_id: int | None = Form(None),
|
|
role_ids: list[int] = Form([]),
|
|
is_active: str | None = Form(None),
|
|
allow_login: str | None = Form(None),
|
|
invite_user: str | None = Form(None),
|
|
employee_code: str = Form(""),
|
|
employee_mobile: str = Form(""),
|
|
employee_department: str = Form(""),
|
|
employee_designation: str = Form(""),
|
|
employee_date_of_joining: str = Form(""),
|
|
employee_employment_type: str = Form("full_time"),
|
|
csrf_token: str = Form(...),
|
|
):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
_ensure_user_admin(db, current_user)
|
|
except Exception:
|
|
return _redirect_denied()
|
|
|
|
scope = build_scope(db, current_user)
|
|
user_obj = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
|
if not user_obj:
|
|
return RedirectResponse(url="/system-settings/users", status_code=303)
|
|
|
|
try:
|
|
ensure_users_manage_scope(scope)
|
|
ensure_manageable_existing_user(db, scope, user_obj)
|
|
resolved_tenant_id, resolved_branch_id = resolve_target_tenant_branch(db, scope, tenant_id, branch_id)
|
|
roles = ensure_assignable_roles(db, scope, role_ids)
|
|
except Exception as exc:
|
|
return templates.TemplateResponse(
|
|
"modules/core/iam/templates/user_form.html",
|
|
_form_context(
|
|
db,
|
|
request,
|
|
current_user,
|
|
scope,
|
|
title=f"Edit User - {user_obj.email}",
|
|
form_mode="edit",
|
|
user_obj=user_obj,
|
|
assigned_role_ids=role_ids,
|
|
flash=str(exc),
|
|
),
|
|
status_code=403,
|
|
)
|
|
|
|
before_snapshot = model_snapshot(
|
|
user_obj,
|
|
["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"],
|
|
)
|
|
old_role_ids = list(get_user_role_ids(db, user_obj.id))
|
|
|
|
user_obj.full_name = full_name.strip()
|
|
user_obj.tenant_id = resolved_tenant_id
|
|
user_obj.branch_id = resolved_branch_id
|
|
user_obj.is_active = is_active is not None
|
|
user_obj.allow_login = allow_login is not None
|
|
|
|
if password.strip():
|
|
user_obj.password_hash = hash_password(password.strip())
|
|
|
|
db.execute(UserRole.__table__.delete().where(UserRole.user_id == user_obj.id))
|
|
for role in roles:
|
|
db.add(UserRole(user_id=user_obj.id, role_id=role.id))
|
|
|
|
role_names = _role_name_set(roles)
|
|
try:
|
|
employee_link_result = _ensure_employee_profile_for_internal_user(
|
|
db,
|
|
actor=current_user,
|
|
user_obj=user_obj,
|
|
role_names=role_names,
|
|
employee_code=employee_code,
|
|
mobile=employee_mobile,
|
|
department=employee_department,
|
|
designation=employee_designation,
|
|
date_of_joining=employee_date_of_joining,
|
|
employment_type=employee_employment_type,
|
|
)
|
|
except Exception as exc:
|
|
db.rollback()
|
|
return templates.TemplateResponse(
|
|
"modules/core/iam/templates/user_form.html",
|
|
_form_context(
|
|
db,
|
|
request,
|
|
current_user,
|
|
scope,
|
|
title=f"Edit User - {user_obj.email}",
|
|
form_mode="edit",
|
|
user_obj=user_obj,
|
|
assigned_role_ids=role_ids,
|
|
flash=f"User was not updated: {exc}",
|
|
),
|
|
status_code=400,
|
|
)
|
|
|
|
db.commit()
|
|
|
|
if invite_user is not None:
|
|
invite_token = issue_invite_token(db, user_obj)
|
|
invite_url = _public_invite_url(invite_token)
|
|
try:
|
|
send_user_invite_email(db, user=user_obj, invite_token=invite_token)
|
|
db.commit()
|
|
except Exception as exc:
|
|
print(f"[EMAIL INVITE ERROR] user={user_obj.email} error={exc}")
|
|
write_audit_log(
|
|
db,
|
|
action="user.invited",
|
|
entity_type="user",
|
|
actor=current_user,
|
|
request=request,
|
|
entity_id=user_obj.id,
|
|
entity_name=user_obj.email,
|
|
target_tenant_id=user_obj.tenant_id,
|
|
target_branch_id=user_obj.branch_id,
|
|
details={"invite_url": invite_url},
|
|
)
|
|
|
|
write_audit_log(
|
|
db,
|
|
action="user.update",
|
|
entity_type="user",
|
|
actor=current_user,
|
|
request=request,
|
|
entity_id=user_obj.id,
|
|
entity_name=user_obj.email,
|
|
target_tenant_id=user_obj.tenant_id,
|
|
target_branch_id=user_obj.branch_id,
|
|
details={
|
|
**pair_before_after(
|
|
before_snapshot,
|
|
model_snapshot(
|
|
user_obj,
|
|
["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"],
|
|
),
|
|
),
|
|
"old_role_ids": old_role_ids,
|
|
"new_role_ids": [role.id for role in roles],
|
|
"employee_profile": employee_link_result,
|
|
},
|
|
)
|
|
|
|
return RedirectResponse(url="/system-settings/users", status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _lifecycle_template_response(request: Request, db, current_user: User, scope, flash: str, status_code: int = 400):
|
|
users = list_scoped_users(db, scope)
|
|
roles = {user_id: get_user_role_names(db, user_id) for user_id in [u.id for u in users]}
|
|
tenants = {t.id: t for t in list_visible_tenants(db, scope)}
|
|
|
|
visible_branch_tenant_id = current_user.tenant_id if not scope.is_system_admin else None
|
|
branch_rows = (
|
|
db.execute(select(Branch).order_by(Branch.name)).scalars().all()
|
|
if scope.is_system_admin
|
|
else list_visible_branches(db, scope, visible_branch_tenant_id)
|
|
)
|
|
branches = {b.id: b for b in branch_rows}
|
|
|
|
return templates.TemplateResponse(
|
|
"modules/core/iam/templates/users_list.html",
|
|
_common_context(
|
|
request,
|
|
db,
|
|
current_user,
|
|
{
|
|
"title": "Users",
|
|
"users": users,
|
|
"user_roles": roles,
|
|
"tenants": tenants,
|
|
"branches": branches,
|
|
"scope": scope,
|
|
"flash": flash,
|
|
},
|
|
),
|
|
status_code=status_code,
|
|
)
|
|
|
|
|
|
def _run_lifecycle_action(db, request: Request, current_user: User, user_id: int, action: str):
|
|
scope = build_scope(db, current_user)
|
|
user_obj = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
|
|
if not user_obj:
|
|
return RedirectResponse(url="/system-settings/users", status_code=303)
|
|
|
|
try:
|
|
_ensure_user_admin(db, current_user)
|
|
ensure_users_manage_scope(scope)
|
|
ensure_manageable_lifecycle(scope, db, current_user, user_obj, action.replace("-", " "))
|
|
except (Exception, LifecycleError) as exc:
|
|
return _lifecycle_template_response(request, db, current_user, scope, str(exc), 403)
|
|
|
|
before_snapshot = model_snapshot(
|
|
user_obj,
|
|
["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"],
|
|
)
|
|
|
|
if action == "activate":
|
|
activate_user(user_obj)
|
|
audit_action = "user.activate"
|
|
elif action == "deactivate":
|
|
deactivate_user(user_obj)
|
|
audit_action = "user.deactivate"
|
|
elif action == "enable-login":
|
|
enable_login(user_obj)
|
|
audit_action = "user.enable_login"
|
|
elif action == "disable-login":
|
|
disable_login(user_obj)
|
|
audit_action = "user.disable_login"
|
|
elif action == "lock":
|
|
lock_user(user_obj)
|
|
audit_action = "user.lock"
|
|
elif action == "unlock":
|
|
unlock_user(user_obj)
|
|
audit_action = "user.unlock"
|
|
elif action == "delete":
|
|
soft_delete_user(user_obj)
|
|
audit_action = "user.soft_delete"
|
|
elif action == "restore":
|
|
restore_user(user_obj)
|
|
audit_action = "user.restore"
|
|
else:
|
|
return _lifecycle_template_response(request, db, current_user, scope, "Unknown action", 400)
|
|
|
|
db.commit()
|
|
|
|
write_audit_log(
|
|
db,
|
|
action=audit_action,
|
|
entity_type="user",
|
|
actor=current_user,
|
|
request=request,
|
|
entity_id=user_obj.id,
|
|
entity_name=user_obj.email,
|
|
target_tenant_id=user_obj.tenant_id,
|
|
target_branch_id=user_obj.branch_id,
|
|
details=pair_before_after(
|
|
before_snapshot,
|
|
model_snapshot(
|
|
user_obj,
|
|
["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"],
|
|
),
|
|
),
|
|
)
|
|
|
|
return RedirectResponse(url="/system-settings/users", status_code=303)
|
|
|
|
|
|
@router.post("/{user_id}/activate")
|
|
def user_activate_submit(request: Request, user_id: int, csrf_token: str = Form(...)):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
return _run_lifecycle_action(db, request, current_user, user_id, "activate")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/{user_id}/deactivate")
|
|
def user_deactivate_submit(request: Request, user_id: int, csrf_token: str = Form(...)):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
return _run_lifecycle_action(db, request, current_user, user_id, "deactivate")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/{user_id}/enable-login")
|
|
def user_enable_login_submit(request: Request, user_id: int, csrf_token: str = Form(...)):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
return _run_lifecycle_action(db, request, current_user, user_id, "enable-login")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/{user_id}/disable-login")
|
|
def user_disable_login_submit(request: Request, user_id: int, csrf_token: str = Form(...)):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
return _run_lifecycle_action(db, request, current_user, user_id, "disable-login")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/{user_id}/lock")
|
|
def user_lock_submit(request: Request, user_id: int, csrf_token: str = Form(...)):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
return _run_lifecycle_action(db, request, current_user, user_id, "lock")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/{user_id}/unlock")
|
|
def user_unlock_submit(request: Request, user_id: int, csrf_token: str = Form(...)):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
return _run_lifecycle_action(db, request, current_user, user_id, "unlock")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/{user_id}/delete")
|
|
def user_delete_submit(request: Request, user_id: int, csrf_token: str = Form(...)):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
return _run_lifecycle_action(db, request, current_user, user_id, "delete")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/{user_id}/restore")
|
|
def user_restore_submit(request: Request, user_id: int, csrf_token: str = Form(...)):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
current_user = get_current_user(request, db=db)
|
|
if not current_user:
|
|
return _redirect_login()
|
|
try:
|
|
require_permission(db, current_user, "users.manage")
|
|
except Exception:
|
|
return _redirect_denied()
|
|
return _run_lifecycle_action(db, request, current_user, user_id, "restore")
|
|
finally:
|
|
db.close() |