Skip to content

Commit 9634e9f

Browse files
Merge pull request #22 from codewithme-py/feat/audit-logging-prices-and-statuses
feat: implement audit logging for inventory and order services
2 parents 6b56213 + fa27896 commit 9634e9f

11 files changed

Lines changed: 258 additions & 14 deletions

File tree

app/core/audit_log/models.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from datetime import datetime
2+
from typing import Any
3+
from uuid import UUID, uuid4
4+
5+
from sqlalchemy import ForeignKey
6+
from sqlalchemy.orm import Mapped, mapped_column
7+
from sqlalchemy.sql import func
8+
from sqlalchemy.types import JSON, DateTime, String
9+
10+
from app.core.database import Base
11+
12+
13+
class AuditLog(Base):
14+
__tablename__ = 'audit_logs'
15+
16+
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
17+
target_type: Mapped[str] = mapped_column(String(), nullable=False, index=True)
18+
target_id: Mapped[UUID] = mapped_column(nullable=False, index=True)
19+
actor_id: Mapped[UUID] = mapped_column(
20+
ForeignKey('users.id'), nullable=False, index=True
21+
)
22+
action: Mapped[str] = mapped_column(String(), nullable=False, index=True)
23+
changes: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
24+
created_at: Mapped[datetime] = mapped_column(
25+
DateTime(timezone=True), server_default=func.now(), index=True
26+
)
27+
remote_ip: Mapped[str | None] = mapped_column(String(45), index=True)
28+
request_id: Mapped[str | None] = mapped_column(index=True)

app/core/audit_log/service.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from typing import Any
2+
from uuid import UUID
3+
4+
from pydantic import BaseModel
5+
from sqlalchemy.ext.asyncio import AsyncSession
6+
from structlog.contextvars import get_contextvars
7+
8+
from app.core.audit_log.models import AuditLog
9+
10+
11+
class AuditLogService:
12+
async def log_event(
13+
self,
14+
session: AsyncSession,
15+
actor_id: UUID,
16+
target_type: str,
17+
target_id: UUID,
18+
action: str,
19+
changes: dict[str, Any],
20+
) -> None:
21+
context = get_contextvars()
22+
request_id = context.get('request_id')
23+
remote_ip = context.get('remote_ip')
24+
25+
log = AuditLog(
26+
actor_id=actor_id,
27+
target_type=target_type,
28+
target_id=target_id,
29+
action=action,
30+
changes=changes,
31+
request_id=request_id,
32+
remote_ip=remote_ip,
33+
)
34+
session.add(log)
35+
await session.flush()
36+
37+
@staticmethod
38+
def get_diff(
39+
old_model: BaseModel | None,
40+
new_model: BaseModel | None,
41+
) -> dict[str, Any]:
42+
if old_model is None and new_model is not None:
43+
return {k: [None, v] for k, v in new_model.model_dump(mode='json').items()}
44+
if old_model is not None and new_model is None:
45+
return {k: [v, None] for k, v in old_model.model_dump(mode='json').items()}
46+
if old_model is not None and new_model is not None:
47+
old_data = old_model.model_dump(mode='json')
48+
new_data = new_model.model_dump(mode='json')
49+
diff = {}
50+
for key, value in new_data.items():
51+
old_val = old_data.get(key)
52+
if value != old_val:
53+
diff[key] = [old_val, value]
54+
return diff
55+
return {}
56+
57+
async def log_object_change(
58+
self,
59+
session: AsyncSession,
60+
actor_id: UUID,
61+
target_id: UUID,
62+
target_type: str,
63+
action: str,
64+
old_obj: BaseModel | None,
65+
new_obj: BaseModel | None,
66+
) -> None:
67+
diff = self.get_diff(old_obj, new_obj)
68+
if diff:
69+
await self.log_event(
70+
session=session,
71+
actor_id=actor_id,
72+
target_type=target_type,
73+
target_id=target_id,
74+
action=action,
75+
changes=diff,
76+
)
77+
78+
79+
audit_log_service = AuditLogService()

app/main.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
from collections.abc import AsyncGenerator
1+
import uuid
2+
from collections.abc import AsyncGenerator, Awaitable, Callable
23
from contextlib import asynccontextmanager
34

