chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)
The PM panel (PmPanelPage -> iframe /pm/) needs the project-management platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM, branch feature/project-management-platform. Update via: git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
"""User and project-assignment administration (role `project_manager` only).
|
||||
|
||||
Creating the accounts that hold a scoped role and deciding *which* projects the
|
||||
scoped role may touch is what makes the RBAC in app/authz.py usable; nothing
|
||||
here changes an operational record.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.authz import ROLE_LABELS, AuthContext, Perm, Role, require_permission, role_of
|
||||
from app.db import get_db
|
||||
from app.dev_accounts import clear_assignments
|
||||
from app.errors import NotFoundError, ValidationError
|
||||
from app.models import PMUser, Project, ProjectAssignment
|
||||
from app.security import hash_password
|
||||
from app.serializers import _iso
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
manage = require_permission(Perm.users_manage)
|
||||
|
||||
|
||||
def _role_value(value: str) -> str:
|
||||
try:
|
||||
return Role(value).value
|
||||
except ValueError as exc:
|
||||
raise ValidationError(f"نقش نامعتبر است: {value}", field="role") from exc
|
||||
|
||||
|
||||
async def _assigned_project_ids(db: AsyncSession, user_id) -> list[str]:
|
||||
"""Projects assigned to a user, as native UUIDs."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ProjectAssignment.project_id).where(
|
||||
ProjectAssignment.user_id == user_id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
return sorted(str(pid) for pid in rows if pid is not None)
|
||||
|
||||
|
||||
async def _user_dict(db: AsyncSession, user: PMUser) -> dict:
|
||||
role = role_of(user)
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"role": role.value,
|
||||
"role_label": ROLE_LABELS.get(role.value, role.value),
|
||||
"scoped": role in (Role.project_supervisor,),
|
||||
"project_ids": await _assigned_project_ids(db, user.id),
|
||||
"created_at": _iso(user.created_at),
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_users(ctx: AuthContext = Depends(manage), db: AsyncSession = Depends(get_db)):
|
||||
users = (await db.execute(select(PMUser).order_by(PMUser.username))).scalars().all()
|
||||
return {"data": [await _user_dict(db, u) for u in users]}
|
||||
|
||||
|
||||
class UserIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
display_name: str = ""
|
||||
role: str = Role.project_supervisor.value
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def _username(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("نام کاربری الزامی است")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def _password(cls, v: str) -> str:
|
||||
if len(v) < 6:
|
||||
raise ValueError("رمز عبور باید حداقل ۶ نویسه باشد")
|
||||
return v
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_user(payload: UserIn, ctx: AuthContext = Depends(manage), db: AsyncSession = Depends(get_db)):
|
||||
existing = await db.scalar(select(PMUser).where(PMUser.username == payload.username))
|
||||
if existing is not None:
|
||||
raise ValidationError("این نام کاربری قبلاً ثبت شده است", field="username")
|
||||
user = PMUser(
|
||||
username=payload.username,
|
||||
password_hash=hash_password(payload.password),
|
||||
display_name=payload.display_name.strip(),
|
||||
role=_role_value(payload.role),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return {"data": await _user_dict(db, user)}
|
||||
|
||||
|
||||
class UserPatch(BaseModel):
|
||||
display_name: str | None = None
|
||||
role: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def _password(cls, v: str | None) -> str | None:
|
||||
if v is not None and len(v) < 6:
|
||||
raise ValueError("رمز عبور باید حداقل ۶ نویسه باشد")
|
||||
return v
|
||||
|
||||
|
||||
@router.patch("/{user_id}")
|
||||
async def patch_user(
|
||||
user_id: uuid.UUID,
|
||||
payload: UserPatch,
|
||||
ctx: AuthContext = Depends(manage),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await db.get(PMUser, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
if payload.display_name is not None:
|
||||
user.display_name = payload.display_name.strip()
|
||||
if payload.password:
|
||||
user.password_hash = hash_password(payload.password)
|
||||
if payload.role is not None:
|
||||
new_role = _role_value(payload.role)
|
||||
# never let a manager remove their own manager role (lock-out guard)
|
||||
if user.id == ctx.user.id and new_role != Role.project_manager.value:
|
||||
raise ValidationError("نقش مدیر فعلی خود را نمیتوانید تغییر دهید", field="role")
|
||||
user.role = new_role
|
||||
await db.commit()
|
||||
return {"data": await _user_dict(db, user)}
|
||||
|
||||
|
||||
class ProjectsIn(BaseModel):
|
||||
"""Native project UUIDs (the live world). Legacy integer ids still accepted."""
|
||||
|
||||
project_ids: list[str] = []
|
||||
|
||||
|
||||
@router.put("/{user_id}/projects")
|
||||
async def set_user_projects(
|
||||
user_id: uuid.UUID,
|
||||
payload: ProjectsIn,
|
||||
ctx: AuthContext = Depends(manage),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Replace a user's assigned projects (the scope a supervisor is limited to)."""
|
||||
user = await db.get(PMUser, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
|
||||
native_ids: list[uuid.UUID] = []
|
||||
legacy_ids: list[int] = []
|
||||
unknown: list[str] = []
|
||||
for raw in dict.fromkeys(payload.project_ids):
|
||||
raw = str(raw).strip()
|
||||
parsed = None
|
||||
try:
|
||||
parsed = uuid.UUID(raw)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
|
||||
if parsed is not None:
|
||||
if await db.get(Project, parsed) is None:
|
||||
unknown.append(raw)
|
||||
else:
|
||||
native_ids.append(parsed)
|
||||
else:
|
||||
unknown.append(raw)
|
||||
|
||||
if unknown:
|
||||
raise ValidationError(
|
||||
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
|
||||
)
|
||||
|
||||
await clear_assignments(db, user.id)
|
||||
for project_uuid in native_ids:
|
||||
db.add(ProjectAssignment(user_id=user.id, project_id=project_uuid))
|
||||
await db.commit()
|
||||
return {"data": await _user_dict(db, user)}
|
||||
Reference in New Issue
Block a user