diff --git a/app/core/admin/admin.py b/app/core/admin/admin.py
new file mode 100644
index 0000000..49cdd91
--- /dev/null
+++ b/app/core/admin/admin.py
@@ -0,0 +1,272 @@
+from typing import Any
+
+from markupsafe import Markup
+from sqladmin import ModelView
+from sqlalchemy import select
+from starlette.requests import Request
+
+from app.core.audit_log.models import AuditLog
+from app.core.database import async_session_factory
+from app.services.inventory.models import Product, Reservation
+from app.services.orders.models import Order
+from app.services.user.models import (
+ APIKeyB2BPartner,
+ User,
+ UserRole,
+ VerificationRequest,
+ VerificationStatus,
+)
+
+
+class AdminAccessMixin(ModelView):
+ def is_accessible(self, request: Request) -> bool:
+ user = getattr(request.state, 'user', None)
+ return user is not None and user.role == UserRole.ADMIN
+
+ def is_visible(self, request: Request) -> bool:
+ user = getattr(request.state, 'user', None)
+ return user is not None and user.role == UserRole.ADMIN
+
+
+class AdminPanelFormatter:
+ @staticmethod
+ def status_formatter(model: Any, name: Any) -> Any:
+ status_colors = {
+ 'ACTIVE': 'success',
+ 'PENDING': 'warning',
+ 'REJECTED': 'danger',
+ 'PENDING_MODERATION': 'info',
+ 'PAID': 'success',
+ 'SHIPPED': 'info',
+ 'COMPLETED': 'success',
+ 'CANCELLED': 'danger',
+ 'FAILED': 'danger',
+ 'EXPIRED': 'secondary',
+ 'DRAFT': 'secondary',
+ 'MODERATION_IN_PROGRESS': 'info',
+ 'APPROVED': 'success',
+ 'SELLER_B2B': 'primary',
+ 'USER_B2B': 'secondary',
+ }
+ if name in ('status', 'target_role', 'role'):
+ value = getattr(model, name)
+ color = status_colors.get(value, 'secondary')
+ return Markup(f'{value}')
+ return getattr(model, name)
+
+ @staticmethod
+ def user_link_formatter(model: Any, name: Any) -> Any:
+ user_id = getattr(model, name)
+ if not user_id:
+ return 'N/A'
+ return Markup(f'{user_id}')
+
+ @staticmethod
+ def product_link_formatter(model: Any, name: Any) -> Any:
+ product_id = getattr(model, name)
+ if not product_id:
+ return 'N/A'
+ return Markup(f'{product_id}')
+
+ @staticmethod
+ def order_link_formatter(model: Any, name: Any) -> Any:
+ order_id = getattr(model, name)
+ if not order_id:
+ return 'N/A'
+ return Markup(f'{order_id}')
+
+
+class VerificationRequestAdmin(ModelView, model=VerificationRequest):
+ column_list = [
+ VerificationRequest.id,
+ VerificationRequest.user_id,
+ VerificationRequest.target_role,
+ VerificationRequest.status,
+ VerificationRequest.docs_url,
+ VerificationRequest.admin_feedback,
+ VerificationRequest.created_at,
+ VerificationRequest.updated_at,
+ ]
+ column_searchable_list = column_list
+ column_default_sort = [('created_at', True)]
+ column_formatters = {
+ 'user_id': AdminPanelFormatter.user_link_formatter,
+ 'target_role': AdminPanelFormatter.status_formatter,
+ 'status': AdminPanelFormatter.status_formatter,
+ }
+ column_formatters_detail = column_formatters
+ name = 'Verification Request'
+ name_plural = 'Verification Requests'
+ icon = 'fa-solid fa-user-check'
+ can_delete = False
+ can_create = False
+ can_edit = True
+ form_columns = [VerificationRequest.status, VerificationRequest.admin_feedback]
+
+ async def on_model_change(
+ self, data: dict, model: Any, is_created: bool, request: Request
+ ) -> None:
+ if not is_created and data.get('status') == VerificationStatus.APPROVED:
+ async with async_session_factory() as session:
+ result = await session.execute(
+ select(User).where(User.id == model.user_id)
+ )
+ user = result.scalar_one()
+ user.role = model.target_role
+ user.is_verified = True
+ await session.commit()
+
+
+class UserAdmin(ModelView, model=User):
+ column_list = [User.id, User.email, User.role, User.is_active]
+ column_searchable_list = [User.id, User.email]
+ can_delete = False
+ name = 'User'
+ name_plural = 'Users'
+ icon = 'fa-solid fa-user'
+ column_default_sort = [('created_at', True)]
+
+ def is_accessible(self, request: Request) -> bool:
+ user = getattr(request.state, 'user', None)
+ if not user:
+ return False
+
+ if user.role == UserRole.MODERATOR:
+ endpoint = request.scope.get('endpoint')
+ endpoint_name = getattr(endpoint, '__name__', '')
+ route = request.scope.get('route')
+ route_name = getattr(route, 'name', '')
+ path = request.url.path
+ forbidden = {'create', 'edit', 'delete'}
+ if (
+ endpoint_name in forbidden
+ or route_name in forbidden
+ or any(f'/{x}/' in path for x in forbidden)
+ ):
+ return False
+ return user.role in (UserRole.ADMIN, UserRole.MODERATOR)
+
+
+class ProductAdmin(ModelView, model=Product):
+ column_list = [
+ Product.id,
+ Product.name,
+ Product.price,
+ Product.qty_available,
+ Product.status,
+ Product.owner_id,
+ Product.moderator_id,
+ ]
+ column_labels = {'qty_available': 'Quantity Available'}
+ column_default_sort = [('created_at', True)]
+ column_formatters = {
+ 'status': AdminPanelFormatter.status_formatter,
+ 'owner_id': AdminPanelFormatter.user_link_formatter,
+ 'moderator_id': AdminPanelFormatter.user_link_formatter,
+ }
+ column_formatters_detail = column_formatters
+ column_searchable_list = [Product.id, Product.name, Product.description]
+ can_delete = False
+ name = 'Product'
+ name_plural = 'Products'
+ icon = 'fa-solid fa-box'
+
+
+class OrderAdmin(ModelView, model=Order):
+ column_list = [
+ Order.id,
+ Order.user_id,
+ Order.status,
+ Order.total_amount,
+ Order.created_at,
+ ]
+ column_searchable_list = [Order.id, Order.user_id]
+ can_delete = False
+ name = 'Order'
+ name_plural = 'Orders'
+ icon = 'fa-solid fa-receipt'
+ column_formatters = {
+ 'status': AdminPanelFormatter.status_formatter,
+ 'user_id': AdminPanelFormatter.user_link_formatter,
+ }
+ column_formatters_detail = column_formatters
+ column_default_sort = [('created_at', True)]
+
+
+class ReservationAdmin(ModelView, model=Reservation):
+ column_list = [
+ Reservation.id,
+ Reservation.product_id,
+ Reservation.user_id,
+ Reservation.qty_reserved,
+ Reservation.status,
+ Reservation.created_at,
+ Reservation.expires_at,
+ ]
+ column_searchable_list = [Reservation.id, Reservation.user_id]
+ can_delete = False
+ name = 'Reservation'
+ name_plural = 'Reservations'
+ icon = 'fa-solid fa-cart-arrow-down'
+ column_formatters = {
+ 'user_id': AdminPanelFormatter.user_link_formatter,
+ 'product_id': AdminPanelFormatter.product_link_formatter,
+ 'status': AdminPanelFormatter.status_formatter,
+ 'order_id': AdminPanelFormatter.order_link_formatter,
+ }
+ column_formatters_detail = column_formatters
+ column_default_sort = [('created_at', True)]
+ column_labels = {'qty_reserved': 'Quantity reserved'}
+
+
+class AuditLogAdmin(AdminAccessMixin, model=AuditLog):
+ column_list = [
+ AuditLog.id,
+ AuditLog.target_type,
+ AuditLog.target_id,
+ AuditLog.actor_id,
+ AuditLog.action,
+ ]
+ column_searchable_list = [AuditLog.target_type, AuditLog.target_id, AuditLog.action]
+ can_create = False
+ can_edit = False
+ can_delete = False
+ name = 'Audit Log'
+ name_plural = 'Audit Logs'
+ icon = 'fa-solid fa-clipboard-list'
+ column_default_sort = [('created_at', True)]
+ column_formatters = {
+ 'actor_id': AdminPanelFormatter.user_link_formatter,
+ 'target_id': AdminPanelFormatter.order_link_formatter,
+ }
+ column_formatters_detail = column_formatters
+
+
+class APIKeyB2BPartnerAdmin(AdminAccessMixin, model=APIKeyB2BPartner):
+ column_list = [
+ APIKeyB2BPartner.id,
+ APIKeyB2BPartner.user_id,
+ APIKeyB2BPartner.name,
+ APIKeyB2BPartner.key_prefix,
+ APIKeyB2BPartner.is_active,
+ ]
+ column_searchable_list = [APIKeyB2BPartner.name, APIKeyB2BPartner.key_prefix]
+ can_delete = False
+ name = 'API Key'
+ name_plural = 'API Keys'
+ icon = 'fa-solid fa-key'
+ column_default_sort = [('created_at', True)]
+ column_formatters = {
+ 'user_id': AdminPanelFormatter.user_link_formatter,
+ }
+ column_formatters_detail = column_formatters
+
+
+def register_admin_views(admin: Any) -> None:
+ admin.add_view(UserAdmin)
+ admin.add_view(ProductAdmin)
+ admin.add_view(OrderAdmin)
+ admin.add_view(ReservationAdmin)
+ admin.add_view(AuditLogAdmin)
+ admin.add_view(APIKeyB2BPartnerAdmin)
+ admin.add_view(VerificationRequestAdmin)
diff --git a/app/core/admin/admin_auth.py b/app/core/admin/admin_auth.py
new file mode 100644
index 0000000..bae900f
--- /dev/null
+++ b/app/core/admin/admin_auth.py
@@ -0,0 +1,52 @@
+from sqladmin.authentication import AuthenticationBackend
+from sqlalchemy import select
+from starlette.requests import Request
+
+from app.core.config import settings
+from app.core.database import async_session_factory
+from app.core.exceptions import PermissionDeniedError
+from app.core.security import check_permission
+from app.services.user.models import User, UserRole
+from app.services.user.service import UserService
+
+
+class AdminAuth(AuthenticationBackend):
+ async def login(self, request: Request) -> bool:
+ user_data = await request.form()
+ email = user_data.get('username')
+ password = user_data.get('password')
+
+ if not isinstance(email, str) or not isinstance(password, str):
+ return False
+
+ async with async_session_factory() as session:
+ user = await UserService.authenticate_user(session, email, password)
+ if user is None:
+ return False
+
+ try:
+ await check_permission(user, [UserRole.ADMIN, UserRole.MODERATOR])
+ request.session['token'] = str(user.id)
+ return True
+ except PermissionDeniedError:
+ return False
+
+ async def logout(self, request: Request) -> bool:
+ request.session.clear()
+ return True
+
+ async def authenticate(self, request: Request) -> bool:
+ token = request.session.get('token')
+ if not token:
+ return False
+
+ async with async_session_factory() as session:
+ result = await session.execute(select(User).where(User.id == token))
+ user = result.scalar_one_or_none()
+ if not user or user.role not in (UserRole.ADMIN, UserRole.MODERATOR):
+ return False
+ request.state.user = user
+ return True
+
+
+authentication_backend = AdminAuth(secret_key=settings.secret_key)
diff --git a/app/core/exception_handlers.py b/app/core/exception_handlers.py
index 87e1209..0782e00 100644
--- a/app/core/exception_handlers.py
+++ b/app/core/exception_handlers.py
@@ -8,6 +8,7 @@
NotFoundError,
PermissionDeniedError,
UserAlreadyExists,
+ VerificationRequestAlreadyExists,
)
EXISTING_USER_MESSAGE = 'User already exists'
@@ -66,3 +67,12 @@ async def permission_denied_handler(
status_code=status.HTTP_403_FORBIDDEN,
content={'detail': str(exc) or 'Permission denied'},
)
+
+
+async def verification_request_already_exists_handler(
+ request: Request, exc: VerificationRequestAlreadyExists
+) -> JSONResponse:
+ return JSONResponse(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ content={'detail': str(exc) or 'Verification request already exists'},
+ )
diff --git a/app/core/exceptions.py b/app/core/exceptions.py
index e6acc45..1d683b2 100644
--- a/app/core/exceptions.py
+++ b/app/core/exceptions.py
@@ -44,3 +44,10 @@ class PermissionDeniedError(AppError):
def __init__(self, message: str = 'Permission denied'):
super().__init__(message=message)
+
+
+class VerificationRequestAlreadyExists(AppError):
+ """Verification request already exists."""
+
+ def __init__(self, message: str = 'Verification request already exists'):
+ super().__init__(message=message)
diff --git a/app/core/setup.py b/app/core/setup.py
index 104cc82..317a05f 100644
--- a/app/core/setup.py
+++ b/app/core/setup.py
@@ -7,6 +7,7 @@
not_found_error_handler,
permission_denied_handler,
user_already_exists_handler,
+ verification_request_already_exists_handler,
)
from .exceptions import (
ConflictError,
@@ -15,6 +16,7 @@
NotFoundError,
PermissionDeniedError,
UserAlreadyExists,
+ VerificationRequestAlreadyExists,
)
@@ -31,3 +33,7 @@ def setup_exception_handlers(app: FastAPI) -> None:
PermissionDeniedError,
permission_denied_handler, # type: ignore[arg-type]
)
+ app.add_exception_handler(
+ VerificationRequestAlreadyExists,
+ verification_request_already_exists_handler, # type: ignore[arg-type]
+ )
diff --git a/app/main.py b/app/main.py
index 5c512ea..5787344 100644
--- a/app/main.py
+++ b/app/main.py
@@ -7,9 +7,14 @@
from fastapi.responses import ORJSONResponse
from prometheus_fastapi_instrumentator import Instrumentator
from redis.asyncio import Redis
+from sqladmin import Admin
+from starlette.middleware.sessions import SessionMiddleware
from structlog.contextvars import bind_contextvars
+from app.core.admin.admin import register_admin_views
+from app.core.admin.admin_auth import authentication_backend
from app.core.config import settings
+from app.core.database import engine
from app.core.logging import setup_logging
from app.core.lua_scripts import RATE_LIMIT_LUA_SCRIPT
from app.core.s3 import init_s3_bucket
@@ -49,6 +54,17 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
setup_exception_handlers(app)
+admin = Admin(
+ app,
+ engine,
+ authentication_backend=authentication_backend,
+ title='FairDrop Admin Panel',
+)
+
+register_admin_views(admin)
+
+app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
+
@app.middleware('http')
async def add_request_context(
diff --git a/app/services/user/models.py b/app/services/user/models.py
index 4f62679..f870993 100644
--- a/app/services/user/models.py
+++ b/app/services/user/models.py
@@ -2,8 +2,8 @@
from enum import StrEnum
from uuid import UUID, uuid4
+from sqlalchemy import JSON, ForeignKey
from sqlalchemy import Enum as SQLEnum
-from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from sqlalchemy.types import Boolean, DateTime, String
@@ -22,6 +22,12 @@ class UserRole(StrEnum):
SELLER_B2B = 'SELLER_B2B'
+class VerificationStatus(StrEnum):
+ PENDING = 'PENDING'
+ APPROVED = 'APPROVED'
+ REJECTED = 'REJECTED'
+
+
class User(Base):
__tablename__ = 'users'
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
@@ -37,6 +43,9 @@ class User(Base):
api_keys_b2b_partners: Mapped[list['APIKeyB2BPartner']] = relationship(
back_populates='user'
)
+ verification_requests: Mapped[list['VerificationRequest']] = relationship(
+ back_populates='user'
+ )
class RefreshToken(Base):
@@ -67,3 +76,22 @@ class APIKeyB2BPartner(Base):
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
user: Mapped[User] = relationship(back_populates='api_keys_b2b_partners')
+
+
+class VerificationRequest(Base):
+ __tablename__ = 'verification_requests'
+ id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
+ user_id: Mapped[UUID] = mapped_column(
+ ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True
+ )
+ target_role: Mapped[UserRole] = mapped_column(SQLEnum(UserRole), nullable=False)
+ status: Mapped[VerificationStatus] = mapped_column(
+ SQLEnum(VerificationStatus), default=VerificationStatus.PENDING
+ )
+ docs_url: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ admin_feedback: Mapped[str | None] = mapped_column(String(), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime, server_default=func.now(), onupdate=func.now()
+ )
+ user: Mapped[User] = relationship(back_populates='verification_requests')
diff --git a/app/services/user/routes.py b/app/services/user/routes.py
index 0d77cfc..1cf9ee6 100644
--- a/app/services/user/routes.py
+++ b/app/services/user/routes.py
@@ -4,11 +4,12 @@
from fastapi import APIRouter, Depends, status
from fastapi.security import OAuth2PasswordRequestForm
+from sqlalchemy import select
from app.core.database import SessionDep
from app.core.exceptions import CredentialsError
from app.core.security import RoleChecker, create_access_token
-from app.services.user.models import User, UserRole
+from app.services.user.models import APIKeyB2BPartner, User, UserRole
from app.services.user.schemas import (
APIKeyCreate,
APIKeyRead,
@@ -17,6 +18,8 @@
Token,
UserCreate,
UserRead,
+ VerificationRequestCreate,
+ VerificationRequestRead,
)
from app.services.user.service import UserService
from app.shared.deps import get_current_user
@@ -88,11 +91,13 @@ async def create_api_key_b2b_partner(
@router_v1.get('/users/me/api-keys', response_model=list[APIKeyRead])
async def get_api_keys_b2b_partners(
current_user: Annotated[User, B2B_PARTNER_DEP],
+ session: SessionDep,
) -> list[APIKeyRead]:
- return [
- APIKeyRead.model_validate(api_key)
- for api_key in current_user.api_keys_b2b_partners
- ]
+ result = await session.execute(
+ select(APIKeyB2BPartner).where(APIKeyB2BPartner.user_id == current_user.id)
+ )
+ api_keys = result.scalars().all()
+ return [APIKeyRead.model_validate(api_key) for api_key in api_keys]
@router_v1.delete('/users/me/api-keys/{key_id}', status_code=status.HTTP_204_NO_CONTENT)
@@ -102,3 +107,22 @@ async def delete_api_key_b2b_partner(
session: SessionDep,
) -> None:
await UserService.delete_api_key_b2b_partner(session, current_user.id, key_id)
+
+
+@router_v1.post(
+ '/users/me/upgrade-requests',
+ status_code=status.HTTP_201_CREATED,
+ response_model=VerificationRequestRead,
+)
+async def create_upgrade_request(
+ schema: VerificationRequestCreate,
+ current_user: Annotated[User, Depends(get_current_user)],
+ session: SessionDep,
+) -> VerificationRequestRead:
+ verification_request = await UserService.create_verification_request(
+ session=session,
+ user_id=current_user.id,
+ target_role=schema.target_role,
+ docs_url=schema.docs_url,
+ )
+ return VerificationRequestRead.model_validate(verification_request)
diff --git a/app/services/user/schemas.py b/app/services/user/schemas.py
index cdc2620..9d1ab7f 100644
--- a/app/services/user/schemas.py
+++ b/app/services/user/schemas.py
@@ -1,9 +1,10 @@
from datetime import datetime
+from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr
-from .models import UserRole
+from .models import UserRole, VerificationStatus
class UserCreate(BaseModel):
@@ -45,3 +46,15 @@ class APIKeyRead(BaseModel):
class APIKeyWithSecret(APIKeyRead):
raw_key: str
+
+
+class VerificationRequestCreate(BaseModel):
+ target_role: Literal[UserRole.USER_B2B, UserRole.SELLER_B2B]
+ docs_url: dict[str, str] | None = None
+
+
+class VerificationRequestRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+ id: UUID
+ status: VerificationStatus
+ created_at: datetime
diff --git a/app/services/user/service.py b/app/services/user/service.py
index 2f85fc3..c0a9873 100644
--- a/app/services/user/service.py
+++ b/app/services/user/service.py
@@ -7,9 +7,22 @@
from sqlalchemy.orm import joinedload
from app.core.config import settings
-from app.core.exceptions import CredentialsError, NotFoundError, UserAlreadyExists
+from app.core.exceptions import (
+ CredentialsError,
+ NotFoundError,
+ PermissionDeniedError,
+ UserAlreadyExists,
+ VerificationRequestAlreadyExists,
+)
from app.core.security import get_password_hash, verify_password
-from app.services.user.models import APIKeyB2BPartner, RefreshToken, User
+from app.services.user.models import (
+ APIKeyB2BPartner,
+ RefreshToken,
+ User,
+ UserRole,
+ VerificationRequest,
+ VerificationStatus,
+)
from app.services.user.schemas import UserCreate
URLSAFE_PARAM = 32
@@ -125,3 +138,30 @@ async def delete_api_key_b2b_partner(
await session.delete(api_key)
await session.commit()
return None
+
+ @staticmethod
+ async def create_verification_request(
+ session: AsyncSession,
+ user_id: UUID,
+ target_role: UserRole,
+ docs_url: dict | None = None,
+ ) -> VerificationRequest:
+ result = await session.execute(
+ select(VerificationRequest).where(
+ VerificationRequest.user_id == user_id,
+ VerificationRequest.status == VerificationStatus.PENDING,
+ )
+ )
+ if result.scalar_one_or_none():
+ raise VerificationRequestAlreadyExists()
+ if target_role in (UserRole.ADMIN, UserRole.MODERATOR):
+ raise PermissionDeniedError('Cannot request administrative roles')
+ verification_request = VerificationRequest(
+ user_id=user_id,
+ target_role=target_role,
+ docs_url=docs_url,
+ )
+ session.add(verification_request)
+ await session.commit()
+ await session.refresh(verification_request)
+ return verification_request
diff --git a/app/shared/decorators.py b/app/shared/decorators.py
index 7c04372..19fa68e 100644
--- a/app/shared/decorators.py
+++ b/app/shared/decorators.py
@@ -35,7 +35,9 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response | Any:
detail='Missing X-Idempotency-Key header',
)
redis_client = request.app.state.redis
- redis_key = f'idempotency:{idempotency_key}'
+ redis_key = (
+ f'idempotency:{request.method}:{request.url.path}:{idempotency_key}'
+ )
cached_response = await redis_client.get(redis_key)
if cached_response:
data = orjson.loads(cached_response)
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 138b4b8..a56faf6 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -78,6 +78,18 @@ services:
volumes:
- fairdrop-app:/data
+ worker:
+ build: .
+ command: /app/.venv/bin/arq app.worker.WorkerSettings
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ env_file: .env
+ volumes:
+ - fairdrop-app:/data
+
gateway:
image: nginx:alpine
container_name: ${GATEWAY_HOST}
diff --git a/migrations/versions/96dda86ac1b0_add_verification_requests.py b/migrations/versions/96dda86ac1b0_add_verification_requests.py
new file mode 100644
index 0000000..258771f
--- /dev/null
+++ b/migrations/versions/96dda86ac1b0_add_verification_requests.py
@@ -0,0 +1,75 @@
+"""add_verification_requests
+
+Revision ID: 96dda86ac1b0
+Revises: 58fabaa5d620
+Create Date: 2026-03-21 21:39:51.747859
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = '96dda86ac1b0'
+down_revision: str | Sequence[str] | None = '58fabaa5d620'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ 'verification_requests',
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('user_id', sa.Uuid(), nullable=False),
+ sa.Column(
+ 'target_role',
+ postgresql.ENUM(
+ 'ADMIN',
+ 'MODERATOR',
+ 'USER',
+ 'USER_B2B',
+ 'SELLER',
+ 'SELLER_B2B',
+ name='userrole',
+ create_type=False,
+ ),
+ nullable=False,
+ ),
+ sa.Column(
+ 'status',
+ sa.Enum('PENDING', 'APPROVED', 'REJECTED', name='verificationstatus'),
+ nullable=False,
+ ),
+ sa.Column('docs_url', sa.JSON(), nullable=True),
+ sa.Column('admin_feedback', sa.String(), nullable=True),
+ sa.Column(
+ 'created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False
+ ),
+ sa.Column(
+ 'updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False
+ ),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id'),
+ )
+ op.create_index(
+ op.f('ix_verification_requests_user_id'),
+ 'verification_requests',
+ ['user_id'],
+ unique=False,
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(
+ op.f('ix_verification_requests_user_id'), table_name='verification_requests'
+ )
+ op.drop_table('verification_requests')
+ # ### end Alembic commands ###
diff --git a/pyproject.toml b/pyproject.toml
index 23cfe34..201b2c2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,6 +13,7 @@ dependencies = [
'boto3-stubs[essential,s3]>=1.42.54',
'email-validator>=2.3.0',
'fastapi>=0.129.0',
+ 'itsdangerous>=2.2.0',
'orjson>=3.11.7',
'passlib[bcrypt]==1.7.4',
'prometheus-fastapi-instrumentator>=7.1.0',
@@ -20,6 +21,7 @@ dependencies = [
'python-jose[cryptography]>=3.5.0',
'python-multipart>=0.0.22',
'redis>=5.3.1',
+ 'sqladmin>=0.23.0',
'sqlalchemy[asyncio]>=2.0.46',
'structlog>=25.5.0',
'uvicorn[standard]>=0.40.0',
diff --git a/uv.lock b/uv.lock
index 6759dc9..8aaf100 100644
--- a/uv.lock
+++ b/uv.lock
@@ -974,6 +974,7 @@ dependencies = [
{ name = "boto3-stubs", extra = ["essential", "s3"] },
{ name = "email-validator" },
{ name = "fastapi" },
+ { name = "itsdangerous" },
{ name = "orjson" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "prometheus-fastapi-instrumentator" },
@@ -981,6 +982,7 @@ dependencies = [
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "redis" },
+ { name = "sqladmin" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "structlog" },
{ name = "uvicorn", extra = ["standard"] },
@@ -1009,6 +1011,7 @@ requires-dist = [
{ name = "boto3-stubs", extras = ["essential", "s3"], specifier = ">=1.42.54" },
{ name = "email-validator", specifier = ">=2.3.0" },
{ name = "fastapi", specifier = ">=0.129.0" },
+ { name = "itsdangerous", specifier = ">=2.2.0" },
{ name = "orjson", specifier = ">=3.11.7" },
{ name = "passlib", extras = ["bcrypt"], specifier = "==1.7.4" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=7.1.0" },
@@ -1016,6 +1019,7 @@ requires-dist = [
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "python-multipart", specifier = ">=0.0.22" },
{ name = "redis", specifier = ">=5.3.1" },
+ { name = "sqladmin", specifier = ">=0.23.0" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.46" },
{ name = "structlog", specifier = ">=25.5.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" },
@@ -3091,6 +3095,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
+[[package]]
+name = "sqladmin"
+version = "0.23.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "python-multipart" },
+ { name = "sqlalchemy" },
+ { name = "starlette" },
+ { name = "wtforms" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/4e/c05e73c207865f76331ee306e69c468a81eb5e104486d704a377230912ff/sqladmin-0.23.0.tar.gz", hash = "sha256:62339022dbf2f5d7323a6b6b548d2daa9ea2f98fc675b238ac7af088f1d530d2", size = 1437285, upload-time = "2026-02-04T08:15:29.647Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/76/640953262e86ce6903db8f7005b8ff681ee232cfc90242cce3ece081ecda/sqladmin-0.23.0-py3-none-any.whl", hash = "sha256:d8f3a837bb7e6759f9c4069102190182b070ba2c63480a5eaa95c9b8f46ad4c5", size = 1450678, upload-time = "2026-02-04T08:15:31.489Z" },
+]
+
[[package]]
name = "sqlalchemy"
version = "2.0.46"
@@ -3636,6 +3656,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
]
+[[package]]
+name = "wtforms"
+version = "3.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6a/c7/96d10183c3470f1836846f7b9527d6cb0b6c2226ebca40f36fa29f23de60/wtforms-3.1.2.tar.gz", hash = "sha256:f8d76180d7239c94c6322f7990ae1216dae3659b7aa1cee94b6318bdffb474b9", size = 134705, upload-time = "2024-01-06T07:52:41.075Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/19/c3232f35e24dccfad372e9f341c4f3a1166ae7c66e4e1351a9467c921cc1/wtforms-3.1.2-py3-none-any.whl", hash = "sha256:bf831c042829c8cdbad74c27575098d541d039b1faa74c771545ecac916f2c07", size = 145961, upload-time = "2024-01-06T07:52:43.023Z" },
+]
+
[[package]]
name = "yarl"
version = "1.22.0"