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
This commit is contained in:
counterweight 2025-12-26 20:13:24 +01:00
parent 74b934135a
commit 4d0dad8e2b
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
8 changed files with 534 additions and 0 deletions

View file

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

View file

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

View file

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

33
backend/routes/pricing.py Normal file
View file

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

View file

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

View file

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

106
backend/services/pricing.py Normal file
View file

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

View file

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