45
import structlog
5-
from fastapi import FastAPI
6+
from fastapi import FastAPI, Request, Response
67
from fastapi.responses import ORJSONResponse
78
from prometheus_fastapi_instrumentator import Instrumentator
89
from redis.asyncio import Redis
10+
from structlog.contextvars import bind_contextvars
911

1012
from app.core.config import settings
1113
from app.core.logging import setup_logging
@@ -47,6 +49,25 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
4749

4850
setup_exception_handlers(app)
4951

52+
53+
@app.middleware('http')
54+
async def add_request_context(
55+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
56+
) -> Response:
57+
request_id = str(uuid.uuid4())
58+
bind_contextvars(
59+
request_id=request_id,
60+
remote_ip=request.client.host if request.client else 'unknown',
61+
)
62+
try:
63+
response = await call_next(request)
64+
response.headers['X-Request-ID'] = request_id
65+
except Exception as e:
66+
logger.error('request failed', exc_info=True)
67+
raise e
68+
return response
69+
70+
5071
app.include_router(user_router_v1, prefix='/api/v1', tags=['Users'])
5172
app.include_router(order_router_v1, prefix='/api/v1', tags=['Orders'])
5273
app.include_router(inventory_router_v1, prefix='/api/v1', tags=['Inventory'])

app/services/inventory/models.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from datetime import datetime
22
from decimal import Decimal
33
from enum import StrEnum
4+
from typing import TYPE_CHECKING
45
from uuid import UUID, uuid4
56

67
from sqlalchemy import CheckConstraint, ForeignKey, Numeric
78
from sqlalchemy import Enum as SQLEnum
8-
from sqlalchemy.orm import Mapped, mapped_column
9+
from sqlalchemy.orm import Mapped, mapped_column, relationship
910
from sqlalchemy.sql import func
1011
from sqlalchemy.types import DateTime, Integer, String, Text
1112

@@ -45,6 +46,11 @@ class Product(Base):
4546
status: Mapped[ProductStatus] = mapped_column(
4647
SQLEnum(ProductStatus), nullable=False, default=ProductStatus.DRAFT
4748
)
49+
if TYPE_CHECKING:
50+
from app.services.media.models import ProductImage
51+
images: Mapped[list['ProductImage']] = relationship(
52+
back_populates='product', cascade='all, delete-orphan'
53+
)
4854

