From 3c463a9b9587e6f61a7991fb179f5ac2f49ee6ed Mon Sep 17 00:00:00 2001 From: Code With Me Date: Sun, 22 Mar 2026 22:24:55 +0300 Subject: [PATCH] feat: Implement user verification requests with dedicated database schema and admin panel management. --- app/core/admin/admin.py | 272 ++++++++++++++++++ app/core/admin/admin_auth.py | 52 ++++ app/core/exception_handlers.py | 10 + app/core/exceptions.py | 7 + app/core/setup.py | 6 + app/main.py | 16 ++ app/services/user/models.py | 30 +- app/services/user/routes.py | 34 ++- app/services/user/schemas.py | 15 +- app/services/user/service.py | 44 ++- app/shared/decorators.py | 4 +- docker-compose.yaml | 12 + .../96dda86ac1b0_add_verification_requests.py | 75 +++++ pyproject.toml | 2 + uv.lock | 32 +++ 15 files changed, 601 insertions(+), 10 deletions(-) create mode 100644 app/core/admin/admin.py create mode 100644 app/core/admin/admin_auth.py create mode 100644 migrations/versions/96dda86ac1b0_add_verification_requests.py 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"