diff --git a/app/core/audit_log/models.py b/app/core/audit_log/models.py new file mode 100644 index 0000000..689f472 --- /dev/null +++ b/app/core/audit_log/models.py @@ -0,0 +1,28 @@ +from datetime import datetime +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func +from sqlalchemy.types import JSON, DateTime, String + +from app.core.database import Base + + +class AuditLog(Base): + __tablename__ = 'audit_logs' + + id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) + target_type: Mapped[str] = mapped_column(String(), nullable=False, index=True) + target_id: Mapped[UUID] = mapped_column(nullable=False, index=True) + actor_id: Mapped[UUID] = mapped_column( + ForeignKey('users.id'), nullable=False, index=True + ) + action: Mapped[str] = mapped_column(String(), nullable=False, index=True) + changes: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + remote_ip: Mapped[str | None] = mapped_column(String(45), index=True) + request_id: Mapped[str | None] = mapped_column(index=True) diff --git a/app/core/audit_log/service.py b/app/core/audit_log/service.py new file mode 100644 index 0000000..f9edb20 --- /dev/null +++ b/app/core/audit_log/service.py @@ -0,0 +1,79 @@ +from typing import Any +from uuid import UUID + +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from structlog.contextvars import get_contextvars + +from app.core.audit_log.models import AuditLog + + +class AuditLogService: + async def log_event( + self, + session: AsyncSession, + actor_id: UUID, + target_type: str, + target_id: UUID, + action: str, + changes: dict[str, Any], + ) -> None: + context = get_contextvars() + request_id = context.get('request_id') + remote_ip = context.get('remote_ip') + + log = AuditLog( + actor_id=actor_id, + target_type=target_type, + target_id=target_id, + action=action, + changes=changes, + request_id=request_id, + remote_ip=remote_ip, + ) + session.add(log) + await session.flush() + + @staticmethod + def get_diff( + old_model: BaseModel | None, + new_model: BaseModel | None, + ) -> dict[str, Any]: + if old_model is None and new_model is not None: + return {k: [None, v] for k, v in new_model.model_dump(mode='json').items()} + if old_model is not None and new_model is None: + return {k: [v, None] for k, v in old_model.model_dump(mode='json').items()} + if old_model is not None and new_model is not None: + old_data = old_model.model_dump(mode='json') + new_data = new_model.model_dump(mode='json') + diff = {} + for key, value in new_data.items(): + old_val = old_data.get(key) + if value != old_val: + diff[key] = [old_val, value] + return diff + return {} + + async def log_object_change( + self, + session: AsyncSession, + actor_id: UUID, + target_id: UUID, + target_type: str, + action: str, + old_obj: BaseModel | None, + new_obj: BaseModel | None, + ) -> None: + diff = self.get_diff(old_obj, new_obj) + if diff: + await self.log_event( + session=session, + actor_id=actor_id, + target_type=target_type, + target_id=target_id, + action=action, + changes=diff, + ) + + +audit_log_service = AuditLogService() diff --git a/app/main.py b/app/main.py index c399d7b..5c512ea 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,13 @@ -from collections.abc import AsyncGenerator +import uuid +from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager import structlog -from fastapi import FastAPI +from fastapi import FastAPI, Request, Response from fastapi.responses import ORJSONResponse from prometheus_fastapi_instrumentator import Instrumentator from redis.asyncio import Redis +from structlog.contextvars import bind_contextvars from app.core.config import settings from app.core.logging import setup_logging @@ -47,6 +49,25 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: setup_exception_handlers(app) + +@app.middleware('http') +async def add_request_context( + request: Request, call_next: Callable[[Request], Awaitable[Response]] +) -> Response: + request_id = str(uuid.uuid4()) + bind_contextvars( + request_id=request_id, + remote_ip=request.client.host if request.client else 'unknown', + ) + try: + response = await call_next(request) + response.headers['X-Request-ID'] = request_id + except Exception as e: + logger.error('request failed', exc_info=True) + raise e + return response + + app.include_router(user_router_v1, prefix='/api/v1', tags=['Users']) app.include_router(order_router_v1, prefix='/api/v1', tags=['Orders']) app.include_router(inventory_router_v1, prefix='/api/v1', tags=['Inventory']) diff --git a/app/services/inventory/models.py b/app/services/inventory/models.py index 659f591..2ba91d7 100644 --- a/app/services/inventory/models.py +++ b/app/services/inventory/models.py @@ -1,11 +1,12 @@ from datetime import datetime from decimal import Decimal from enum import StrEnum +from typing import TYPE_CHECKING from uuid import UUID, uuid4 from sqlalchemy import CheckConstraint, ForeignKey, Numeric from sqlalchemy import Enum as SQLEnum -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func from sqlalchemy.types import DateTime, Integer, String, Text @@ -45,6 +46,11 @@ class Product(Base): status: Mapped[ProductStatus] = mapped_column( SQLEnum(ProductStatus), nullable=False, default=ProductStatus.DRAFT ) + if TYPE_CHECKING: + from app.services.media.models import ProductImage + images: Mapped[list['ProductImage']] = relationship( + back_populates='product', cascade='all, delete-orphan' + ) __table_args__ = ( CheckConstraint('qty_available >= 0', name='check_qty_non_negative'), diff --git a/app/services/inventory/routes.py b/app/services/inventory/routes.py index f4e6a61..4644536 100644 --- a/app/services/inventory/routes.py +++ b/app/services/inventory/routes.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any +from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, Header, Request, status @@ -67,6 +67,7 @@ async def create_product( current_user: Annotated[User, SELLER_DEPENDENCY], ) -> ProductRead: product = await InventoryService.create_product( + current_user=current_user, session=session, product_data=product_data, owner_id=current_user.id, @@ -78,12 +79,13 @@ async def create_product( async def activate_product( product_id: UUID, session: Annotated[AsyncSession, Depends(get_session)], - _: Any = ADMIN_DEPENDENCY, + current_user: Annotated[User, ADMIN_DEPENDENCY], ) -> ProductRead: product = await InventoryService.change_status( session=session, product_id=product_id, status=ProductStatus.ACTIVE, + current_user=current_user, ) return ProductRead.model_validate(product) diff --git a/app/services/inventory/schemas.py b/app/services/inventory/schemas.py index 8a7d667..2439884 100644 --- a/app/services/inventory/schemas.py +++ b/app/services/inventory/schemas.py @@ -29,7 +29,7 @@ class ProductRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID name: str - description: str + description: str | None = None price: Decimal qty_available: int status: ProductStatus diff --git a/app/services/inventory/service.py b/app/services/inventory/service.py index d3352e9..d04adc2 100644 --- a/app/services/inventory/service.py +++ b/app/services/inventory/service.py @@ -5,6 +5,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from app.core.audit_log.service import audit_log_service from app.core.config import settings from app.core.exceptions import ( ConflictError, @@ -15,6 +16,7 @@ from app.services.inventory.models import Product, ProductStatus, Reservation from app.services.inventory.schemas import ( ProductCreate, + ProductRead, ProductUpdate, ReservationCreate, ) @@ -41,25 +43,65 @@ async def _get_product( check_ownership(current_user, product) return product + @staticmethod + async def _log_product_change( + session: AsyncSession, + user: User, + product: Product, + old_snapshot: ProductRead | None, + action: str, + ) -> None: + await audit_log_service.log_object_change( + session=session, + actor_id=user.id, + target_id=product.id, + target_type='product', + action=action, + old_obj=old_snapshot, + new_obj=ProductRead.model_validate(product), + ) + @staticmethod async def change_status( - session: AsyncSession, product_id: UUID, status: ProductStatus + session: AsyncSession, + product_id: UUID, + status: ProductStatus, + current_user: User, ) -> Product: product = await InventoryService._get_product( session, product_id, for_update=True ) + old_snapshot = ProductRead.model_validate(product) product.status = status + await InventoryService._log_product_change( + session=session, + user=current_user, + product=product, + old_snapshot=old_snapshot, + action='update', + ) await session.commit() await session.refresh(product) return product @staticmethod async def create_product( - session: AsyncSession, owner_id: UUID, product_data: ProductCreate + session: AsyncSession, + owner_id: UUID, + product_data: ProductCreate, + current_user: User, ) -> Product: new_product = Product(**product_data.model_dump()) new_product.owner_id = owner_id session.add(new_product) + await session.flush() + await InventoryService._log_product_change( + session=session, + user=current_user, + product=new_product, + old_snapshot=None, + action='create', + ) await session.commit() await session.refresh(new_product) return new_product @@ -74,8 +116,16 @@ async def update_product( product = await InventoryService._get_product( session, product_id, for_update=True, current_user=current_user ) + old_snapshot = ProductRead.model_validate(product) for field, value in product_data.model_dump(exclude_unset=True).items(): setattr(product, field, value) + await InventoryService._log_product_change( + session=session, + user=current_user, + product=product, + old_snapshot=old_snapshot, + action='update', + ) await session.commit() await session.refresh(product) return product @@ -89,6 +139,13 @@ async def delete_product( product = await InventoryService._get_product( session, product_id, for_update=True, current_user=current_user ) + await InventoryService._log_product_change( + session=session, + user=current_user, + product=product, + old_snapshot=ProductRead.model_validate(product), + action='delete', + ) await session.delete(product) await session.commit() diff --git a/app/services/media/models.py b/app/services/media/models.py index 20515c7..255b59b 100644 --- a/app/services/media/models.py +++ b/app/services/media/models.py @@ -1,9 +1,10 @@ from datetime import datetime from enum import StrEnum +from typing import TYPE_CHECKING from uuid import UUID, uuid4 from sqlalchemy import ForeignKey -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func from sqlalchemy.types import DateTime, String @@ -28,3 +29,7 @@ class ProductImage(Base): updated_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now(), onupdate=func.now() ) + + if TYPE_CHECKING: + from app.services.inventory.models import Product + product: Mapped['Product'] = relationship(back_populates='images') diff --git a/app/services/orders/service.py b/app/services/orders/service.py index e6db8d6..db75d93 100644 --- a/app/services/orders/service.py +++ b/app/services/orders/service.py @@ -4,6 +4,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.audit_log.service import audit_log_service from app.core.exceptions import ConflictError, NotFoundError from app.core.security import check_ownership from app.services.inventory.internal import ( @@ -12,7 +13,7 @@ ) from app.services.inventory.models import Product, Reservation from app.services.orders.models import Order, OrderItem, OrderStatus -from app.services.orders.schemas import OrderCreate +from app.services.orders.schemas import OrderCreate, OrderResponse from app.services.user.models import User @@ -34,6 +35,24 @@ async def _get_order( check_ownership(current_user, order) return order + @staticmethod + async def _log_order_change( + session: AsyncSession, + user: User, + order: Order, + old_snapshot: OrderResponse | None, + action: str, + ) -> None: + await audit_log_service.log_object_change( + session=session, + actor_id=user.id, + target_id=order.id, + target_type='order', + action=action, + old_obj=old_snapshot, + new_obj=OrderResponse.model_validate(order), + ) + @staticmethod async def create_order_from_reservation( session: AsyncSession, @@ -80,8 +99,16 @@ async def create_order_from_reservation( ) session.add(create_order_item) reservation.order_id = create_order.id - await session.commit() + await session.flush() await session.refresh(create_order, attribute_names=['items']) + await OrderService._log_order_change( + session=session, + user=current_user, + order=create_order, + old_snapshot=None, + action='create', + ) + await session.commit() return create_order @staticmethod @@ -93,7 +120,17 @@ async def confirm_order_payment( ) if order.status != OrderStatus.PENDING: raise ConflictError + await session.flush() + await session.refresh(order, attribute_names=['items']) + old_snapshot = OrderResponse.model_validate(order) order.status = OrderStatus.PAID + await OrderService._log_order_change( + session=session, + user=current_user, + order=order, + old_snapshot=old_snapshot, + action='payment', + ) await mark_reservation_by_order_as_completed(session, order_id) await session.commit() return order @@ -107,8 +144,17 @@ async def cancel_order( ) if order.status != OrderStatus.PENDING: raise ConflictError - + await session.flush() + await session.refresh(order, attribute_names=['items']) + old_snapshot = OrderResponse.model_validate(order) order.status = OrderStatus.CANCELLED + await OrderService._log_order_change( + session=session, + user=current_user, + order=order, + old_snapshot=old_snapshot, + action='cancel', + ) await cancel_reservation_by_order_and_return_stock(session, order_id) await session.commit() return order diff --git a/tests/test_arq_expiry.py b/tests/test_arq_expiry.py index 899b149..2aaf8fd 100644 --- a/tests/test_arq_expiry.py +++ b/tests/test_arq_expiry.py @@ -30,7 +30,7 @@ async def test_arq_concurrent_expiry_no_double_return( name='Test Plate carrier', price=Decimal('100.00'), qty_available=0 ) product = await InventoryService.create_product( - setup_session, user.id, product_data + setup_session, user.id, product_data, current_user=user ) for _ in range(10): reservation = Reservation( diff --git a/tests/test_concurrency_inventory.py b/tests/test_concurrency_inventory.py index 29ba712..67ee0b4 100644 --- a/tests/test_concurrency_inventory.py +++ b/tests/test_concurrency_inventory.py @@ -25,7 +25,7 @@ async def test_concurrent_reservations_service_level( name='Test Sneakers', price=Decimal('100.00'), qty_available=10 ) product = await InventoryService.create_product( - setup_session, user.id, product_data + setup_session, user.id, product_data, current_user=user ) product_id = product.id user_id = user.id