organizations.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. """Pydantic surface for the Organization API.
  2. Backed by ``principals`` rows where ``kind == ORG``. There is no
  3. dedicated ``organizations`` table — the Organization concept is a
  4. Pydantic-only DTO over ``Principal`` for the public API.
  5. """
  6. import re
  7. from datetime import datetime
  8. from typing import ClassVar, List, Optional
  9. from sqlmodel import Field, SQLModel
  10. from gpustack.schemas.common import ListParams, PaginatedList
  11. from gpustack.schemas.principals import (
  12. PLATFORM_PRINCIPAL_ID,
  13. Principal,
  14. PrincipalType,
  15. )
  16. # Backwards-compatible alias used across the schema/api_keys/models for
  17. # "the platform Org's id". Now resolves to the platform principal id.
  18. PLATFORM_ORGANIZATION_ID = PLATFORM_PRINCIPAL_ID
  19. slug_pattern = r'^[a-z](?:[a-z0-9\-]*[a-z0-9])?$'
  20. # "Personal" is the conceptual user-self namespace (no longer a separate
  21. # Org row); "Global" is the UI label for admin-curated Platform rows
  22. # (e.g. inference backends with owner_principal_id IS NULL). Letting users
  23. # create regular Orgs with these names would collide with built-in UX
  24. # slots. Match case-insensitively after trimming whitespace.
  25. RESERVED_ORG_NAMES = {"personal", "global"}
  26. RESERVED_ORG_SLUGS = {"personal", "global"}
  27. # User-principal slug pattern — keep humans from grabbing the slot of a
  28. # user's auto-generated Personal namespace.
  29. personal_slug_pattern = re.compile(r'^user-\d+$')
  30. def _check_reserved_name(name: str) -> None:
  31. """Raise ValueError if name is reserved for the system."""
  32. if not isinstance(name, str):
  33. raise ValueError("name must be a string")
  34. if name.strip().lower() in RESERVED_ORG_NAMES:
  35. raise ValueError(
  36. f"'{name}' is a reserved organization name; please choose another"
  37. )
  38. def _check_slug_format(slug: str) -> None:
  39. """Raise ValueError if slug fails the formatting / reserved checks."""
  40. if not isinstance(slug, str):
  41. raise ValueError("slug must be a string")
  42. if not re.match(slug_pattern, slug):
  43. raise ValueError(
  44. "slug must be lowercase, start with a letter, only contain "
  45. "letters, numbers, and hyphens, and not end with a hyphen"
  46. )
  47. if slug.lower() in RESERVED_ORG_SLUGS or personal_slug_pattern.match(slug):
  48. raise ValueError(f"'{slug}' is a reserved slug; please choose another")
  49. def validate_org_input(*, name: Optional[str], slug: Optional[str] = None) -> None:
  50. """Validate user-supplied Org create/update payloads."""
  51. if name is not None:
  52. _check_reserved_name(name)
  53. if slug is not None:
  54. _check_slug_format(slug)
  55. class OrganizationUpdate(SQLModel):
  56. name: str = Field(nullable=False)
  57. description: Optional[str] = Field(default=None, nullable=True)
  58. class OrganizationCreate(OrganizationUpdate):
  59. slug: str = Field(nullable=False)
  60. class OrganizationListParams(ListParams):
  61. sortable_fields: ClassVar[List[str]] = [
  62. "name",
  63. "slug",
  64. "created_at",
  65. "updated_at",
  66. ]
  67. class OrganizationPublic(SQLModel):
  68. id: int
  69. name: str
  70. slug: Optional[str] = None
  71. description: Optional[str] = None
  72. # ``is_personal`` is no longer a stored flag — a row is "personal"
  73. # iff it's a USER principal (rendered through this DTO when listing
  74. # me/orgs etc.). The Org listing endpoint filters to ORG kind, so
  75. # this defaults to False there.
  76. is_personal: bool = False
  77. created_at: datetime
  78. updated_at: datetime
  79. @classmethod
  80. def from_principal(cls, p: Principal) -> "OrganizationPublic":
  81. """Render a Principal row as the legacy Organization shape.
  82. For USER principals, surface ``name="Personal"`` so the
  83. OrgSwitcher renders the canonical label instead of the user's
  84. username (which is what's stored on the principal row for
  85. URL-prefix purposes via ``slug=user-{id}``).
  86. """
  87. is_personal = p.kind == PrincipalType.USER
  88. return cls(
  89. id=p.id,
  90. name="Personal" if is_personal else p.name,
  91. slug=p.slug,
  92. description=p.description,
  93. is_personal=is_personal,
  94. created_at=p.created_at,
  95. updated_at=p.updated_at,
  96. )
  97. OrganizationsPublic = PaginatedList[OrganizationPublic]
  98. class OrganizationMembershipPublic(SQLModel):
  99. user_id: int
  100. organization_id: int
  101. role: Optional[str] = None
  102. created_at: datetime
  103. # Server-resolved labels so the UI list doesn't need a separate
  104. # `queryUsersList(page=-1)` round trip just to render names.
  105. username: Optional[str] = None
  106. full_name: Optional[str] = None