249 lines
6.7 KiB
Python
249 lines
6.7 KiB
Python
"""Users & Organization — team management within an org, role-scoped."""
|
|
import secrets
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, status
|
|
|
|
from app.core.config import settings
|
|
from app.core.deps import require_org, require_client_roles, Principal
|
|
from app.core.security import create_token
|
|
from app.models import User, OrgMember, Plan, AuditLog
|
|
from app.models.documents import now
|
|
from app.models.enums import ClientRole
|
|
from app.services.email import send_invite_email
|
|
import csv
|
|
from io import StringIO
|
|
from fastapi.responses import StreamingResponse
|
|
from app.core.security import hash_password
|
|
|
|
|
|
router = APIRouter(prefix="/users", tags=["users"])
|
|
|
|
|
|
async def _member_out(m: OrgMember) -> dict:
|
|
user = await User.get(m.user_id) if m.user_id else None
|
|
return {
|
|
"member_id": str(m.id),
|
|
"user_id": m.user_id,
|
|
"name": user.name if user else (m.invited_email or "Invited user"),
|
|
"email": user.email if user else m.invited_email,
|
|
"avatar_url": user.avatar_url if user else None,
|
|
"role": m.role,
|
|
"department": m.department,
|
|
"status": m.status,
|
|
"last_login": user.last_login if user else None,
|
|
"joined_at": m.joined_at or m.created_at,
|
|
}
|
|
|
|
from collections import Counter
|
|
@router.get("")
|
|
async def list_users(p: Principal = Depends(require_org)):
|
|
members = await OrgMember.find(OrgMember.org_id == str(p.org.id)).to_list()
|
|
rows = [await _member_out(m) for m in members]
|
|
role_summary = Counter()
|
|
|
|
for r in rows:
|
|
role_summary[r["role"]] += 1
|
|
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
|
|
return {
|
|
"items": rows,
|
|
"role_summary": dict(role_summary),
|
|
"kpis": {
|
|
"total": len(rows),
|
|
"active": sum(1 for r in rows if r["status"] == "active"),
|
|
"pending": sum(1 for r in rows if r["status"] == "pending"),
|
|
"disabled": sum(1 for r in rows if r["status"] == "disabled"),
|
|
"user_limit": plan.user_limit if plan else None,
|
|
},
|
|
}
|
|
|
|
@router.get("/export")
|
|
async def export_users(
|
|
p: Principal = Depends(require_client_roles(ClientRole.admin))
|
|
):
|
|
members = await OrgMember.find(
|
|
OrgMember.org_id == str(p.org.id)
|
|
).to_list()
|
|
|
|
rows = [await _member_out(m) for m in members]
|
|
|
|
output = StringIO()
|
|
writer = csv.writer(output)
|
|
|
|
writer.writerow([
|
|
"Name",
|
|
"Email",
|
|
"Role",
|
|
"Department",
|
|
"Status",
|
|
"Last Login",
|
|
"Joined On",
|
|
])
|
|
|
|
for r in rows:
|
|
writer.writerow([
|
|
r["name"],
|
|
r["email"],
|
|
r["role"],
|
|
r["department"] or "",
|
|
r["status"],
|
|
r["last_login"] or "",
|
|
r["joined_at"] or "",
|
|
])
|
|
|
|
output.seek(0)
|
|
|
|
return StreamingResponse(
|
|
iter([output.getvalue()]),
|
|
media_type="text/csv",
|
|
headers={
|
|
"Content-Disposition":
|
|
"attachment; filename=users.csv"
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/invite", status_code=201)
|
|
async def invite_user(
|
|
body: dict = Body(...),
|
|
p: Principal = Depends(require_client_roles(ClientRole.admin))
|
|
):
|
|
|
|
email = body["email"].strip().lower()
|
|
|
|
if await User.find_one(User.email == email):
|
|
raise HTTPException(
|
|
status.HTTP_409_CONFLICT,
|
|
"Email already exists",
|
|
)
|
|
|
|
user = User(
|
|
name=body["name"],
|
|
email=email,
|
|
hashed_password=hash_password(body["password"]),
|
|
email_verified=True,
|
|
)
|
|
|
|
await user.insert()
|
|
|
|
member = OrgMember(
|
|
org_id=str(p.org.id),
|
|
user_id=str(user.id),
|
|
invited_email=email,
|
|
role=ClientRole(body["role"]),
|
|
department=body.get("department"),
|
|
status="active",
|
|
joined_at=now(),
|
|
)
|
|
|
|
await member.insert()
|
|
|
|
await AuditLog(
|
|
org_id=str(p.org.id),
|
|
user_id=str(p.user.id),
|
|
user_name=p.user.name,
|
|
action="user.create",
|
|
module="users",
|
|
details=f"Created user {email}",
|
|
).insert()
|
|
|
|
return await _member_out(member)
|
|
|
|
@router.get("/{member_id}/activity")
|
|
async def user_activity(member_id: str,
|
|
p: Principal = Depends(require_org)):
|
|
|
|
logs = await (
|
|
AuditLog.find(
|
|
AuditLog.org_id == str(p.org.id),
|
|
AuditLog.target_member_id == member_id,
|
|
)
|
|
.sort(-AuditLog.created_at)
|
|
.to_list()
|
|
)
|
|
|
|
return {
|
|
"items": [
|
|
{
|
|
"id": str(log.id),
|
|
"action": log.action,
|
|
"module": log.module,
|
|
"details": log.details,
|
|
"created_at": log.created_at,
|
|
}
|
|
for log in logs
|
|
]
|
|
}
|
|
@router.put("/{member_id}")
|
|
async def update_member(
|
|
member_id: str,
|
|
body: dict = Body(...),
|
|
p: Principal = Depends(require_client_roles(ClientRole.admin))
|
|
):
|
|
# Find member
|
|
m = await OrgMember.get(member_id)
|
|
if not m or m.org_id != str(p.org.id):
|
|
raise HTTPException(
|
|
status.HTTP_404_NOT_FOUND,
|
|
"Member not found"
|
|
)
|
|
|
|
# Find linked user
|
|
user = await User.get(m.user_id)
|
|
|
|
if user:
|
|
# Update name
|
|
if body.get("name"):
|
|
user.name = body["name"]
|
|
|
|
# Update email (optional)
|
|
if body.get("email"):
|
|
user.email = body["email"].strip().lower()
|
|
|
|
# Update password (optional)
|
|
if body.get("password"):
|
|
user.hashed_password = hash_password(body["password"])
|
|
|
|
await user.save()
|
|
|
|
# Update OrgMember fields
|
|
if "role" in body:
|
|
m.role = ClientRole(body["role"])
|
|
|
|
if "department" in body:
|
|
m.department = body["department"]
|
|
|
|
if (
|
|
"status" in body
|
|
and body["status"] in ("active", "disabled", "pending")
|
|
):
|
|
m.status = body["status"]
|
|
|
|
await m.save()
|
|
|
|
# Audit log
|
|
await AuditLog(
|
|
org_id=str(p.org.id),
|
|
user_id=str(p.user.id),
|
|
user_name=p.user.name,
|
|
action="user.update",
|
|
module="users",
|
|
details=f"Updated member {member_id}",
|
|
).insert()
|
|
|
|
return await _member_out(m)
|
|
|
|
|
|
@router.delete("/{member_id}", status_code=204)
|
|
async def remove_member(member_id: str,
|
|
p: Principal = Depends(require_client_roles(ClientRole.admin))):
|
|
m = await OrgMember.get(member_id)
|
|
if not m or m.org_id != str(p.org.id):
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Member not found")
|
|
if m.user_id == str(p.user.id):
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "You cannot remove yourself")
|
|
await m.delete()
|
|
p.org.user_count = max(1, p.org.user_count - 1)
|
|
await p.org.save()
|
|
|