diff --git a/litellm/proxy/management_endpoints/management_v1/customers.py b/litellm/proxy/management_endpoints/management_v1/customers.py index f451344f8cc..9f063f50155 100644 --- a/litellm/proxy/management_endpoints/management_v1/customers.py +++ b/litellm/proxy/management_endpoints/management_v1/customers.py @@ -471,7 +471,9 @@ async def get_customer_member( @router.patch( "/customer_members/{customer_member_id}", response_model=CustomerMember, - description="Update a customer member and its budgets. Proxy admins only.", + description="Update a customer member and its budgets. Supply parent_team_ids to replace its teams within the " + "same organization, with the first team as default. Identity, budget counters and historical usage are retained. " + "Historical usage stays attributed to the original team. Proxy admins only.", ) @management_endpoint_wrapper async def update_customer_member( diff --git a/litellm/proxy/management_helpers/customer_hierarchy.py b/litellm/proxy/management_helpers/customer_hierarchy.py index d2a687a08cb..97993517d8f 100644 --- a/litellm/proxy/management_helpers/customer_hierarchy.py +++ b/litellm/proxy/management_helpers/customer_hierarchy.py @@ -29,6 +29,7 @@ from litellm.types.proxy.management_endpoints.customer_hierarchy import ( BudgetTemplate, CustomerMember, MemberCreate, + MemberFields, MemberPatch, Organization, OrganizationBudget, @@ -371,7 +372,7 @@ def team_response(row: PrismaRecord, now: datetime) -> Team: def override_expiry( - row: Team | CustomerMember | None, payload: TeamPatch | MemberPatch, override_id: str | None + row: Team | CustomerMember | None, payload: TeamPatch | MemberFields, override_id: str | None ) -> datetime | None: if override_id is None: return None @@ -995,9 +996,20 @@ class CustomerHierarchyStore: return await self.update_budget(current_id, payload.budget_override, actor) async def update_member(self, member_id: str, payload: MemberPatch, actor: str) -> CustomerMember | Failure: + if payload.parent_team_ids is not None: + await self.lock_customer_identities() member: Final = await self.get_member(member_id) if isinstance(member, Failure): return member + if payload.parent_team_ids is not None: + teams: Final = await find_many_in(self.teams, "customer_team_id", payload.parent_team_ids) + if len(teams) != len(payload.parent_team_ids): + return Failure(ErrorCode.PARENT_NOT_FOUND) + if any(team.model_dump()["parent_org_id"] != member.parent_org_id for team in teams): + return Failure(ErrorCode.CUSTOMER_ORG_MISMATCH) + added: Final = tuple(team for team in payload.parent_team_ids if team not in member.parent_team_ids) + if await self.member_capacity_reached(added): + return Failure(ErrorCode.FREE_TIER_LIMIT) selected: Final = await self.member_template_patch(member, payload) if isinstance(selected, Failure): return selected @@ -1012,6 +1024,7 @@ class CustomerHierarchyStore: where={"customer_member_id": member_id}, data={ **alias_patch(payload), + **({"parent_team_ids": payload.parent_team_ids} if payload.parent_team_ids is not None else {}), **({"budget_id": selected} if "budget_id" in payload.model_fields_set else {}), **( { @@ -1154,6 +1167,7 @@ class CustomerHierarchyStore: return None async def delete_team(self, team_id: str, *, cascade: bool = False) -> None | Failure: + await self.lock_customer_identities() team: Final = await self.get_team(team_id) if isinstance(team, Failure): return team @@ -1166,6 +1180,7 @@ class CustomerHierarchyStore: return None async def delete_organization(self, organization_id: str, *, cascade: bool = False) -> None | Failure: + await self.lock_customer_identities() organization: Final = await self.get_organization(organization_id) if isinstance(organization, Failure): return organization diff --git a/litellm/types/proxy/management_endpoints/customer_hierarchy.py b/litellm/types/proxy/management_endpoints/customer_hierarchy.py index 5550e2c1f81..0835a31ae10 100644 --- a/litellm/types/proxy/management_endpoints/customer_hierarchy.py +++ b/litellm/types/proxy/management_endpoints/customer_hierarchy.py @@ -2,7 +2,7 @@ from collections.abc import Mapping from datetime import datetime from hashlib import sha256 from json import dumps -from typing import Final, Literal, TypeAlias +from typing import Annotated, Final, Literal, TypeAlias from pydantic import ( AwareDatetime, @@ -14,6 +14,7 @@ from pydantic import ( field_validator, model_validator, ) +from pydantic.json_schema import SkipJsonSchema from litellm.models.budget import BudgetLimits from litellm.proxy.common_utils.timezone_utils import budget_duration_error @@ -116,7 +117,7 @@ class TeamCreate(TeamPatch): parent_org_id: str -class MemberPatch(BaseModel): +class MemberFields(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") alias: str | None = None @@ -125,13 +126,28 @@ class MemberPatch(BaseModel): budget_override_expires_at: AwareDatetime | None = None -class MemberCreate(MemberPatch): +class MemberPatch(MemberFields): + parent_team_ids: Annotated[tuple[str, ...], Field(min_length=1)] | SkipJsonSchema[None] = Field( + default=None, + description="Replace the member's teams within its existing organization. First team is the default. " + "Omit to keep current teams. Member identity, budgets and historical usage are retained.", + ) + + @field_validator("parent_team_ids") + @classmethod + def valid_parent_teams(cls, value: tuple[str, ...] | None) -> tuple[str, ...]: + if value is None or any(not team.strip() for team in value) or len(set(value)) != len(value): + raise ValueError("parent_team_ids must contain unique, nonempty team IDs") + return value + + +class MemberCreate(MemberFields): customer_member_id: str | None = None parent_org_id: str | None = Field(default=None, description="Must match the parent teams' organization when set") parent_team_ids: tuple[str, ...] = Field(min_length=1, description="Teams the member belongs to, first is default") -class MemberUpsert(MemberPatch): +class MemberUpsert(MemberFields): customer_member_id: str = Field(min_length=1) customer_org_id: str | None = None customer_org_alias: str | None = None diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyManager.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyManager.tsx index 8ed6fec1ea5..5b8b6bb0c05 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyManager.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomerHierarchyManager.tsx @@ -1,11 +1,19 @@ "use client"; import { useState } from "react"; -import { ChevronRight, Pencil, Plus } from "lucide-react"; +import { ChevronRight, MoreHorizontal, Pencil, Plus } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; import { ErrorState } from "@/components/shared/ErrorState"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import MoveCustomerMemberDialog from "./MoveCustomerMemberDialog"; +import type { Member } from "./customerHierarchyForms"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { extractProxyErrorMessage } from "@/lib/http/client"; import CustomerHierarchyNavigation, { useCustomerHierarchyLinks } from "./CustomerHierarchyNavigation"; @@ -126,6 +134,7 @@ function hierarchyRows({ hierarchy, selected, scope }: ScopeProps) { entity: customerUsageEntity(team.customer_team_id, team), open: () => hierarchy.openTeam(team.customer_team_id), edit: () => hierarchy.openTeam(team.customer_team_id, true), + member: null, parentTeamId: null, openParentTeam: () => hierarchy.openTeam(team.customer_team_id), usageScope: { org: scope.org, team: team.customer_team_id, member: null }, @@ -137,6 +146,7 @@ function hierarchyRows({ hierarchy, selected, scope }: ScopeProps) { entity: customerUsageEntity(member.customer_member_id, member), open: () => hierarchy.openMember(member.customer_member_id, member.parent_team_ids[0] ?? ""), edit: () => hierarchy.openMember(member.customer_member_id, member.parent_team_ids[0] ?? ""), + member, parentTeamId: member.parent_team_ids[0] ?? "", openParentTeam: () => hierarchy.openTeam(member.parent_team_ids[0] ?? ""), usageScope: { org: scope.org, team: selected.team?.customer_team_id ?? null, member: member.customer_member_id }, @@ -178,6 +188,7 @@ function HierarchyCreateAction({ hierarchy, selected, scope }: ScopeProps) { function HierarchyChildTable(props: ScopeProps) { const { customerHierarchyHref } = useCustomerHierarchyLinks(); + const [movingMember, setMovingMember] = useState(null); const { t } = useTranslation(); const { hierarchy } = props; const { children, childUsage, childTab, page } = hierarchy; @@ -272,16 +283,38 @@ function HierarchyChildTable(props: ScopeProps) { > {t("customerPortal.hierarchy.viewUsage")} - + {row.member ? ( + + + } + > + + + + {t("customerPortal.hierarchy.settings")} + setMovingMember(row.member)}> + {t("customerPortal.hierarchy.moveTeams")} + + + + ) : ( + + )} @@ -299,6 +332,13 @@ function HierarchyChildTable(props: ScopeProps) { )} + {movingMember && ( + setMovingMember(null)} + /> + )} {pages > 1 && (
{t("customerPortal.hierarchy.pagination", { page, pages })} diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/MoveCustomerMemberDialog.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/MoveCustomerMemberDialog.tsx new file mode 100644 index 00000000000..c484f39f027 --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/MoveCustomerMemberDialog.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { fetchClient } from "@/lib/http/api"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { extractProxyErrorMessage } from "@/lib/http/client"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { Team, Member } from "./customerHierarchyForms"; + +async function teamChoices(organizationId: string, signal: AbortSignal, page = 1): Promise { + if (page > 1_000) throw new Error("Customer team list is incomplete"); + const response = await fetchClient.GET("/management/v1/customer_teams", { + params: { query: { parent_org_id: organizationId, page, page_size: 100 } }, + signal, + }); + if (!response.data) throw new Error("Missing customer teams response"); + if (page >= response.data.meta.total_pages) return response.data.data; + return [...response.data.data, ...(await teamChoices(organizationId, signal, page + 1))]; +} + +function CustomerMemberTeams({ + organizationId, + value, + onChange, +}: { + organizationId: string; + value: readonly string[]; + onChange: (value: string[]) => void; +}) { + const { t } = useTranslation(); + const teams = useQuery({ + queryKey: ["customer-hierarchy", "team-choices", organizationId], + queryFn: ({ signal }) => teamChoices(organizationId, signal), + }); + const options = (teams.data ?? []).map((team) => ({ + value: team.customer_team_id, + label: team.alias || team.customer_team_id, + description: team.alias ? team.customer_team_id : undefined, + })); + const label = (id: string) => options.find((option) => option.value === id)?.label ?? id; + return ( +
+

{t("customerPortal.hierarchy.memberTeamsHint")}

+
1 ? "grid gap-4 sm:grid-cols-2" : "space-y-4"}> +
+ + +
+ {value.length > 1 && ( +
+ + +

{t("customerPortal.hierarchy.defaultTeamHint")}

+
+ )} +
+ {teams.isError && ( +
+ {t("customerPortal.hierarchy.teamsLoadError")} + +
+ )} +
+ ); +} + +function MoveForm({ + member, + organizationId, + onClose, + pending, + setPending, +}: { + member: Member; + organizationId: string; + onClose: () => void; + pending: boolean; + setPending: (pending: boolean) => void; +}) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [teams, setTeams] = useState(member.parent_team_ids); + const [error, setError] = useState(null); + const dirty = JSON.stringify(teams) !== JSON.stringify(member.parent_team_ids); + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!teams.length || !dirty) return; + setPending(true); + setError(null); + try { + await fetchClient.PATCH("/management/v1/customer_members/{customer_member_id}", { + params: { path: { customer_member_id: member.customer_member_id } }, + body: { parent_team_ids: teams }, + }); + void queryClient.invalidateQueries({ queryKey: ["customer-hierarchy"] }); + void queryClient.invalidateQueries({ queryKey: ["get", "/management/v1/customer_portal/limits"] }); + toast.success(t("customerPortal.hierarchy.memberMoved")); + onClose(); + } catch (cause) { + setError(extractProxyErrorMessage(cause)); + } finally { + setPending(false); + } + }; + return ( + +
void submit(event)}> + + {t("customerPortal.hierarchy.moveTeams")} + {member.alias || member.customer_member_id} + +
+ +
+ {!teams.length && ( +

+ {t("customerPortal.hierarchy.teamsRequired")} +

+ )} + {error && ( +

+ {error} +

+ )} + + + + +
+
+ ); +} + +export default function MoveCustomerMemberDialog({ + member, + organizationId, + onClose, +}: { + member: Member; + organizationId: string; + onClose: () => void; +}) { + const [pending, setPending] = useState(false); + return ( + { + if (!open && !pending) onClose(); + }} + > + + + ); +} diff --git a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/manage/page.integration.test.tsx b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/manage/page.integration.test.tsx index 93b42b3d5c3..d13bd6098ab 100644 --- a/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/manage/page.integration.test.tsx +++ b/litellm/ui/litellm-dashboard/src/app/(dashboard)/customers/manage/page.integration.test.tsx @@ -117,6 +117,36 @@ function BrowserNavigationProbe() { } describe("customer hierarchy management", () => { + it("moves a member through its row menu without editing budgets", async () => { + const user = userEvent.setup(); + const moved = { ...member, parent_team_ids: ["team-b"] }; + fetchMock.mockImplementation(async (input, init) => { + const url = requestUrl(input); + const method = input instanceof Request ? input.method : init?.method; + if (url.pathname === "/management/v1/customer_members/member-a" && method === "PATCH") return json(moved); + return serve(input); + }); + const options = { searchParams: "?org=org-1&tab=members" }; + renderWithProviders(, options); + await user.click(await screen.findByRole("button", { name: "Actions for Member One" })); + await user.keyboard("{ArrowDown}"); + await user.click(await screen.findByRole("menuitem", { name: "Move teams" })); + const dialog = screen.getByRole("dialog", { name: "Move teams" }); + const teams = within(dialog).getByRole("combobox", { name: "Choose teams" }); + await waitFor(() => expect(teams).toBeEnabled()); + await user.click(teams); + await user.click(screen.getByRole("option", { name: /Operations/ })); + await user.click(screen.getByRole("option", { name: /Product/ })); + await user.keyboard("{Escape}"); + await user.click(within(dialog).getByRole("button", { name: "Move teams" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + const write = fetchMock.mock.calls + .map(([input]) => input) + .find((input) => input instanceof Request && input.method === "PATCH"); + expect(write).toBeInstanceOf(Request); + expect(await (write as Request).clone().json()).toEqual({ parent_team_ids: ["team-b"] }); + }); + it.each(["teams", "members"] as const)( "should show usage-style budgets, spend share, tokens and actions for customer %s", async (kind) => { @@ -195,7 +225,9 @@ describe("customer hierarchy management", () => { ); expect(within(table).getByRole("cell", { name: "250" })).toBeInTheDocument(); expect(within(table).getByText("Within budget")).toBeInTheDocument(); - expect(within(table).getByRole("button", { name: `Edit ${label}` })).toBeEnabled(); + expect( + within(table).getByRole("button", { name: kind === "teams" ? `Edit ${label}` : `Actions for ${label}` }), + ).toBeEnabled(); const validRow = within(table).getByRole("row", { name: new RegExp(label) }); expect(within(validRow).getByRole("link", { name: "View usage" })).toHaveAttribute( "href", @@ -480,7 +512,9 @@ describe("customer hierarchy management", () => { .find((url) => url.pathname === "/management/v1/customer_members") ?.searchParams.get("parent_team_id"), ).toBe("team-a"); - await user.click(within(members).getByRole("button", { name: "Edit Member One" })); + await user.click(within(members).getByRole("button", { name: "Actions for Member One" })); + await user.keyboard("{ArrowDown}"); + await user.click(await screen.findByRole("menuitem", { name: "Settings" })); expect(await screen.findByRole("heading", { name: "Member One" })).toBeInTheDocument(); expect(screen.getByText("Manage settings and budget for this customer member.")).toBeInTheDocument(); const usage = screen.getByRole("link", { name: "Usage" }); diff --git a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json index e8c979032ac..37107f05a2e 100644 --- a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json @@ -20775,7 +20775,17 @@ "deleteOrganizationMessage": "This deletes this customer organization and its customer teams and customer members.", "deleteTeamMessage": "This deletes this customer team and its customer members.", "deleteMemberMessage": "This deletes this customer member.", - "deleteInformationTitle": "{{kind}} details" + "deleteInformationTitle": "{{kind}} details", + "teamsRequired": "Select at least one team.", + "memberTeamsHint": "Changing teams keeps this member’s ID, budgets and spend history. Past usage stays with the original team.", + "defaultTeam": "Default team", + "defaultTeamHint": "Requests without a team use this team.", + "retryTeams": "Retry", + "chooseTeams": "Choose teams", + "moveTeams": "Move teams", + "moving": "Moving…", + "memberMoved": "Member teams updated.", + "memberActions": "Actions for {{name}}" }, "switcher": { "label": "Customer Management", diff --git a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts index a115de0fdd0..75d6c28959c 100644 --- a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -9761,7 +9761,7 @@ export interface paths { head?: never; /** * Update Customer Member - * @description Update a customer member and its budgets. Proxy admins only. + * @description Update a customer member and its budgets. Supply parent_team_ids to replace its teams within the same organization, with the first team as default. Identity, budget counters and historical usage are retained. Historical usage stays attributed to the original team. Proxy admins only. */ patch: operations["update_customer_member_management_v1_customer_members__customer_member_id__patch"]; trace?: never; @@ -40893,6 +40893,11 @@ export interface components { budget_override?: components["schemas"]["Budget"] | null; /** Budget Override Expires At */ budget_override_expires_at?: string | null; + /** + * Parent Team Ids + * @description Replace the member's teams within its existing organization. First team is the default. Omit to keep current teams. Member identity, budgets and historical usage are retained. + */ + parent_team_ids?: string[]; }; /** MemberUpsert */ MemberUpsert: { diff --git a/tests/unit/proxy/management_endpoints/management_v1/test_customers.py b/tests/unit/proxy/management_endpoints/management_v1/test_customers.py index 64126c6df52..1f86013eb70 100644 --- a/tests/unit/proxy/management_endpoints/management_v1/test_customers.py +++ b/tests/unit/proxy/management_endpoints/management_v1/test_customers.py @@ -1931,3 +1931,114 @@ def test_list_and_detail_expose_each_effective_budget_with_its_own_recorded_spen assert detail.status_code == listing.status_code == 200 assert listing.json()["data"][0]["budget_usage"] == detail.json()["budget_usage"] assert [(item["spend"], item["max_budget"]) for item in detail.json()["budget_usage"]] == [(0, 50), (8, 10)] + +def test_member_team_replacement_retains_identity_budgets_and_spend() -> None: + database: Final = Database() + cache: Final = UserApiKeyCache() + http: Final = client(database=database, cache=cache) + family(http) + assert ( + http.post( + "/management/v1/customer_teams", json={"customer_team_id": "next", "parent_org_id": "org"} + ).status_code + == 201 + ) + created: Final = http.post( + "/management/v1/customer_members", + json={ + "customer_member_id": "moving", + "parent_team_ids": ["team"], + "budget_override": {"max_budget": 25, "budget_duration": "1mo"}, + }, + ) + assert created.status_code == 201, created.text + asyncio.run( + database.litellm_customermembertable.update(where={"customer_member_id": "moving"}, data={"spend": 7.5}) + ) + original: Final = http.get("/management/v1/customer_members/moving").json() + cache.set_cache(customer_member_cache_key("moving"), "old-team") + moved: Final = http.patch("/management/v1/customer_members/moving", json={"parent_team_ids": ["next"]}) + assert moved.status_code == 200, moved.text + assert moved.json() == {**original, "parent_team_ids": ["next"]} + assert cache.get_cache(customer_member_cache_key("moving")) is None + assert http.get("/management/v1/customer_members", params={"parent_team_id": "team"}).json()["data"] == [] + assert [ + m["customer_member_id"] + for m in http.get("/management/v1/customer_members", params={"parent_team_id": "next"}).json()["data"] + ] == ["moving"] + renamed: Final = http.patch("/management/v1/customer_members/moving", json={"alias": "Renamed"}) + assert renamed.status_code == 200, renamed.text + assert renamed.json()["parent_team_ids"] == ["next"] + + +@pytest.mark.parametrize( + "teams,status", + ((None, 422), ([], 422), ([""], 422), (["team", "team"], 422), (["missing"], 404), (["foreign"], 409)), +) +def test_member_team_replacement_rejects_invalid_parents_atomically(teams: object, status: int) -> None: + http: Final = client() + family(http) + assert http.post("/management/v1/customer_organizations", json={"customer_org_id": "other"}).status_code == 201 + assert ( + http.post( + "/management/v1/customer_teams", json={"customer_team_id": "foreign", "parent_org_id": "other"} + ).status_code + == 201 + ) + original: Final = http.post( + "/management/v1/customer_members", json={"customer_member_id": "moving", "parent_team_ids": ["team"]} + ) + assert original.status_code == 201, original.text + rejected: Final = http.patch( + "/management/v1/customer_members/moving", json={"parent_team_ids": teams, "alias": "Must not change"} + ) + assert rejected.status_code == status, rejected.text + assert http.get("/management/v1/customer_members/moving").json() == original.json() + + +def test_member_team_replacement_schema_allows_omission_but_not_null() -> None: + http: Final = client() + response: Final = http.get("/openapi.json") + assert response.status_code == 200, response.text + spec: Final = response.json() + request_schema: Final = spec["paths"]["/management/v1/customer_members/{customer_member_id}"]["patch"][ + "requestBody" + ]["content"]["application/json"]["schema"] + member_patch: Final = spec["components"]["schemas"][request_schema["$ref"].rsplit("/", 1)[-1]] + parent_teams: Final = member_patch["properties"]["parent_team_ids"] + assert parent_teams["type"] == "array" + assert parent_teams["items"] == {"type": "string"} + assert "parent_team_ids" not in member_patch.get("required", ()) + + +@pytest.mark.parametrize("premium", (False, True)) +def test_member_team_replacement_checks_only_added_team_capacity(premium: bool) -> None: + http: Final = client(premium_user=premium) + family(http) + assert ( + http.post( + "/management/v1/customer_teams", json={"customer_team_id": "next", "parent_org_id": "org"} + ).status_code + == 201 + ) + for index in range(5): + assert ( + http.post( + "/management/v1/customer_members", + json={"customer_member_id": f"full-{index}", "parent_team_ids": ["team"]}, + ).status_code + == 201 + ) + moved: Final = http.patch("/management/v1/customer_members/full-0", json={"parent_team_ids": ["team", "next"]}) + assert moved.status_code == 200, moved.text + reordered: Final = http.patch("/management/v1/customer_members/full-0", json={"parent_team_ids": ["next", "team"]}) + assert reordered.status_code == 200, reordered.text + assert reordered.json()["parent_team_ids"] == ["next", "team"] + assert ( + http.post( + "/management/v1/customer_members", json={"customer_member_id": "moving", "parent_team_ids": ["next"]} + ).status_code + == 201 + ) + full: Final = http.patch("/management/v1/customer_members/moving", json={"parent_team_ids": ["team"]}) + assert full.status_code == (200 if premium else 403), full.text