From 60e47f768a23a9b139e2d716450a746e6e86f702 Mon Sep 17 00:00:00 2001 From: Pippa H Date: Thu, 5 Jun 2025 16:02:00 +0200 Subject: [PATCH 1/2] Add pricing tables API functionality - Introduced a new `pricing_tables` module with endpoints for creating, retrieving, and deleting pricing tables, accessible based on user roles. - Updated the main application to include the new pricing tables router. - Added corresponding schemas for pricing table creation and response. - Implemented tests to validate the functionality and access control for pricing table operations. --- app/api/pricing_tables.py | 90 +++++++++++++ app/main.py | 3 +- app/schemas/models.py | 9 ++ tests/test_pricing_tables.py | 238 +++++++++++++++++++++++++++++++++++ 4 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 app/api/pricing_tables.py create mode 100644 tests/test_pricing_tables.py diff --git a/app/api/pricing_tables.py b/app/api/pricing_tables.py new file mode 100644 index 0000000..35eb782 --- /dev/null +++ b/app/api/pricing_tables.py @@ -0,0 +1,90 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from datetime import datetime, UTC + +from app.db.database import get_db +from app.db.models import DBSystemSecret +from app.core.security import check_system_admin, get_role_min_team_admin +from app.schemas.models import PricingTableCreate, PricingTableResponse + +router = APIRouter( + tags=["pricing-tables"] +) + +@router.post("", response_model=PricingTableResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(check_system_admin)]) +@router.post("/", response_model=PricingTableResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(check_system_admin)]) +async def create_pricing_table( + pricing_table: PricingTableCreate, + db: Session = Depends(get_db) +): + """ + Create or update the current pricing table. Only accessible by system admin users. + There can only be one active pricing table at a time. + """ + # Check if a pricing table already exists + existing_table = db.query(DBSystemSecret).filter(DBSystemSecret.key == "CurrentPricingTable").first() + + if existing_table: + # Update existing table + existing_table.value = pricing_table.pricing_table_id + existing_table.updated_at = datetime.now(UTC) + db.commit() + db.refresh(existing_table) + return PricingTableResponse( + pricing_table_id=existing_table.value, + updated_at=existing_table.updated_at + ) + else: + # Create new table + db_table = DBSystemSecret( + key="CurrentPricingTable", + value=pricing_table.pricing_table_id, + description="Current Stripe pricing table ID", + created_at=datetime.now(UTC) + ) + db.add(db_table) + db.commit() + db.refresh(db_table) + return PricingTableResponse( + pricing_table_id=db_table.value, + updated_at=db_table.created_at + ) + +@router.get("", response_model=PricingTableResponse, dependencies=[Depends(get_role_min_team_admin)]) +@router.get("/", response_model=PricingTableResponse, dependencies=[Depends(get_role_min_team_admin)]) +async def get_pricing_table( + db: Session = Depends(get_db) +): + """ + Get the current pricing table ID. Only accessible by team admin users or higher privileges. + """ + pricing_table = db.query(DBSystemSecret).filter(DBSystemSecret.key == "CurrentPricingTable").first() + if not pricing_table: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No pricing table found" + ) + return PricingTableResponse( + pricing_table_id=pricing_table.value, + updated_at=pricing_table.updated_at or pricing_table.created_at + ) + +@router.delete("", dependencies=[Depends(check_system_admin)]) +@router.delete("/", dependencies=[Depends(check_system_admin)]) +async def delete_pricing_table( + db: Session = Depends(get_db) +): + """ + Delete the current pricing table. Only accessible by system admin users. + """ + pricing_table = db.query(DBSystemSecret).filter(DBSystemSecret.key == "CurrentPricingTable").first() + if not pricing_table: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No pricing table found" + ) + + db.delete(pricing_table) + db.commit() + + return {"message": "Pricing table deleted successfully"} \ No newline at end of file diff --git a/app/main.py b/app/main.py index df2029a..a7eebbe 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from fastapi.openapi.docs import get_swagger_ui_html from fastapi.openapi.utils import get_openapi from prometheus_fastapi_instrumentator import Instrumentator, metrics -from app.api import auth, private_ai_keys, users, regions, audit, teams, billing, products +from app.api import auth, private_ai_keys, users, regions, audit, teams, billing, products, pricing_tables from app.core.config import settings from app.db.database import get_db from app.middleware.audit import AuditLogMiddleware @@ -134,6 +134,7 @@ async def health_check(): app.include_router(teams.router, prefix="/teams", tags=["teams"]) app.include_router(billing.router, prefix="/billing", tags=["billing"]) app.include_router(products.router, prefix="/products", tags=["products"]) +app.include_router(pricing_tables.router, prefix="/pricing-tables", tags=["pricing-tables"]) @app.get("/", include_in_schema=False) async def custom_swagger_ui_html(): diff --git a/app/schemas/models.py b/app/schemas/models.py index a7b9b72..e106134 100644 --- a/app/schemas/models.py +++ b/app/schemas/models.py @@ -311,4 +311,13 @@ class Product(ProductBase): class PricingTableSession(BaseModel): client_secret: str + model_config = ConfigDict(from_attributes=True) + +class PricingTableCreate(BaseModel): + pricing_table_id: str + model_config = ConfigDict(from_attributes=True) + +class PricingTableResponse(BaseModel): + pricing_table_id: str + updated_at: datetime model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/tests/test_pricing_tables.py b/tests/test_pricing_tables.py new file mode 100644 index 0000000..d269718 --- /dev/null +++ b/tests/test_pricing_tables.py @@ -0,0 +1,238 @@ +import pytest +from fastapi import status +from datetime import datetime, UTC +from app.db.models import DBSystemSecret, DBUser +from app.core.security import get_password_hash + +def test_create_pricing_table_system_admin(client, db, test_admin): + """Test that a system admin can create a pricing table""" + # Login as system admin + response = client.post( + "/auth/login", + data={"username": test_admin.email, "password": "adminpassword"} + ) + assert response.status_code == status.HTTP_200_OK + token = response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # Create pricing table + response = client.post( + "/pricing-tables", + json={"pricing_table_id": "test_pricing_table_123"}, + headers=headers + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["pricing_table_id"] == "test_pricing_table_123" + assert "updated_at" in data + + # Verify in database + pricing_table = db.query(DBSystemSecret).filter( + DBSystemSecret.key == "CurrentPricingTable" + ).first() + assert pricing_table is not None + assert pricing_table.value == "test_pricing_table_123" + +def test_create_pricing_table_team_admin(client, db, test_team_admin): + """Test that a team admin cannot create a pricing table""" + # Login as team admin + response = client.post( + "/auth/login", + data={"username": test_team_admin.email, "password": "password123"} + ) + assert response.status_code == status.HTTP_200_OK + token = response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # Try to create pricing table + response = client.post( + "/pricing-tables", + json={"pricing_table_id": "test_pricing_table_123"}, + headers=headers + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + +def test_get_pricing_table_team_admin(client, db, test_team_admin): + """Test that a team admin can get the pricing table""" + # Create pricing table as system admin + system_admin = DBUser( + email="system_admin@test.com", + hashed_password=get_password_hash("testpassword"), + is_admin=True + ) + db.add(system_admin) + db.commit() + + # Create pricing table + pricing_table = DBSystemSecret( + key="CurrentPricingTable", + value="test_pricing_table_123", + description="Test pricing table", + created_at=datetime.now(UTC) + ) + db.add(pricing_table) + db.commit() + + # Login as team admin + response = client.post( + "/auth/login", + data={"username": test_team_admin.email, "password": "password123"} + ) + assert response.status_code == status.HTTP_200_OK + token = response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # Get pricing table + response = client.get("/pricing-tables", headers=headers) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["pricing_table_id"] == "test_pricing_table_123" + assert "updated_at" in data + +def test_get_pricing_table_not_found(client, db, test_team_admin): + """Test getting pricing table when none exists""" + # Login as team admin + response = client.post( + "/auth/login", + data={"username": test_team_admin.email, "password": "password123"} + ) + assert response.status_code == status.HTTP_200_OK + token = response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # Try to get pricing table + response = client.get("/pricing-tables", headers=headers) + assert response.status_code == status.HTTP_404_NOT_FOUND + +def test_delete_pricing_table_system_admin(client, db, test_admin): + """Test that a system admin can delete the pricing table""" + # Create pricing table + pricing_table = DBSystemSecret( + key="CurrentPricingTable", + value="test_pricing_table_123", + description="Test pricing table", + created_at=datetime.now(UTC) + ) + db.add(pricing_table) + db.commit() + + # Login as system admin + response = client.post( + "/auth/login", + data={"username": test_admin.email, "password": "adminpassword"} + ) + assert response.status_code == status.HTTP_200_OK + token = response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # Delete pricing table + response = client.delete("/pricing-tables", headers=headers) + assert response.status_code == status.HTTP_200_OK + assert response.json()["message"] == "Pricing table deleted successfully" + + # Verify deleted from database + pricing_table = db.query(DBSystemSecret).filter( + DBSystemSecret.key == "CurrentPricingTable" + ).first() + assert pricing_table is None + +def test_delete_pricing_table_team_admin(client, db, test_team_admin): + """Test that a team admin cannot delete the pricing table""" + # Create pricing table + pricing_table = DBSystemSecret( + key="CurrentPricingTable", + value="test_pricing_table_123", + description="Test pricing table", + created_at=datetime.now(UTC) + ) + db.add(pricing_table) + db.commit() + + # Login as team admin + response = client.post( + "/auth/login", + data={"username": test_team_admin.email, "password": "password123"} + ) + assert response.status_code == status.HTTP_200_OK + token = response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # Try to delete pricing table + response = client.delete("/pricing-tables", headers=headers) + assert response.status_code == status.HTTP_403_FORBIDDEN + +def test_update_existing_pricing_table(client, db, test_admin): + """Test updating an existing pricing table""" + # Create initial pricing table + pricing_table = DBSystemSecret( + key="CurrentPricingTable", + value="initial_pricing_table", + description="Test pricing table", + created_at=datetime.now(UTC) + ) + db.add(pricing_table) + db.commit() + + # Login as system admin + response = client.post( + "/auth/login", + data={"username": test_admin.email, "password": "adminpassword"} + ) + assert response.status_code == status.HTTP_200_OK + token = response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # Update pricing table + response = client.post( + "/pricing-tables", + json={"pricing_table_id": "updated_pricing_table"}, + headers=headers + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["pricing_table_id"] == "updated_pricing_table" + assert "updated_at" in data + + # Verify only one pricing table exists with updated value + pricing_tables = db.query(DBSystemSecret).filter( + DBSystemSecret.key == "CurrentPricingTable" + ).all() + assert len(pricing_tables) == 1 + assert pricing_tables[0].value == "updated_pricing_table" + +def test_key_creator_cannot_access_pricing_table(client, db, test_team_key_creator): + """Test that a key_creator cannot access the pricing table""" + # Create pricing table as system admin + pricing_table = DBSystemSecret( + key="CurrentPricingTable", + value="test_pricing_table_123", + description="Test pricing table", + created_at=datetime.now(UTC) + ) + db.add(pricing_table) + db.commit() + + # Login as key_creator + response = client.post( + "/auth/login", + data={"username": test_team_key_creator.email, "password": "password123"} + ) + assert response.status_code == status.HTTP_200_OK + token = response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # Try to get pricing table + response = client.get("/pricing-tables", headers=headers) + assert response.status_code == status.HTTP_403_FORBIDDEN + + # Try to create pricing table + response = client.post( + "/pricing-tables", + json={"pricing_table_id": "new_pricing_table"}, + headers=headers + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + # Try to delete pricing table + response = client.delete("/pricing-tables", headers=headers) + assert response.status_code == status.HTTP_403_FORBIDDEN \ No newline at end of file From 4b53c375faec95788dff3b025ee0877916c95277 Mon Sep 17 00:00:00 2001 From: Pippa H Date: Thu, 5 Jun 2025 16:20:35 +0200 Subject: [PATCH 2/2] Enhance pricing table management in admin interface - Added functionality to manage pricing tables, including creating, updating, and deleting pricing table entries. - Integrated pricing table data fetching using React Query for improved state management and error handling. - Updated the admin product management page to include a dialog for managing pricing tables, allowing users to input and display relevant pricing table information. - Enhanced error handling for missing Stripe configuration and pricing table loading failures. --- frontend/next.config.ts | 4 +- frontend/src/app/admin/products/page.tsx | 357 ++++++++++++------- frontend/src/app/team-admin/pricing/page.tsx | 29 +- 3 files changed, 261 insertions(+), 129 deletions(-) diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e9ffa30..bab6d9e 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + env: { + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY, + }, }; export default nextConfig; diff --git a/frontend/src/app/admin/products/page.tsx b/frontend/src/app/admin/products/page.tsx index 2399499..5c7947e 100644 --- a/frontend/src/app/admin/products/page.tsx +++ b/frontend/src/app/admin/products/page.tsx @@ -39,13 +39,20 @@ interface Product { created_at: string; } +interface PricingTable { + pricing_table_id: string; + updated_at: string; +} + export default function ProductsPage() { const { toast } = useToast(); const queryClient = useQueryClient(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isPricingTableDialogOpen, setIsPricingTableDialogOpen] = useState(false); const [selectedProduct, setSelectedProduct] = useState(null); const [formData, setFormData] = useState>({}); + const [pricingTableId, setPricingTableId] = useState(''); // Update form data const updateFormData = (newData: Partial) => { @@ -61,6 +68,15 @@ export default function ProductsPage() { }, }); + // Pricing Table Queries + const { data: pricingTable } = useQuery({ + queryKey: ['pricing-table'], + queryFn: async () => { + const response = await get('/pricing-tables'); + return response.json(); + }, + }); + // Mutations const createProductMutation = useMutation({ mutationFn: async (productData: Partial) => { @@ -129,6 +145,50 @@ export default function ProductsPage() { }, }); + // Pricing Table Mutations + const updatePricingTableMutation = useMutation({ + mutationFn: async (pricingTableId: string) => { + const response = await post('/pricing-tables', { pricing_table_id: pricingTableId }); + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pricing-table'] }); + setIsPricingTableDialogOpen(false); + setPricingTableId(''); + toast({ + title: "Success", + description: "Pricing table updated successfully" + }); + }, + onError: (error: Error) => { + toast({ + variant: "destructive", + title: "Error", + description: error.message + }); + }, + }); + + const deletePricingTableMutation = useMutation({ + mutationFn: async () => { + await del('/pricing-tables'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pricing-table'] }); + toast({ + title: "Success", + description: "Pricing table deleted successfully" + }); + }, + onError: (error: Error) => { + toast({ + variant: "destructive", + title: "Error", + description: error.message + }); + }, + }); + const handleCreate = () => { createProductMutation.mutate(formData); }; @@ -143,137 +203,188 @@ export default function ProductsPage() { deleteProductMutation.mutate(id); }; + const handleUpdatePricingTable = () => { + updatePricingTableMutation.mutate(pricingTableId); + }; + + const handleDeletePricingTable = () => { + if (!confirm('Are you sure you want to delete the current pricing table?')) return; + deletePricingTableMutation.mutate(); + }; + return (

