Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions app/core/audit_log/models.py
Original file line number Diff line number Diff line change
@@ -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)
79 changes: 79 additions & 0 deletions app/core/audit_log/service.py
Original file line number Diff line number Diff line change
@@ -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()
25 changes: 23 additions & 2 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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'])
Expand Down
8 changes: 7 additions & 1 deletion app/services/inventory/models.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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'),
Expand Down
6 changes: 4 additions & 2 deletions app/services/inventory/routes.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion app/services/inventory/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 59 additions & 2 deletions app/services/inventory/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,6 +16,7 @@
from app.services.inventory.models import Product, ProductStatus, Reservation
from app.services.inventory.schemas import (
ProductCreate,
ProductRead,
ProductUpdate,
ReservationCreate,
)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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()

Expand Down
7 changes: 6 additions & 1 deletion app/services/media/models.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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')
Loading