4955
__table_args__ = (
5056
CheckConstraint('qty_available >= 0', name='check_qty_non_negative'),

app/services/inventory/routes.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Annotated, Any
1+
from typing import Annotated
22
from uuid import UUID
33

44
from fastapi import APIRouter, Depends, Header, Request, status
@@ -67,6 +67,7 @@ async def create_product(
6767
current_user: Annotated[User, SELLER_DEPENDENCY],
6868
) -> ProductRead:
6969
product = await InventoryService.create_product(
70+
current_user=current_user,
7071
session=session,
7172
product_data=product_data,
7273
owner_id=current_user.id,
@@ -78,12 +79,13 @@ async def create_product(
7879
async def activate_product(
7980
product_id: UUID,
8081
session: Annotated[AsyncSession, Depends(get_session)],
81-
_: Any = ADMIN_DEPENDENCY,
82+
current_user: Annotated[User, ADMIN_DEPENDENCY],
8283
) -> ProductRead:
8384
product = await InventoryService.change_status(
8485
session=session,
8586
product_id=product_id,
8687
status=ProductStatus.ACTIVE,
88+
current_user=current_user,
8789
)
8890
return ProductRead.model_validate(product)
8991

app/services/inventory/schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class ProductRead(BaseModel):
2929
model_config = ConfigDict(from_attributes=True)
3030
id: UUID
3131
name: str
32-
description: str
32+
description: str | None = None
3333
price: Decimal
3434
qty_available: int
3535
status: ProductStatus

app/services/inventory/service.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from sqlalchemy.exc import IntegrityError
66
from sqlalchemy.ext.asyncio import AsyncSession
77

8+
from app.core.audit_log.service import audit_log_service
89
from app.core.config import settings
910
from app.core.exceptions import (
1011
ConflictError,
@@ -15,6 +16,7 @@
1516
from app.services.inventory.models import Product, ProductStatus, Reservation
1617
from app.services.inventory.schemas import (
1718
ProductCreate,
19+
ProductRead,
1820
ProductUpdate,
1921
ReservationCreate,
2022
)
@@ -41,25 +43,65 @@ async def _get_product(
4143
check_ownership(current_user, product)
4244
return product
4345

46+
@staticmethod
47+
async def _log_product_change(
48+
session: AsyncSession,
49+
user: User,
50+
product: Product,
51+
old_snapshot: ProductRead | None,
52+
action: str,
53+
) -> None:
54+
await audit_log_service.log_object_change(
55+
session=session,
56+
actor_id=user.id,
57+
target_id=product.id,
58+
target_type='product',
59+
action=action,
60+
old_obj=old_snapshot,
61+
new_obj=ProductRead.model_validate(product),
62+
)
63+
4464
@staticmethod
4565
async def change_status(
46-
session: AsyncSession, product_id: UUID, status: ProductStatus
66+
session: AsyncSession,
67+
product_id: UUID,
68+
status: ProductStatus,
69+
current_user: User,
4770
) -> Product:
4871
product = await InventoryService._get_product(
4972
session, product_id, for_update=True
5073
)
74+
old_snapshot = ProductRead.model_validate(product)
5175
product.status = status
76+
await InventoryService._log_product_change(
77+
session=session,
78+
user=current_user,
79+
product=product,
80+
old_snapshot=old_snapshot,
81+
action='update',
82+
)
5283
await session.commit()
5384
await session.refresh(product)
5485
return product
5586

5687
@staticmethod
5788
async def create_product(
58-
session: AsyncSession, owner_id: UUID, product_data: ProductCreate
89+
session: AsyncSession,
90+
owner_id: UUID,
91+
product_data: ProductCreate,
92+
current_user: User,
5993
) -> Product:
6094
new_product = Product(**product_data.model_dump())
6195
new_product.owner_id = owner_id
6296
session.add(new_product)
97+
await session.flush()
98+
await InventoryService._log_product_change(
99+
session=session,
100+
user=current_user,
101+
product=new_product,
102+
old_snapshot=None,
103+
action='create',
104+
)
63105
await session.commit()
64106
await session.refresh(new_product)
65107
return new_product
@@ -74,8 +116,16 @@ async def update_product(
74116
product = await InventoryService._get_product(
75117
session, product_id, for_update=True, current_user=current_user
76118
)
119+
old_snapshot = ProductRead.model_validate(product)
77120
for field, value in product_data.model_dump(exclude_unset=True).items():
78121
setattr(product, field, value)
122+
await InventoryService._log_product_change(
123+
session=session,
124+
user=current_user,
125+
product=product,
126+
old_snapshot=old_snapshot,
127+
action='update',
128+
)
79129
await session.commit()
80130
await session.refresh(product)
81131
return product
@@ -89,6 +139,13 @@ async def delete_product(
89139
product = await InventoryService._get_product(
90140
session, product_id, for_update=True, current_user=current_user
91141
)
142+
await InventoryService._log_product_change(
143+
session=session,
144+
user=current_user,
145+
product=product,
146+
old_snapshot=ProductRead.model_validate(product),
147+
action='delete',
148+
)
92149
await session.delete(product)
93150
await session.commit()
94151

app/services/media/models.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from datetime import datetime
22
from enum import StrEnum
3+
from typing import TYPE_CHECKING
34
from uuid import UUID, uuid4
45

56
from sqlalchemy import ForeignKey
6-
from sqlalchemy.orm import Mapped, mapped_column
7+
from sqlalchemy.orm import Mapped, mapped_column, relationship
78
from sqlalchemy.sql import func
89
from sqlalchemy.types import DateTime, String
910

@@ -28,3 +29,7 @@ class ProductImage(Base):
2829
updated_at: Mapped[datetime] = mapped_column(
2930
DateTime, server_default=func.now(), onupdate=func.now()
3031
)
32+
33+
if TYPE_CHECKING:
34+
from app.services.inventory.models import Product
35+
product: Mapped['Product'] = relationship(back_populates='images')

0 commit comments

Comments
 (0)