From 4d0dad8e2b3ab84981e3a31a4958d7eb0808fb9d Mon Sep 17 00:00:00 2001 From: counterweight Date: Fri, 26 Dec 2025 20:13:24 +0100 Subject: [PATCH] Step 3: Add admin API endpoints for pricing configuration - Add PricingConfigResponse and PricingConfigUpdate schemas - Create PricingService with validation logic - Add GET and PUT endpoints in routes/pricing.py - Add MANAGE_PRICING permission to admin role - Register pricing router in main.py - Add comprehensive API tests for permissions and validation --- backend/main.py | 2 + backend/models/enums.py | 1 + backend/models/role_config.py | 1 + backend/routes/pricing.py | 33 +++ backend/schemas/__init__.py | 5 + backend/schemas/pricing.py | 27 +++ backend/services/pricing.py | 106 +++++++++ backend/tests/test_pricing_api.py | 359 ++++++++++++++++++++++++++++++ 8 files changed, 534 insertions(+) create mode 100644 backend/routes/pricing.py create mode 100644 backend/schemas/pricing.py create mode 100644 backend/services/pricing.py create mode 100644 backend/tests/test_pricing_api.py diff --git a/backend/main.py b/backend/main.py index 545ecb8..f037bab 100644 --- a/backend/main.py +++ b/backend/main.py @@ -16,6 +16,7 @@ from routes import availability as availability_routes from routes import exchange as exchange_routes from routes import invites as invites_routes from routes import meta as meta_routes +from routes import pricing as pricing_routes from routes import profile as profile_routes from routes import test as test_routes from shared_constants import PRICE_REFRESH_SECONDS @@ -91,6 +92,7 @@ app.include_router(auth_routes.router) app.include_router(audit_routes.router) app.include_router(profile_routes.router) app.include_router(availability_routes.router) +app.include_router(pricing_routes.router) app.include_router(meta_routes.router) app.include_router(test_routes.router) diff --git a/backend/models/enums.py b/backend/models/enums.py index 57d3e64..e691e19 100644 --- a/backend/models/enums.py +++ b/backend/models/enums.py @@ -22,6 +22,7 @@ class Permission(str, PyEnum): # Availability/Exchange permissions (admin) MANAGE_AVAILABILITY = "manage_availability" + MANAGE_PRICING = "manage_pricing" VIEW_ALL_EXCHANGES = "view_all_exchanges" CANCEL_ANY_EXCHANGE = "cancel_any_exchange" COMPLETE_EXCHANGE = "complete_exchange" diff --git a/backend/models/role_config.py b/backend/models/role_config.py index d4cd0f7..a622210 100644 --- a/backend/models/role_config.py +++ b/backend/models/role_config.py @@ -14,6 +14,7 @@ ROLE_DEFINITIONS: dict[str, RoleConfig] = { Permission.FETCH_PRICE, Permission.MANAGE_INVITES, Permission.MANAGE_AVAILABILITY, + Permission.MANAGE_PRICING, Permission.VIEW_ALL_EXCHANGES, Permission.CANCEL_ANY_EXCHANGE, Permission.COMPLETE_EXCHANGE, diff --git a/backend/routes/pricing.py b/backend/routes/pricing.py new file mode 100644 index 0000000..1881ad7 --- /dev/null +++ b/backend/routes/pricing.py @@ -0,0 +1,33 @@ +"""Pricing routes for admin to manage pricing configuration.""" + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from auth import require_permission +from database import get_db +from models import Permission, User +from schemas import PricingConfigResponse, PricingConfigUpdate +from services.pricing import PricingService + +router = APIRouter(prefix="/api/admin/pricing", tags=["pricing"]) + + +@router.get("", response_model=PricingConfigResponse) +async def get_pricing_config( + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(require_permission(Permission.MANAGE_PRICING)), +) -> PricingConfigResponse: + """Get current pricing configuration.""" + service = PricingService(db) + return await service.get_config() + + +@router.put("", response_model=PricingConfigResponse) +async def update_pricing_config( + request: PricingConfigUpdate, + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(require_permission(Permission.MANAGE_PRICING)), +) -> PricingConfigResponse: + """Update pricing configuration.""" + service = PricingService(db) + return await service.update_config(request) diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py index 48081b0..9ab468b 100644 --- a/backend/schemas/__init__.py +++ b/backend/schemas/__init__.py @@ -44,6 +44,9 @@ from .price import ( PriceResponse, ) +# Export pricing schemas +from .pricing import PricingConfigResponse, PricingConfigUpdate + # Export profile schemas from .profile import ProfileResponse, ProfileUpdate @@ -73,6 +76,8 @@ __all__ = [ "PaginatedResponse", "PriceHistoryResponse", "PriceResponse", + "PricingConfigResponse", + "PricingConfigUpdate", "ProfileResponse", "ProfileUpdate", "RecordT", diff --git a/backend/schemas/pricing.py b/backend/schemas/pricing.py new file mode 100644 index 0000000..2c4b527 --- /dev/null +++ b/backend/schemas/pricing.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel + + +class PricingConfigResponse(BaseModel): + """Response model for pricing configuration.""" + + premium_buy: int + premium_sell: int + small_trade_threshold_eur: int + small_trade_extra_premium: int + eur_min_buy: int + eur_max_buy: int + eur_min_sell: int + eur_max_sell: int + + +class PricingConfigUpdate(BaseModel): + """Request model for updating pricing configuration.""" + + premium_buy: int + premium_sell: int + small_trade_threshold_eur: int + small_trade_extra_premium: int + eur_min_buy: int + eur_max_buy: int + eur_min_sell: int + eur_max_sell: int diff --git a/backend/services/pricing.py b/backend/services/pricing.py new file mode 100644 index 0000000..1fd4012 --- /dev/null +++ b/backend/services/pricing.py @@ -0,0 +1,106 @@ +"""Pricing service for managing pricing configuration.""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from exceptions import BadRequestError, NotFoundError +from repositories.pricing import PricingRepository +from schemas import PricingConfigResponse, PricingConfigUpdate + + +class PricingService: + """Service for pricing configuration business logic.""" + + def __init__(self, db: AsyncSession): + self.db = db + self.repo = PricingRepository(db) + + async def get_config(self) -> PricingConfigResponse: + """Get current pricing configuration. + + Returns: + PricingConfigResponse with current config + + Raises: + NotFoundError: If no config exists + """ + config = await self.repo.get_current() + if config is None: + raise NotFoundError("Pricing configuration not found") + + return PricingConfigResponse( + premium_buy=config.premium_buy, + premium_sell=config.premium_sell, + small_trade_threshold_eur=config.small_trade_threshold_eur, + small_trade_extra_premium=config.small_trade_extra_premium, + eur_min_buy=config.eur_min_buy, + eur_max_buy=config.eur_max_buy, + eur_min_sell=config.eur_min_sell, + eur_max_sell=config.eur_max_sell, + ) + + async def update_config(self, data: PricingConfigUpdate) -> PricingConfigResponse: + """Update pricing configuration with validation. + + Args: + data: Pricing configuration update data + + Returns: + PricingConfigResponse with updated config + + Raises: + BadRequestError: If validation fails + """ + # Validate premium values (-100 to 100) + if not (-100 <= data.premium_buy <= 100): + raise BadRequestError("premium_buy must be between -100 and 100") + if not (-100 <= data.premium_sell <= 100): + raise BadRequestError("premium_sell must be between -100 and 100") + if not (-100 <= data.small_trade_extra_premium <= 100): + raise BadRequestError( + "small_trade_extra_premium must be between -100 and 100" + ) + + # Validate min amounts (positive integers) + if data.eur_min_buy <= 0: + raise BadRequestError("eur_min_buy must be positive") + if data.eur_min_sell <= 0: + raise BadRequestError("eur_min_sell must be positive") + + # Validate max amounts (positive integers) + if data.eur_max_buy <= 0: + raise BadRequestError("eur_max_buy must be positive") + if data.eur_max_sell <= 0: + raise BadRequestError("eur_max_sell must be positive") + + # Validate min < max for both directions + if data.eur_min_buy >= data.eur_max_buy: + raise BadRequestError("eur_min_buy must be less than eur_max_buy") + if data.eur_min_sell >= data.eur_max_sell: + raise BadRequestError("eur_min_sell must be less than eur_max_sell") + + # Validate threshold (positive integer) + if data.small_trade_threshold_eur <= 0: + raise BadRequestError("small_trade_threshold_eur must be positive") + + # Update config + config = await self.repo.create_or_update( + premium_buy=data.premium_buy, + premium_sell=data.premium_sell, + small_trade_threshold_eur=data.small_trade_threshold_eur, + small_trade_extra_premium=data.small_trade_extra_premium, + eur_min_buy=data.eur_min_buy, + eur_max_buy=data.eur_max_buy, + eur_min_sell=data.eur_min_sell, + eur_max_sell=data.eur_max_sell, + ) + + return PricingConfigResponse( + premium_buy=config.premium_buy, + premium_sell=config.premium_sell, + small_trade_threshold_eur=config.small_trade_threshold_eur, + small_trade_extra_premium=config.small_trade_extra_premium, + eur_min_buy=config.eur_min_buy, + eur_max_buy=config.eur_max_buy, + eur_min_sell=config.eur_min_sell, + eur_max_sell=config.eur_max_sell, + ) diff --git a/backend/tests/test_pricing_api.py b/backend/tests/test_pricing_api.py new file mode 100644 index 0000000..f80ea0c --- /dev/null +++ b/backend/tests/test_pricing_api.py @@ -0,0 +1,359 @@ +""" +Pricing API Tests + +Tests for the admin pricing configuration endpoints. +""" + +import pytest + +from repositories.pricing import PricingRepository + + +class TestPricingPermissions: + """Test that only admins can access pricing endpoints.""" + + @pytest.mark.asyncio + async def test_admin_can_get_pricing(self, client_factory, admin_user): + """Admin can access GET pricing endpoint.""" + # Seed pricing config first + async with client_factory.get_db_session() as db: + repo = PricingRepository(db) + await repo.create_or_update( + premium_buy=5, + premium_sell=5, + small_trade_threshold_eur=20000, + small_trade_extra_premium=2, + eur_min_buy=10000, + eur_max_buy=300000, + eur_min_sell=10000, + eur_max_sell=300000, + ) + + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.get("/api/admin/pricing") + + assert response.status_code == 200 + data = response.json() + assert "premium_buy" in data + assert "premium_sell" in data + assert data["premium_buy"] == 5 + + @pytest.mark.asyncio + async def test_regular_user_cannot_get_pricing(self, client_factory, regular_user): + """Regular user cannot access GET pricing endpoint.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.get("/api/admin/pricing") + + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_unauthenticated_cannot_get_pricing(self, client): + """Unauthenticated user cannot access GET pricing endpoint.""" + response = await client.get("/api/admin/pricing") + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_admin_can_update_pricing(self, client_factory, admin_user): + """Admin can access PUT pricing endpoint.""" + # Seed pricing config first + async with client_factory.get_db_session() as db: + repo = PricingRepository(db) + await repo.create_or_update( + premium_buy=5, + premium_sell=5, + small_trade_threshold_eur=20000, + small_trade_extra_premium=2, + eur_min_buy=10000, + eur_max_buy=300000, + eur_min_sell=10000, + eur_max_sell=300000, + ) + + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.put( + "/api/admin/pricing", + json={ + "premium_buy": 7, + "premium_sell": 6, + "small_trade_threshold_eur": 25000, + "small_trade_extra_premium": 3, + "eur_min_buy": 15000, + "eur_max_buy": 350000, + "eur_min_sell": 12000, + "eur_max_sell": 320000, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["premium_buy"] == 7 + assert data["premium_sell"] == 6 + + @pytest.mark.asyncio + async def test_regular_user_cannot_update_pricing( + self, client_factory, regular_user + ): + """Regular user cannot access PUT pricing endpoint.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.put( + "/api/admin/pricing", + json={ + "premium_buy": 7, + "premium_sell": 6, + "small_trade_threshold_eur": 25000, + "small_trade_extra_premium": 3, + "eur_min_buy": 15000, + "eur_max_buy": 350000, + "eur_min_sell": 12000, + "eur_max_sell": 320000, + }, + ) + + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_unauthenticated_cannot_update_pricing(self, client): + """Unauthenticated user cannot access PUT pricing endpoint.""" + response = await client.put( + "/api/admin/pricing", + json={ + "premium_buy": 7, + "premium_sell": 6, + "small_trade_threshold_eur": 25000, + "small_trade_extra_premium": 3, + "eur_min_buy": 15000, + "eur_max_buy": 350000, + "eur_min_sell": 12000, + "eur_max_sell": 320000, + }, + ) + assert response.status_code == 401 + + +class TestPricingValidation: + """Test validation of pricing configuration updates.""" + + @pytest.mark.asyncio + async def test_update_with_valid_data(self, client_factory, admin_user): + """Update with valid data succeeds.""" + # Seed pricing config first + async with client_factory.get_db_session() as db: + repo = PricingRepository(db) + await repo.create_or_update( + premium_buy=5, + premium_sell=5, + small_trade_threshold_eur=20000, + small_trade_extra_premium=2, + eur_min_buy=10000, + eur_max_buy=300000, + eur_min_sell=10000, + eur_max_sell=300000, + ) + + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.put( + "/api/admin/pricing", + json={ + "premium_buy": 10, + "premium_sell": 8, + "small_trade_threshold_eur": 30000, + "small_trade_extra_premium": 5, + "eur_min_buy": 20000, + "eur_max_buy": 400000, + "eur_min_sell": 15000, + "eur_max_sell": 350000, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["premium_buy"] == 10 + assert data["premium_sell"] == 8 + + @pytest.mark.asyncio + async def test_premium_out_of_range_rejected(self, client_factory, admin_user): + """Premium values outside -100 to 100 are rejected.""" + # Seed pricing config first + async with client_factory.get_db_session() as db: + repo = PricingRepository(db) + await repo.create_or_update( + premium_buy=5, + premium_sell=5, + small_trade_threshold_eur=20000, + small_trade_extra_premium=2, + eur_min_buy=10000, + eur_max_buy=300000, + eur_min_sell=10000, + eur_max_sell=300000, + ) + + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Test premium_buy > 100 + response = await client.put( + "/api/admin/pricing", + json={ + "premium_buy": 101, + "premium_sell": 5, + "small_trade_threshold_eur": 20000, + "small_trade_extra_premium": 2, + "eur_min_buy": 10000, + "eur_max_buy": 300000, + "eur_min_sell": 10000, + "eur_max_sell": 300000, + }, + ) + assert response.status_code == 400 + + # Test premium_buy < -100 + response = await client.put( + "/api/admin/pricing", + json={ + "premium_buy": -101, + "premium_sell": 5, + "small_trade_threshold_eur": 20000, + "small_trade_extra_premium": 2, + "eur_min_buy": 10000, + "eur_max_buy": 300000, + "eur_min_sell": 10000, + "eur_max_sell": 300000, + }, + ) + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_min_greater_than_max_rejected(self, client_factory, admin_user): + """Min >= Max is rejected.""" + # Seed pricing config first + async with client_factory.get_db_session() as db: + repo = PricingRepository(db) + await repo.create_or_update( + premium_buy=5, + premium_sell=5, + small_trade_threshold_eur=20000, + small_trade_extra_premium=2, + eur_min_buy=10000, + eur_max_buy=300000, + eur_min_sell=10000, + eur_max_sell=300000, + ) + + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Test eur_min_buy >= eur_max_buy + response = await client.put( + "/api/admin/pricing", + json={ + "premium_buy": 5, + "premium_sell": 5, + "small_trade_threshold_eur": 20000, + "small_trade_extra_premium": 2, + "eur_min_buy": 300000, + "eur_max_buy": 300000, + "eur_min_sell": 10000, + "eur_max_sell": 300000, + }, + ) + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_negative_amounts_rejected(self, client_factory, admin_user): + """Negative amounts are rejected.""" + # Seed pricing config first + async with client_factory.get_db_session() as db: + repo = PricingRepository(db) + await repo.create_or_update( + premium_buy=5, + premium_sell=5, + small_trade_threshold_eur=20000, + small_trade_extra_premium=2, + eur_min_buy=10000, + eur_max_buy=300000, + eur_min_sell=10000, + eur_max_sell=300000, + ) + + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Test negative eur_min_buy + response = await client.put( + "/api/admin/pricing", + json={ + "premium_buy": 5, + "premium_sell": 5, + "small_trade_threshold_eur": 20000, + "small_trade_extra_premium": 2, + "eur_min_buy": -1000, + "eur_max_buy": 300000, + "eur_min_sell": 10000, + "eur_max_sell": 300000, + }, + ) + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_zero_amounts_rejected(self, client_factory, admin_user): + """Zero amounts are rejected.""" + # Seed pricing config first + async with client_factory.get_db_session() as db: + repo = PricingRepository(db) + await repo.create_or_update( + premium_buy=5, + premium_sell=5, + small_trade_threshold_eur=20000, + small_trade_extra_premium=2, + eur_min_buy=10000, + eur_max_buy=300000, + eur_min_sell=10000, + eur_max_sell=300000, + ) + + async with client_factory.create(cookies=admin_user["cookies"]) as client: + # Test zero eur_min_buy + response = await client.put( + "/api/admin/pricing", + json={ + "premium_buy": 5, + "premium_sell": 5, + "small_trade_threshold_eur": 20000, + "small_trade_extra_premium": 2, + "eur_min_buy": 0, + "eur_max_buy": 300000, + "eur_min_sell": 10000, + "eur_max_sell": 300000, + }, + ) + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_get_when_no_config_returns_404(self, client_factory, admin_user): + """GET returns 404 when no config exists.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.get("/api/admin/pricing") + + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_update_creates_config_if_none_exists( + self, client_factory, admin_user + ): + """PUT creates config if none exists.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.put( + "/api/admin/pricing", + json={ + "premium_buy": 5, + "premium_sell": 5, + "small_trade_threshold_eur": 20000, + "small_trade_extra_premium": 2, + "eur_min_buy": 10000, + "eur_max_buy": 300000, + "eur_min_sell": 10000, + "eur_max_sell": 300000, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["premium_buy"] == 5 + + # Verify it was created + response = await client.get("/api/admin/pricing") + assert response.status_code == 200