"""Pydantic surface for the Organization API. Backed by ``principals`` rows where ``kind == ORG``. There is no dedicated ``organizations`` table — the Organization concept is a Pydantic-only DTO over ``Principal`` for the public API. """ import re from datetime import datetime from typing import ClassVar, List, Optional from sqlmodel import Field, SQLModel from gpustack.schemas.common import ListParams, PaginatedList from gpustack.schemas.principals import ( PLATFORM_PRINCIPAL_ID, Principal, PrincipalType, ) # Backwards-compatible alias used across the schema/api_keys/models for # "the platform Org's id". Now resolves to the platform principal id. PLATFORM_ORGANIZATION_ID = PLATFORM_PRINCIPAL_ID slug_pattern = r'^[a-z](?:[a-z0-9\-]*[a-z0-9])?$' # "Personal" is the conceptual user-self namespace (no longer a separate # Org row); "Global" is the UI label for admin-curated Platform rows # (e.g. inference backends with owner_principal_id IS NULL). Letting users # create regular Orgs with these names would collide with built-in UX # slots. Match case-insensitively after trimming whitespace. RESERVED_ORG_NAMES = {"personal", "global"} RESERVED_ORG_SLUGS = {"personal", "global"} # User-principal slug pattern — keep humans from grabbing the slot of a # user's auto-generated Personal namespace. personal_slug_pattern = re.compile(r'^user-\d+$') def _check_reserved_name(name: str) -> None: """Raise ValueError if name is reserved for the system.""" if not isinstance(name, str): raise ValueError("name must be a string") if name.strip().lower() in RESERVED_ORG_NAMES: raise ValueError( f"'{name}' is a reserved organization name; please choose another" ) def _check_slug_format(slug: str) -> None: """Raise ValueError if slug fails the formatting / reserved checks.""" if not isinstance(slug, str): raise ValueError("slug must be a string") if not re.match(slug_pattern, slug): raise ValueError( "slug must be lowercase, start with a letter, only contain " "letters, numbers, and hyphens, and not end with a hyphen" ) if slug.lower() in RESERVED_ORG_SLUGS or personal_slug_pattern.match(slug): raise ValueError(f"'{slug}' is a reserved slug; please choose another") def validate_org_input(*, name: Optional[str], slug: Optional[str] = None) -> None: """Validate user-supplied Org create/update payloads.""" if name is not None: _check_reserved_name(name) if slug is not None: _check_slug_format(slug) class OrganizationUpdate(SQLModel): name: str = Field(nullable=False) description: Optional[str] = Field(default=None, nullable=True) class OrganizationCreate(OrganizationUpdate): slug: str = Field(nullable=False) class OrganizationListParams(ListParams): sortable_fields: ClassVar[List[str]] = [ "name", "slug", "created_at", "updated_at", ] class OrganizationPublic(SQLModel): id: int name: str slug: Optional[str] = None description: Optional[str] = None # ``is_personal`` is no longer a stored flag — a row is "personal" # iff it's a USER principal (rendered through this DTO when listing # me/orgs etc.). The Org listing endpoint filters to ORG kind, so # this defaults to False there. is_personal: bool = False created_at: datetime updated_at: datetime @classmethod def from_principal(cls, p: Principal) -> "OrganizationPublic": """Render a Principal row as the legacy Organization shape. For USER principals, surface ``name="Personal"`` so the OrgSwitcher renders the canonical label instead of the user's username (which is what's stored on the principal row for URL-prefix purposes via ``slug=user-{id}``). """ is_personal = p.kind == PrincipalType.USER return cls( id=p.id, name="Personal" if is_personal else p.name, slug=p.slug, description=p.description, is_personal=is_personal, created_at=p.created_at, updated_at=p.updated_at, ) OrganizationsPublic = PaginatedList[OrganizationPublic] class OrganizationMembershipPublic(SQLModel): user_id: int organization_id: int role: Optional[str] = None created_at: datetime # Server-resolved labels so the UI list doesn't need a separate # `queryUsersList(page=-1)` round trip just to render names. username: Optional[str] = None full_name: Optional[str] = None