449 lines
15 KiB
Python
449 lines
15 KiB
Python
"""Managing a workspace's organisational structure.
|
|||
|
|
|
||
|
|
Everything is scoped to the caller's workspace by their session. Structure is
|
||
|
|
managed by whoever can manage roles — it is the same kind of act, deciding how
|
||
|
|
the workspace is shaped — while *membership* follows user management, because
|
||
|
|
putting somebody in a department is an edit to that person.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from typing import List, Optional
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||
|
|
from pydantic import BaseModel, ConfigDict, Field
|
||
|
|
from sqlalchemy.orm import Session
|
||
|
|
|
||
|
|
from app.config.database import get_db
|
||
|
|
from app.helper.helpers import get_client_ip
|
||
|
|
from app.middleware.auth_middleware import User, get_current_user, require_access
|
||
|
|
from app.models.auth.org_unit_model import OrgUnit, UserAdminScope, UserOrgUnit
|
||
|
|
from app.services.auth import org_unit_service as units
|
||
|
|
from app.services.system.audit_log_service import AuditLogService
|
||
|
|
|
||
|
|
router = APIRouter()
|
||
|
|
|
||
|
|
|
||
|
|
class OrgUnitCreate(BaseModel):
|
||
|
|
name: str = Field(min_length=1, max_length=160)
|
||
|
|
code: Optional[str] = Field(default=None, max_length=60)
|
||
|
|
parent_id: Optional[uuid.UUID] = None
|
||
|
|
|
||
|
|
|
||
|
|
class OrgUnitUpdate(BaseModel):
|
||
|
|
name: Optional[str] = Field(default=None, max_length=160)
|
||
|
|
code: Optional[str] = Field(default=None, max_length=60)
|
||
|
|
|
||
|
|
|
||
|
|
class OrgUnitMove(BaseModel):
|
||
|
|
parent_id: Optional[uuid.UUID] = None
|
||
|
|
|
||
|
|
|
||
|
|
class OrgUnitResponse(BaseModel):
|
||
|
|
model_config = ConfigDict(from_attributes=True)
|
||
|
|
|
||
|
|
id: uuid.UUID
|
||
|
|
name: str
|
||
|
|
code: Optional[str] = None
|
||
|
|
parent_id: Optional[uuid.UUID] = None
|
||
|
|
path: str
|
||
|
|
depth: int
|
||
|
|
is_active: bool
|
||
|
|
seat_limit: Optional[int] = None
|
||
|
|
seats_used: int = 0
|
||
|
|
|
||
|
|
|
||
|
|
class MembershipRequest(BaseModel):
|
||
|
|
user_id: uuid.UUID
|
||
|
|
primary: bool = False
|
||
|
|
lead: bool = False
|
||
|
|
|
||
|
|
|
||
|
|
class MemberResponse(BaseModel):
|
||
|
|
user_id: uuid.UUID
|
||
|
|
email: str
|
||
|
|
is_primary: bool
|
||
|
|
is_lead: bool
|
||
|
|
|
||
|
|
|
||
|
|
def _own(db: Session, unit_id: uuid.UUID, actor: User) -> OrgUnit:
|
||
|
|
unit = (
|
||
|
|
db.query(OrgUnit)
|
||
|
|
.filter(OrgUnit.id == unit_id, OrgUnit.tenant_id == actor.tenant_id)
|
||
|
|
.first()
|
||
|
|
)
|
||
|
|
if unit is None:
|
||
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="No such unit")
|
||
|
|
return unit
|
||
|
|
|
||
|
|
|
||
|
|
def _target(db: Session, user_id: uuid.UUID, actor: User) -> User:
|
||
|
|
"""A person in the caller's workspace, and inside their administrative scope.
|
||
|
|
|
||
|
|
Both checks, and both answer 404: a scoped administrator must not be able to
|
||
|
|
tell "outside my branch" from "does not exist", or the refusal becomes a way
|
||
|
|
to enumerate the workspace.
|
||
|
|
"""
|
||
|
|
target = (
|
||
|
|
db.query(User)
|
||
|
|
.filter(User.id == user_id, User.tenant_id == actor.tenant_id,
|
||
|
|
User.deleted_at.is_(None))
|
||
|
|
.first()
|
||
|
|
)
|
||
|
|
if target is None:
|
||
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="User not found")
|
||
|
|
units.assert_may_administer(db, actor, target)
|
||
|
|
return target
|
||
|
|
|
||
|
|
|
||
|
|
def _workspace(actor: User) -> uuid.UUID:
|
||
|
|
if actor.tenant_id is None:
|
||
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||
|
|
detail="Account is not associated with a workspace")
|
||
|
|
return actor.tenant_id
|
||
|
|
|
||
|
|
|
||
|
|
def _to_response(unit: OrgUnit) -> OrgUnitResponse:
|
||
|
|
payload = OrgUnitResponse.model_validate(unit)
|
||
|
|
payload.depth = unit.depth
|
||
|
|
return payload
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("", response_model=List[OrgUnitResponse])
|
||
|
|
def list_units(
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
current_user: User = Depends(get_current_user),
|
||
|
|
_=Depends(require_access("admin.user.read")),
|
||
|
|
):
|
||
|
|
"""The whole tree, parents before children.
|
||
|
|
|
||
|
|
Ordered on `path`, which gives depth-first order for free — a child's path
|
||
|
|
is its parent's plus one segment.
|
||
|
|
"""
|
||
|
|
from app.services.auth import seat_allocation_service
|
||
|
|
|
||
|
|
tenant_id = _workspace(current_user)
|
||
|
|
rows = units.tree(db, tenant_id)
|
||
|
|
|
||
|
|
limits = seat_allocation_service.limits_by_unit(db, tenant_id)
|
||
|
|
used = seat_allocation_service.usage_by_unit(db, tenant_id)
|
||
|
|
|
||
|
|
listing = []
|
||
|
|
for row in rows:
|
||
|
|
item = _to_response(row)
|
||
|
|
item.seat_limit = limits.get(row.id)
|
||
|
|
item.seats_used = used.get(row.id, 0)
|
||
|
|
listing.append(item)
|
||
|
|
return listing
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("", response_model=OrgUnitResponse,
|
||
|
|
status_code=status.HTTP_201_CREATED)
|
||
|
|
def create_unit(
|
||
|
|
payload: OrgUnitCreate,
|
||
|
|
request: Request,
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
current_user: User = Depends(get_current_user),
|
||
|
|
_=Depends(require_access("admin.role.create")),
|
||
|
|
):
|
||
|
|
unit = units.create(
|
||
|
|
db, tenant_id=_workspace(current_user), name=payload.name,
|
||
|
|
code=payload.code, parent_id=payload.parent_id,
|
||
|
|
)
|
||
|
|
AuditLogService.log(
|
||
|
|
db=db, module_name="Organisation", action_type="CREATE",
|
||
|
|
entity_id=str(unit.id), entity_name=unit.name,
|
||
|
|
description="Unit '" + unit.name + "' created",
|
||
|
|
performed_by_id=str(current_user.id),
|
||
|
|
performed_by_email=current_user.email,
|
||
|
|
ip_address=get_client_ip(request),
|
||
|
|
tenant_id=current_user.tenant_id,
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(unit)
|
||
|
|
return _to_response(unit)
|
||
|
|
|
||
|
|
|
||
|
|
@router.put("/{unit_id}", response_model=OrgUnitResponse)
|
||
|
|
def update_unit(
|
||
|
|
unit_id: uuid.UUID,
|
||
|
|
payload: OrgUnitUpdate,
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
current_user: User = Depends(get_current_user),
|
||
|
|
_=Depends(require_access("admin.role.update")),
|
||
|
|
):
|
||
|
|
unit = units.rename(db, _own(db, unit_id, current_user),
|
||
|
|
name=payload.name, code=payload.code)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(unit)
|
||
|
|
return _to_response(unit)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/{unit_id}/move", response_model=OrgUnitResponse)
|
||
|
|
def move_unit(
|
||
|
|
unit_id: uuid.UUID,
|
||
|
|
payload: OrgUnitMove,
|
||
|
|
request: Request,
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
current_user: User = Depends(get_current_user),
|
||
|
|
_=Depends(require_access("admin.role.update")),
|
||
|
|
):
|
||
|
|
"""Re-parent a unit, rewriting every descendant's path.
|
||
|
|
|
||
|
|
The expensive operation on purpose — it happens during a reorganisation, and
|
||
|
|
it is what keeps the permission check to one indexed prefix match on every
|
||
|
|
request an administrator makes.
|
||
|
|
"""
|
||
|
|
unit = _own(db, unit_id, current_user)
|
||
|
|
before = unit.path
|
||
|
|
unit = units.move(db, unit, payload.parent_id)
|
||
|
|
|
||
|
|
AuditLogService.log(
|
||
|
|
db=db, module_name="Organisation", action_type="UPDATE",
|
||
|
|
entity_id=str(unit.id), entity_name=unit.name,
|
||
|
|
description="Unit '" + unit.name + "' moved",
|
||
|
|
performed_by_id=str(current_user.id),
|
||
|
|
performed_by_email=current_user.email,
|
||
|
|
ip_address=get_client_ip(request),
|
||
|
|
tenant_id=current_user.tenant_id,
|
||
|
|
old_values={"path": before}, new_values={"path": unit.path},
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(unit)
|
||
|
|
return _to_response(unit)
|
||
|
|
|
||
|
|
|
||
|
|
@router.delete("/{unit_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
|
|
def delete_unit(
|
||
|
|
unit_id: uuid.UUID,
|
||
|
|
request: Request,
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
current_user: User = Depends(get_current_user),
|
||
|
|
_=Depends(require_access("admin.role.delete")),
|
||
|
|
):
|
||
|
|
"""Refused while anything still depends on it.
|
||
|
|
|
||
|
|
A cascade would silently dissolve a department's whole sub-tree and quietly
|
||
|
|
widen every administrator scoped to one of them.
|
||
|
|
"""
|
||
|
|
unit = _own(db, unit_id, current_user)
|
||
|
|
name = unit.name
|
||
|
|
units.delete(db, unit)
|
||
|
|
|
||
|
|
AuditLogService.log(
|
||
|
|
db=db, module_name="Organisation", action_type="DELETE",
|
||
|
|
entity_id=str(unit_id), entity_name=name,
|
||
|
|
description="Unit '" + name + "' deleted",
|
||
|
|
performed_by_id=str(current_user.id),
|
||
|
|
performed_by_email=current_user.email,
|
||
|
|
ip_address=get_client_ip(request),
|
||
|
|
tenant_id=current_user.tenant_id,
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
|
||
|
|
class SeatAllocationRequest(BaseModel):
|
||
|
|
seat_limit: Optional[int] = Field(default=None, ge=0)
|
||
|
|
|
||
|
|
|
||
|
|
class SeatSummary(BaseModel):
|
||
|
|
purchased: Optional[int] = None
|
||
|
|
allocated: int
|
||
|
|
unallocated: Optional[int] = None
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/seats", response_model=SeatSummary)
|
||
|
|
def seat_summary(
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
current_user: User = Depends(get_current_user),
|
||
|
|
_=Depends(require_access("admin.user.read")),
|
||
|
|
):
|
||
|
|
"""What was bought, what is spoken for, and what is left to give.
|
||
|
|
|
||
|
|
A literal path, declared before `/{unit_id}` would matter if it shared the
|
||
|
|
shape — it does not, but keeping it above the parameterised routes is the
|
||
|
|
habit that stops the next literal path being swallowed.
|
||
|
|
"""
|
||
|
|
from app.services.auth import seat_allocation_service
|
||
|
|
|
||
|
|
return SeatSummary(**seat_allocation_service.summary(db, _workspace(current_user)))
|
||
|
|
|
||
|
|
|
||
|
|
@router.put("/{unit_id}/seats", response_model=SeatSummary)
|
||
|
|
def set_seat_allocation(
|
||
|
|
unit_id: uuid.UUID,
|
||
|
|
payload: SeatAllocationRequest,
|
||
|
|
request: Request,
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
current_user: User = Depends(get_current_user),
|
||
|
|
_=Depends(require_access("admin.role.update")),
|
||
|
|
):
|
||
|
|
"""Cap one unit, or remove its cap.
|
||
|
|
|
||
|
|
Guarded by role management rather than user management: this decides what a
|
||
|
|
branch is allowed to become, which is a shape-of-the-workspace decision
|
||
|
|
rather than an edit to a person.
|
||
|
|
"""
|
||
|
|
from app.services.auth import seat_allocation_service
|
||
|
|
|
||
|
|
tenant_id = _workspace(current_user)
|
||
|
|
unit = _own(db, unit_id, current_user)
|
||
|
|
|
||
|
|
if payload.seat_limit is None:
|
||
|
|
seat_allocation_service.remove_allocation(db, unit.id)
|
||
|
|
described = f"Seat allocation removed from unit '{unit.name}'"
|
||
|
|
else:
|
||
|
|
seat_allocation_service.set_allocation(
|
||
|
|
db, tenant_id=tenant_id, unit=unit, seat_limit=payload.seat_limit
|
||
|
|
)
|
||
|
|
described = (
|
||
|
|
f"Unit '{unit.name}' allocated {payload.seat_limit} seat(s)"
|
||
|
|
)
|
||
|
|
|
||
|
|
AuditLogService.log(
|
||
|
|
db=db, module_name="Organisation", action_type="UPDATE",
|
||
|
|
entity_id=str(unit.id), entity_name=unit.name,
|
||
|
|
description=described,
|
||
|
|
performed_by_id=str(current_user.id),
|
||
|
|
performed_by_email=current_user.email,
|
||
|
|
ip_address=get_client_ip(request),
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
return SeatSummary(**seat_allocation_service.summary(db, tenant_id))
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/{unit_id}/members", response_model=List[MemberResponse])
|
||
|
|
def members(
|
||
|
|
unit_id: uuid.UUID,
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
current_user: User = Depends(get_current_user),
|
||
|
|
_=Depends(require_access("admin.user.read")),
|
||
|
|
):
|
||
|
|
unit = _own(db, unit_id, current_user)
|
||
|
|
rows = (
|
||
|
|
db.query(UserOrgUnit, User)
|
||
|
|
.join(User, User.id == UserOrgUnit.user_id)
|
||
|
|
.filter(UserOrgUnit.org_unit_id == unit.id, User.deleted_at.is_(None))
|
||
|
|
.order_by(User.email)
|
||
|
|
.all()
|
||
|
|
)
|
||
|
|
return [
|
||
|
|
MemberResponse(user_id=user.id, email=user.email,
|
||
|
|
is_primary=membership.is_primary,
|
||
|
|
is_lead=membership.is_lead)
|
||
|
|
for membership, user in rows
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/{unit_id}/members", status_code=status.HTTP_204_NO_CONTENT)
|
||
|
|
def add_member(
|
||
|
|
unit_id: uuid.UUID,
|
||
|
|
payload: MembershipRequest,
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
current_user: User = Depends(get_current_user),
|
||
|
|
_=Depends(require_access("admin.user.update")),
|
||
|
|
):
|
||
|
|
"""Putting somebody in a department is an edit to that person, so it follows
|
||
|
|
user management rather than structure management."""
|
||
|
|
unit = _own(db, unit_id, current_user)
|
||
|
|
target = _target(db, payload.user_id, current_user)
|
||
|
|
units.assign(db, user=target, unit=unit, primary=payload.primary,
|
||
|
|
lead=payload.lead)
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
|
||
|
|
@router.delete("/{unit_id}/members/{user_id}",
|
||
|
|
status_code=status.HTTP_204_NO_CONTENT)
|
||
|
|
def remove_member(
|
||
|
|
unit_id: uuid.UUID,
|
||
|
|
user_id: uuid.UUID,
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
current_user: User = Depends(get_current_user),
|
||
|
|
_=Depends(require_access("admin.user.update")),
|
||
|
|
):
|
||
|
|
unit = _own(db, unit_id, current_user)
|
||
|
|
target = _target(db, user_id, current_user)
|
||
|
|
if not units.unassign(db, user=target, unit=unit):
|
||
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||
|
|
detail="That person is not in this unit")
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/{unit_id}/administrators", status_code=status.HTTP_204_NO_CONTENT)
|
||
|
|
def grant_scope(
|
||
|
|
unit_id: uuid.UUID,
|
||
|
|
payload: MembershipRequest,
|
||
|
|
request: Request,
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
current_user: User = Depends(get_current_user),
|
||
|
|
_=Depends(require_access("admin.role.update")),
|
||
|
|
):
|
||
|
|
"""Confine somebody's user administration to this unit and everything under
|
||
|
|
it.
|
||
|
|
|
||
|
|
Guarded by role management rather than user management: this decides what
|
||
|
|
somebody else is allowed to do, which is a permissions act.
|
||
|
|
|
||
|
|
Note that this **narrows** — it never grants. Somebody with no permission to
|
||
|
|
manage users gains nothing from a scope; somebody with it loses everything
|
||
|
|
outside their scopes.
|
||
|
|
"""
|
||
|
|
unit = _own(db, unit_id, current_user)
|
||
|
|
target = _target(db, payload.user_id, current_user)
|
||
|
|
units.grant_admin_scope(db, user=target, unit=unit)
|
||
|
|
|
||
|
|
AuditLogService.log(
|
||
|
|
db=db, module_name="Organisation", action_type="UPDATE",
|
||
|
|
entity_id=str(target.id), entity_name=target.email,
|
||
|
|
description=("User administration for '" + target.email
|
||
|
|
+ "' scoped to unit '" + unit.name + "'"),
|
||
|
|
performed_by_id=str(current_user.id),
|
||
|
|
performed_by_email=current_user.email,
|
||
|
|
ip_address=get_client_ip(request),
|
||
|
|
tenant_id=current_user.tenant_id,
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
|
||
|
|
|
||
|
|
@router.delete("/{unit_id}/administrators/{user_id}",
|
||
|
|
status_code=status.HTTP_204_NO_CONTENT)
|
||
|
|
def revoke_scope(
|
||
|
|
unit_id: uuid.UUID,
|
||
|
|
user_id: uuid.UUID,
|
||
|
|
request: Request,
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
current_user: User = Depends(get_current_user),
|
||
|
|
_=Depends(require_access("admin.role.update")),
|
||
|
|
):
|
||
|
|
"""Removing somebody's last scope **widens** them back to the whole
|
||
|
|
workspace, because no scopes means unrestricted. That is the correct default
|
||
|
|
— it is what every administrator is today — but it is the opposite of what
|
||
|
|
"revoke" sounds like, so it is audited as a widening."""
|
||
|
|
unit = _own(db, unit_id, current_user)
|
||
|
|
target = _target(db, user_id, current_user)
|
||
|
|
units.revoke_admin_scope(db, user=target, unit=unit)
|
||
|
|
|
||
|
|
remaining = (
|
||
|
|
db.query(UserAdminScope)
|
||
|
|
.filter(UserAdminScope.user_id == target.id)
|
||
|
|
.count()
|
||
|
|
)
|
||
|
|
AuditLogService.log(
|
||
|
|
db=db, module_name="Organisation", action_type="UPDATE",
|
||
|
|
entity_id=str(target.id), entity_name=target.email,
|
||
|
|
description=(
|
||
|
|
"Administration scope on '" + unit.name + "' removed from '"
|
||
|
|
+ target.email + "'"
|
||
|
|
+ ("" if remaining else " — they now administer the whole workspace")
|
||
|
|
),
|
||
|
|
performed_by_id=str(current_user.id),
|
||
|
|
performed_by_email=current_user.email,
|
||
|
|
ip_address=get_client_ip(request),
|
||
|
|
tenant_id=current_user.tenant_id,
|
||
|
|
)
|
||
|
|
db.commit()
|