Product Management

- - - - - - - Create New Product - -
-
- - updateFormData({ ...formData, id: e.target.value })} - /> -
-
- - updateFormData({ ...formData, name: e.target.value })} - /> -
-
- - updateFormData({ ...formData, user_count: parseInt(e.target.value) })} - /> -
-
- - updateFormData({ ...formData, keys_per_user: parseInt(e.target.value) })} - /> -
-
- - updateFormData({ ...formData, total_key_count: parseInt(e.target.value) })} - /> -
-
- - updateFormData({ ...formData, service_key_count: parseInt(e.target.value) })} - /> -
-
- - updateFormData({ ...formData, max_budget_per_key: parseFloat(e.target.value) })} - /> -
-
- - updateFormData({ ...formData, rpm_per_key: parseInt(e.target.value) })} - /> -
-
- - updateFormData({ ...formData, vector_db_count: parseInt(e.target.value) })} - /> -
-
- - updateFormData({ ...formData, vector_db_storage: parseInt(e.target.value) })} - /> -
-
- - updateFormData({ ...formData, renewal_period_days: parseInt(e.target.value) })} - /> +
+ + + + + + + Manage Pricing Table + +
+
+ + setPricingTableId(e.target.value)} + /> +
+ {pricingTable && ( +
+ Current pricing table: {pricingTable.pricing_table_id} +
+ Last updated: {new Date(pricingTable.updated_at).toLocaleString()} +
+ )} +
+ + +
-
-
- updateFormData({ ...formData, active: e.target.checked })} - className="h-4 w-4 rounded border-gray-300" + +
+ + + + + + + Create New Product + +
+
+ + updateFormData({ ...formData, id: e.target.value })} /> - +
+
+ + updateFormData({ ...formData, name: e.target.value })} + /> +
+
+ + updateFormData({ ...formData, user_count: parseInt(e.target.value) })} + /> +
+
+ + updateFormData({ ...formData, keys_per_user: parseInt(e.target.value) })} + /> +
+
+ + updateFormData({ ...formData, total_key_count: parseInt(e.target.value) })} + /> +
+
+ + updateFormData({ ...formData, service_key_count: parseInt(e.target.value) })} + /> +
+
+ + updateFormData({ ...formData, max_budget_per_key: parseFloat(e.target.value) })} + /> +
+
+ + updateFormData({ ...formData, rpm_per_key: parseInt(e.target.value) })} + /> +
+
+ + updateFormData({ ...formData, vector_db_count: parseInt(e.target.value) })} + /> +
+
+ + updateFormData({ ...formData, vector_db_storage: parseInt(e.target.value) })} + /> +
+
+ + updateFormData({ ...formData, renewal_period_days: parseInt(e.target.value) })} + /> +
+
+
+ updateFormData({ ...formData, active: e.target.checked })} + className="h-4 w-4 rounded border-gray-300" + /> + +
-
-
- -
- -
+
+ +
+ + +
diff --git a/frontend/src/app/team-admin/pricing/page.tsx b/frontend/src/app/team-admin/pricing/page.tsx index d79a293..8ca14ea 100644 --- a/frontend/src/app/team-admin/pricing/page.tsx +++ b/frontend/src/app/team-admin/pricing/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { useAuth } from '@/hooks/use-auth'; import { get, post } from '@/utils/api'; import Script from 'next/script'; +import { useQuery } from '@tanstack/react-query'; declare module 'react' { interface HTMLAttributes extends AriaAttributes, DOMAttributes { @@ -25,11 +26,25 @@ declare global { } } +interface PricingTable { + pricing_table_id: string; + updated_at: string; +} + export default function PricingPage() { const { user } = useAuth(); const [clientSecret, setClientSecret] = useState(null); const [error, setError] = useState(null); + // Fetch pricing table ID + const { data: pricingTable, error: pricingTableError } = useQuery({ + queryKey: ['pricing-table'], + queryFn: async () => { + const response = await get('/pricing-tables'); + return response.json(); + }, + }); + useEffect(() => { const fetchSessionToken = async () => { try { @@ -57,8 +72,12 @@ export default function PricingPage() { } }; - if (error) { - return
{error}
; + if (error || pricingTableError) { + return
{error || 'Failed to load pricing table. Please try again later.'}
; + } + + if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { + return
Stripe configuration is missing. Please contact support.
; } return ( @@ -73,11 +92,11 @@ export default function PricingPage() {