Skip to content

Commit 0500eae

Browse files
committed
Add team specific product list
This listing allows admins to see which products a team is subscribed to.
1 parent 91218f8 commit 0500eae

File tree

4 files changed

+214
-6
lines changed

4 files changed

+214
-6
lines changed

app/api/products.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from fastapi import APIRouter, Depends, HTTPException, status
22
from sqlalchemy.orm import Session
3-
from typing import List
3+
from typing import List, Optional
44
from datetime import datetime, UTC
55

66
from app.db.database import get_db
7-
from app.db.models import DBProduct, DBTeamProduct
7+
from app.db.models import DBProduct, DBTeamProduct, DBTeam
88
from app.core.security import check_system_admin, get_current_user_from_auth, get_role_min_team_admin
99
from app.schemas.models import Product, ProductCreate, ProductUpdate
1010

@@ -55,11 +55,38 @@ async def create_product(
5555
@router.get("", response_model=List[Product], dependencies=[Depends(get_role_min_team_admin)])
5656
@router.get("/", response_model=List[Product], dependencies=[Depends(get_role_min_team_admin)])
5757
async def list_products(
58-
db: Session = Depends(get_db)
58+
team_id: Optional[int] = None,
59+
db: Session = Depends(get_db),
60+
current_user = Depends(get_current_user_from_auth)
5961
):
6062
"""
6163
List all products. Only accessible by team admin users or higher privileges.
64+
If team_id is provided, only returns products associated with that team.
65+
Team admins can only view products for their own team.
6266
"""
67+
# If team_id is provided, verify the user has access to that team
68+
if team_id is not None:
69+
# First check if the team exists
70+
team = db.query(DBTeam).filter(DBTeam.id == team_id).first()
71+
if not team:
72+
raise HTTPException(
73+
status_code=status.HTTP_404_NOT_FOUND,
74+
detail="Team not found"
75+
)
76+
77+
# System admins can view any team's products
78+
if not current_user.is_admin:
79+
# Team admins can only view their own team's products
80+
if current_user.team_id != team_id:
81+
raise HTTPException(
82+
status_code=status.HTTP_403_FORBIDDEN,
83+
detail="You can only view products for your own team"
84+
)
85+
86+
# Get products associated with the team
87+
return db.query(DBProduct).join(DBTeamProduct).filter(DBTeamProduct.team_id == team_id).all()
88+
89+
# If no team_id provided, return all products
6390
return db.query(DBProduct).all()
6491

6592
@router.get("/{product_id}", response_model=Product, dependencies=[Depends(get_role_min_team_admin)])

frontend/src/app/admin/teams/page.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,22 @@ interface TeamUser {
5555
role: string;
5656
}
5757

58+
interface Product {
59+
id: string;
60+
name: string;
61+
user_count: number;
62+
keys_per_user: number;
63+
total_key_count: number;
64+
service_key_count: number;
65+
max_budget_per_key: number;
66+
rpm_per_key: number;
67+
vector_db_count: number;
68+
vector_db_storage: number;
69+
renewal_period_days: number;
70+
active: boolean;
71+
created_at: string;
72+
}
73+
5874
interface Team {
5975
id: string;
6076
name: string;
@@ -65,6 +81,7 @@ interface Team {
6581
created_at: string;
6682
updated_at: string;
6783
users?: TeamUser[];
84+
products?: Product[];
6885
}
6986

7087
interface User {
@@ -120,6 +137,17 @@ export default function TeamsPage() {
120137
enabled: !!expandedTeamId,
121138
});
122139

140+
// Get products for expanded team
141+
const { data: teamProducts = [], isLoading: isLoadingTeamProducts } = useQuery<Product[]>({
142+
queryKey: ['team-products', expandedTeamId],
143+
queryFn: async () => {
144+
if (!expandedTeamId) return [];
145+
const response = await get(`/products?team_id=${expandedTeamId}`);
146+
return response.json();
147+
},
148+
enabled: !!expandedTeamId,
149+
});
150+
123151
// Search users query
124152
const searchUsersMutation = useMutation({
125153
mutationFn: async (query: string) => {
@@ -540,6 +568,7 @@ export default function TeamsPage() {
540568
<TabsList>
541569
<TabsTrigger value="details">Team Details</TabsTrigger>
542570
<TabsTrigger value="users">Users</TabsTrigger>
571+
<TabsTrigger value="products">Products</TabsTrigger>
543572
</TabsList>
544573
<TabsContent value="details" className="mt-4">
545574
<Card>
@@ -659,6 +688,60 @@ export default function TeamsPage() {
659688
</div>
660689
)}
661690
</TabsContent>
691+
<TabsContent value="products" className="mt-4">
692+
<div className="space-y-4">
693+
{isLoadingTeamProducts ? (
694+
<div className="flex justify-center items-center py-8">
695+
<Loader2 className="h-8 w-8 animate-spin" />
696+
</div>
697+
) : teamProducts.length > 0 ? (
698+
<div className="rounded-md border">
699+
<Table>
700+
<TableHeader>
701+
<TableRow>
702+
<TableHead>Name</TableHead>
703+
<TableHead>User Count</TableHead>
704+
<TableHead>Keys/User</TableHead>
705+
<TableHead>Total Keys</TableHead>
706+
<TableHead>Service Keys</TableHead>
707+
<TableHead>Budget/Key</TableHead>
708+
<TableHead>RPM/Key</TableHead>
709+
<TableHead>Vector DBs</TableHead>
710+
<TableHead>Storage (GiB)</TableHead>
711+
<TableHead>Renewal (Days)</TableHead>
712+
<TableHead>Status</TableHead>
713+
</TableRow>
714+
</TableHeader>
715+
<TableBody>
716+
{teamProducts.map((product) => (
717+
<TableRow key={product.id}>
718+
<TableCell>{product.name}</TableCell>
719+
<TableCell>{product.user_count}</TableCell>
720+
<TableCell>{product.keys_per_user}</TableCell>
721+
<TableCell>{product.total_key_count}</TableCell>
722+
<TableCell>{product.service_key_count}</TableCell>
723+
<TableCell>${product.max_budget_per_key.toFixed(2)}</TableCell>
724+
<TableCell>{product.rpm_per_key}</TableCell>
725+
<TableCell>{product.vector_db_count}</TableCell>
726+
<TableCell>{product.vector_db_storage}</TableCell>
727+
<TableCell>{product.renewal_period_days}</TableCell>
728+
<TableCell>
729+
<Badge variant={product.active ? "default" : "destructive"}>
730+
{product.active ? "Active" : "Inactive"}
731+
</Badge>
732+
</TableCell>
733+
</TableRow>
734+
))}
735+
</TableBody>
736+
</Table>
737+
</div>
738+
) : (
739+
<div className="text-center py-8 border rounded-md">
740+
<p className="text-muted-foreground">No products associated with this team.</p>
741+
</div>
742+
)}
743+
</div>
744+
</TabsContent>
662745
</Tabs>
663746
</div>
664747
) : (

frontend/src/app/team-admin/pricing/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ declare module 'react' {
1515

1616
declare module 'react/jsx-runtime' {
1717
interface Element {
18-
'stripe-pricing-table': any;
18+
'stripe-pricing-table': HTMLElement;
1919
}
2020
}
2121

@@ -74,7 +74,7 @@ export default function PricingPage() {
7474
</div>
7575
<Script src="https://js.stripe.com/v3/pricing-table.js" strategy="afterInteractive" />
7676
{clientSecret && (
77-
// @ts-ignore
77+
// @ts-expect-error - Stripe pricing table is a custom element
7878
<stripe-pricing-table
7979
pricing-table-id="prctbl_1RRqUhPszKsC9PNiI6av2bXK"
8080
publishable-key="pk_test_51RRqG1PszKsC9PNicexnqtXn94fTB1MQXbGxApaEojDe81ZtouhTXDzN8Jgg44DBiHvMjGA5aQSvTZ1Q4N4uLl9i00rhEbJpHm"

tests/test_products.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,4 +409,102 @@ def test_delete_product_with_team_association(client, admin_token, db, test_team
409409

410410
# Verify the product still exists
411411
existing_product = db.query(DBProduct).filter(DBProduct.id == test_product.id).first()
412-
assert existing_product is not None
412+
assert existing_product is not None
413+
414+
def test_list_products_by_team_as_system_admin(client, admin_token, db, test_team, test_product):
415+
"""
416+
Test that a system admin can list products for any team.
417+
418+
GIVEN: The authenticated user is a system admin
419+
WHEN: They request products for a specific team
420+
THEN: A 200 - OK is returned with the team's products
421+
"""
422+
# Associate the product with the team
423+
team_product = DBTeamProduct(
424+
team_id=test_team.id,
425+
product_id=test_product.id
426+
)
427+
db.add(team_product)
428+
db.commit()
429+
430+
response = client.get(
431+
f"/products/?team_id={test_team.id}",
432+
headers={"Authorization": f"Bearer {admin_token}"}
433+
)
434+
assert response.status_code == 200
435+
data = response.json()
436+
assert len(data) == 1
437+
assert data[0]["id"] == test_product.id
438+
assert data[0]["name"] == test_product.name
439+
440+
def test_list_products_by_team_as_team_admin(client, team_admin_token, db, test_team, test_product):
441+
"""
442+
Test that a team admin can list products for their own team.
443+
444+
GIVEN: The authenticated user is a team admin
445+
WHEN: They request products for their own team
446+
THEN: A 200 - OK is returned with their team's products
447+
"""
448+
# Associate the product with the team
449+
team_product = DBTeamProduct(
450+
team_id=test_team.id,
451+
product_id=test_product.id
452+
)
453+
db.add(team_product)
454+
db.commit()
455+
456+
response = client.get(
457+
f"/products/?team_id={test_team.id}",
458+
headers={"Authorization": f"Bearer {team_admin_token}"}
459+
)
460+
assert response.status_code == 200
461+
data = response.json()
462+
assert len(data) == 1
463+
assert data[0]["id"] == test_product.id
464+
assert data[0]["name"] == test_product.name
465+
466+
def test_list_products_by_other_team_as_team_admin(client, team_admin_token, db, test_team, test_product):
467+
"""
468+
Test that a team admin cannot list products for another team.
469+
470+
GIVEN: The authenticated user is a team admin
471+
WHEN: They request products for another team
472+
THEN: A 403 - Forbidden is returned
473+
"""
474+
# Create another team
475+
other_team = DBTeam(
476+
name="Other Team",
477+
admin_email="other@example.com"
478+
)
479+
db.add(other_team)
480+
db.commit()
481+
482+
# Associate the product with the other team
483+
team_product = DBTeamProduct(
484+
team_id=other_team.id,
485+
product_id=test_product.id
486+
)
487+
db.add(team_product)
488+
db.commit()
489+
490+
response = client.get(
491+
f"/products/?team_id={other_team.id}",
492+
headers={"Authorization": f"Bearer {team_admin_token}"}
493+
)
494+
assert response.status_code == 403
495+
assert "own team" in response.json()["detail"].lower()
496+
497+
def test_list_products_by_nonexistent_team(client, admin_token, db):
498+
"""
499+
Test that listing products for a nonexistent team returns 404.
500+
501+
GIVEN: The authenticated user is a system admin
502+
WHEN: They request products for a nonexistent team
503+
THEN: A 404 - Not Found is returned
504+
"""
505+
response = client.get(
506+
"/products/?team_id=999999",
507+
headers={"Authorization": f"Bearer {admin_token}"}
508+
)
509+
assert response.status_code == 404
510+
assert "Team not found" in response.json()["detail"]

0 commit comments

Comments
 (0)