feat(customers): move existing members between teams (#51913)

This commit is contained in:
moe-berri 2026-11-15 12:22:02 +08:00 • committed by GitHub
parent c3b929d203
commit 00313c9e5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 455 additions and 21 deletions

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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<Member | null>(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")}
</a>
<Button
type="button"
variant="ghost"
size="sm"
onClick={row.edit}
aria-label={`${t("customerPortal.hierarchy.edit")} ${row.label}`}
>
<Pencil aria-hidden="true" className="size-3.5" />
{t("customerPortal.hierarchy.edit")}
</Button>
{row.member ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t("customerPortal.hierarchy.memberActions", { name: row.label })}
/>
}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={row.edit}>{t("customerPortal.hierarchy.settings")}</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMovingMember(row.member)}>
{t("customerPortal.hierarchy.moveTeams")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
type="button"
variant="ghost"
size="sm"
onClick={row.edit}
aria-label={`${t("customerPortal.hierarchy.edit")} ${row.label}`}
>
<Pencil aria-hidden="true" className="size-3.5" />
{t("customerPortal.hierarchy.edit")}
</Button>
)}
</div>
</TableCell>
</TableRow>
@ -299,6 +332,13 @@ function HierarchyChildTable(props: ScopeProps) {
)}
</TableBody>
</Table>
{movingMember && (
<MoveCustomerMemberDialog
member={movingMember}
organizationId={props.scope.org}
onClose={() => setMovingMember(null)}
/>
)}
{pages > 1 && (
<div className="mt-4 flex items-center justify-between border-t pt-4 text-xs text-muted-foreground">
<span>{t("customerPortal.hierarchy.pagination", { page, pages })}</span>

View file

@ -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<Team[]> {
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 (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("customerPortal.hierarchy.memberTeamsHint")}</p>
<div className={value.length > 1 ? "grid gap-4 sm:grid-cols-2" : "space-y-4"}>
<div className="space-y-1.5">
<Label htmlFor="member-teams">{t("customerPortal.hierarchy.teams")}</Label>
<MultiSelect
id="member-teams"
options={options}
value={[...value]}
onValueChange={onChange}
loading={teams.isLoading}
disabled={!teams.isSuccess}
placeholder={t("customerPortal.hierarchy.chooseTeams")}
/>
</div>
{value.length > 1 && (
<div className="space-y-1.5">
<Label htmlFor="member-default-team">{t("customerPortal.hierarchy.defaultTeam")}</Label>
<Select
value={value[0]}
onValueChange={(id) => id && onChange([id, ...value.filter((team) => team !== id)])}
>
<SelectTrigger id="member-default-team" className="w-full">
<SelectValue>{label(value[0])}</SelectValue>
</SelectTrigger>
<SelectContent>
{value.map((id) => (
<SelectItem key={id} value={id}>
{label(id)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("customerPortal.hierarchy.defaultTeamHint")}</p>
</div>
)}
</div>
{teams.isError && (
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
{t("customerPortal.hierarchy.teamsLoadError")}
<Button type="button" variant="link" size="sm" onClick={() => void teams.refetch()}>
{t("customerPortal.hierarchy.retryTeams")}
</Button>
</div>
)}
</div>
);
}
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<string | null>(null);
const dirty = JSON.stringify(teams) !== JSON.stringify(member.parent_team_ids);
const submit = async (event: FormEvent<HTMLFormElement>) => {
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 (
<DialogContent className="sm:max-w-xl" showCloseButton={!pending}>
<form className="space-y-5" onSubmit={(event) => void submit(event)}>
<DialogHeader>
<DialogTitle>{t("customerPortal.hierarchy.moveTeams")}</DialogTitle>
<DialogDescription>{member.alias || member.customer_member_id}</DialogDescription>
</DialogHeader>
<fieldset disabled={pending}>
<CustomerMemberTeams organizationId={organizationId} value={teams} onChange={setTeams} />
</fieldset>
{!teams.length && (
<p role="alert" className="text-sm text-destructive">
{t("customerPortal.hierarchy.teamsRequired")}
</p>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button type="button" variant="outline" disabled={pending} onClick={onClose}>
{t("customerPortal.hierarchy.cancel")}
</Button>
<Button type="submit" disabled={pending || !dirty || !teams.length}>
{t(pending ? "customerPortal.hierarchy.moving" : "customerPortal.hierarchy.moveTeams")}
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export default function MoveCustomerMemberDialog({
member,
organizationId,
onClose,
}: {
member: Member;
organizationId: string;
onClose: () => void;
}) {
const [pending, setPending] = useState(false);
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !pending) onClose();
}}
>
<MoveForm
member={member}
organizationId={organizationId}
onClose={onClose}
pending={pending}
setPending={setPending}
/>
</Dialog>
);
}

View file

@ -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(<CustomerHierarchyManager />, 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" });

View file

@ -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",

View file

@ -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: {

View file

@ -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