arc/docs/ACTIVITY_LOGGING_MINIMAL_CHANGES_APPROACH.md
2026-04-14 19:34:55 +03:30

660 lines
23 KiB
Markdown
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# راهکار لاگ‌گیری با حداقل تغییرات در Endpoint ها
## 🎯 هدف
پیاده‌سازی سیستم لاگ‌گیری با **حداقل تغییرات** در endpoint های موجود با استفاده از:
1. **SQLAlchemy Events** - برای لاگ‌گیری خودکار عملیات CRUD
2. **Decorator** - برای لاگ‌گیری عملیات خاص
3. **Context Manager** - برای ذخیره اطلاعات کاربر در session
---
## 🔧 راهکار 1: SQLAlchemy Events (بهترین راهکار)
### مزایا
- ✅ **بدون تغییر endpoint ها** - لاگ‌گیری کاملاً خودکار
- ✅ **پوشش کامل** - تمام عملیات create, update, delete را پوشش می‌دهد
- ✅ **عملکرد خوب** - در سطح ORM اجرا می‌شود
- ✅ **قابل اعتماد** - حتی اگر از repository یا service استفاده نشود، کار می‌کند
### پیاده‌سازی
**فایل:** `hesabixAPI/adapters/db/session.py` (یا فایل جدید `activity_log_hooks.py`)
```python
from sqlalchemy import event
from sqlalchemy.orm import Session
from datetime import datetime
from typing import Dict, Any, Optional
from adapters.db.models.activity_log import ActivityLog
from adapters.db.models import (
Document, WarehouseDocument, Product, Person,
Business, Account, User, FiscalYear
)
# Context برای ذخیره اطلاعات کاربر جاری در session
class ActivityLogContext:
"""Context برای ذخیره اطلاعات کاربر و request در session"""
_contexts: Dict[int, Dict[str, Any]] = {} # session_id -> context
@classmethod
def set_context(cls, session: Session, user_id: int, business_id: Optional[int] = None, request: Optional[Any] = None):
"""تنظیم context برای session"""
session_id = id(session)
cls._contexts[session_id] = {
"user_id": user_id,
"business_id": business_id,
"request": request,
"session": session
}
@classmethod
def get_context(cls, session: Session) -> Optional[Dict[str, Any]]:
"""دریافت context از session"""
session_id = id(session)
return cls._contexts.get(session_id)
@classmethod
def clear_context(cls, session: Session):
"""پاک کردن context بعد از commit"""
session_id = id(session)
cls._contexts.pop(session_id, None)
# Mapping مدل‌ها به category و entity_type
MODEL_CATEGORY_MAP = {
Document: ("accounting", "document"),
WarehouseDocument: ("warehouse", "warehouse_document"),
Product: ("product", "product"),
Person: ("person", "person"),
Business: ("business", "business"),
Account: ("accounting", "account"),
User: ("user", "user"),
FiscalYear: ("accounting", "fiscal_year"),
}
# Helper function برای استخراج business_id از instance
def get_business_id(instance) -> Optional[int]:
"""استخراج business_id از instance"""
if hasattr(instance, 'business_id'):
return getattr(instance, 'business_id')
# برای User که business_id ندارد
return None
# Helper function برای ساخت description
def build_description(instance, action: str) -> str:
"""ساخت description قابل خواندن"""
model_name = instance.__class__.__name__
# استخراج نام یا کد برای نمایش
name = None
if hasattr(instance, 'name'):
name = getattr(instance, 'name')
elif hasattr(instance, 'code'):
name = getattr(instance, 'code')
elif hasattr(instance, 'first_name') and hasattr(instance, 'last_name'):
name = f"{getattr(instance, 'first_name', '')} {getattr(instance, 'last_name', '')}".strip()
elif hasattr(instance, 'email'):
name = getattr(instance, 'email')
name_str = f" '{name}'" if name else ""
action_map = {
"create": "ایجاد شد",
"update": "ویرایش شد",
"delete": "حذف شد"
}
action_persian = action_map.get(action, action)
# نام فارسی برای مدل‌ها
model_name_map = {
"Document": "سند",
"WarehouseDocument": "حواله انبار",
"Product": "محصول",
"Person": "شخص",
"Business": "کسب و کار",
"Account": "حساب",
"User": "کاربر",
"FiscalYear": "سال مالی"
}
model_persian = model_name_map.get(model_name, model_name)
return f"{model_persian}{name_str} {action_persian}"
# Helper function برای استخراج داده‌های مهم
def extract_key_fields(instance) -> Dict[str, Any]:
"""استخراج فیلدهای مهم برای لاگ"""
key_fields = {}
# فیلدهای مشترک
if hasattr(instance, 'id'):
key_fields['id'] = getattr(instance, 'id')
if hasattr(instance, 'code'):
key_fields['code'] = getattr(instance, 'code')
if hasattr(instance, 'name'):
key_fields['name'] = getattr(instance, 'name')
# فیلدهای خاص برای Document
if isinstance(instance, Document):
if hasattr(instance, 'document_type'):
key_fields['document_type'] = getattr(instance, 'document_type')
if hasattr(instance, 'document_date'):
key_fields['document_date'] = str(getattr(instance, 'document_date'))
# فیلدهای خاص برای Product
if isinstance(instance, Product):
if hasattr(instance, 'base_sales_price'):
key_fields['base_sales_price'] = float(getattr(instance, 'base_sales_price') or 0)
# فیلدهای خاص برای Person
if isinstance(instance, Person):
if hasattr(instance, 'first_name'):
key_fields['first_name'] = getattr(instance, 'first_name')
if hasattr(instance, 'last_name'):
key_fields['last_name'] = getattr(instance, 'last_name')
return key_fields
# Event Handlers
@event.listens_for(Session, "after_flush")
def receive_after_flush(session: Session, flush_context):
"""لاگ‌گیری بعد از flush (قبل از commit)"""
context = ActivityLogContext.get_context(session)
if not context:
return # اگر context تنظیم نشده، لاگ نگیر
user_id = context.get("user_id")
business_id = context.get("business_id")
request = context.get("request")
if not user_id:
return
# پردازش instances جدید (insert)
for instance in session.new:
if instance.__class__ not in MODEL_CATEGORY_MAP:
continue
category, entity_type = MODEL_CATEGORY_MAP[instance.__class__]
instance_business_id = get_business_id(instance) or business_id
# برای User، business_id نداریم
if isinstance(instance, User):
instance_business_id = None
description = build_description(instance, "create")
after_data = extract_key_fields(instance)
# استخراج extra_info از request
extra_info = {}
if request:
if hasattr(request, 'client') and request.client:
extra_info['ip_address'] = request.client.host
if hasattr(request, 'headers'):
user_agent = request.headers.get("User-Agent")
if user_agent:
extra_info['user_agent'] = user_agent
log = ActivityLog(
user_id=user_id,
business_id=instance_business_id,
category=category,
action="create",
entity_type=entity_type,
entity_id=getattr(instance, 'id', None),
description=description,
after_data=after_data,
extra_info=extra_info if extra_info else None,
created_at=datetime.utcnow()
)
session.add(log)
# پردازش instances تغییر یافته (update)
for instance in session.dirty:
if instance.__class__ not in MODEL_CATEGORY_MAP:
continue
category, entity_type = MODEL_CATEGORY_MAP[instance.__class__]
instance_business_id = get_business_id(instance) or business_id
if isinstance(instance, User):
instance_business_id = None
# استخراج تغییرات
before_data = {}
after_data = {}
# SQLAlchemy history برای تغییرات
from sqlalchemy.orm.attributes import get_history
for attr_name in instance.__table__.columns.keys():
if attr_name in ['id', 'created_at', 'updated_at']:
continue
history = get_history(instance, attr_name)
if history.has_changes():
# مقدار قبلی
if history.deleted:
before_data[attr_name] = history.deleted[0]
# مقدار جدید
if history.added:
after_data[attr_name] = history.added[0]
# فقط اگر تغییری وجود داشت
if before_data or after_data:
description = build_description(instance, "update")
extra_info = {}
if request:
if hasattr(request, 'client') and request.client:
extra_info['ip_address'] = request.client.host
if hasattr(request, 'headers'):
user_agent = request.headers.get("User-Agent")
if user_agent:
extra_info['user_agent'] = user_agent
log = ActivityLog(
user_id=user_id,
business_id=instance_business_id,
category=category,
action="update",
entity_type=entity_type,
entity_id=getattr(instance, 'id', None),
description=description,
before_data=before_data if before_data else None,
after_data=after_data if after_data else None,
extra_info=extra_info if extra_info else None,
created_at=datetime.utcnow()
)
session.add(log)
# پردازش instances حذف شده (delete)
for instance in session.deleted:
if instance.__class__ not in MODEL_CATEGORY_MAP:
continue
category, entity_type = MODEL_CATEGORY_MAP[instance.__class__]
instance_business_id = get_business_id(instance) or business_id
if isinstance(instance, User):
instance_business_id = None
description = build_description(instance, "delete")
before_data = extract_key_fields(instance)
extra_info = {}
if request:
if hasattr(request, 'client') and request.client:
extra_info['ip_address'] = request.client.host
if hasattr(request, 'headers'):
user_agent = request.headers.get("User-Agent")
if user_agent:
extra_info['user_agent'] = user_agent
log = ActivityLog(
user_id=user_id,
business_id=instance_business_id,
category=category,
action="delete",
entity_type=entity_type,
entity_id=getattr(instance, 'id', None),
description=description,
before_data=before_data,
extra_info=extra_info if extra_info else None,
created_at=datetime.utcnow()
)
session.add(log)
@event.listens_for(Session, "after_commit")
def receive_after_commit(session: Session):
"""پاک کردن context بعد از commit"""
ActivityLogContext.clear_context(session)
@event.listens_for(Session, "after_rollback")
def receive_after_rollback(session: Session):
"""پاک کردن context بعد از rollback"""
ActivityLogContext.clear_context(session)
```
### استفاده در Dependency
**فایل:** `hesabixAPI/app/core/auth_dependency.py` (تغییر کوچک)
```python
from adapters.db.activity_log_hooks import ActivityLogContext
def get_current_user(
request: Request,
db: Session = Depends(get_db)
) -> AuthContext:
# ... کد موجود ...
auth_context = AuthContext(
user=user,
api_key_id=obj.id,
language=language,
calendar_type=calendar_type,
timezone=timezone,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
db=db
)
# تنظیم context برای لاگ‌گیری خودکار
ActivityLogContext.set_context(
session=db,
user_id=user.id,
business_id=business_id,
request=request
)
return auth_context
```
**نتیجه:** با این تغییر کوچک، تمام عملیات CRUD به صورت خودکار لاگ می‌شوند! 🎉
---
## 🔧 راهکار 2: Decorator برای عملیات خاص
برای عملیات‌هایی که در SQLAlchemy Events نمی‌گنجند (مثل post, cancel, approve):
**فایل:** `hesabixAPI/app/core/activity_log_decorator.py`
```python
from functools import wraps
from typing import Callable, Any, Optional
from fastapi import Request
from sqlalchemy.orm import Session
from app.services.activity_log_service import log_activity
from app.core.auth_dependency import get_current_user
def log_activity_decorator(
category: str,
action: str,
entity_type: Optional[str] = None,
description_template: Optional[str] = None,
get_entity_id: Optional[Callable] = None,
get_business_id: Optional[Callable] = None
):
"""
Decorator برای لاگ‌گیری خودکار endpoint ها
استفاده:
@log_activity_decorator(
category="warehouse",
action="post",
entity_type="warehouse_document",
description_template="حواله انبار {code} پست شد",
get_entity_id=lambda result: result.get("id"),
get_business_id=lambda kwargs: kwargs.get("business_id")
)
def post_warehouse_document(...):
...
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args, **kwargs):
# استخراج request و db از kwargs
request: Optional[Request] = kwargs.get('request')
db: Optional[Session] = kwargs.get('db')
ctx = kwargs.get('ctx')
if not db or not ctx:
# اگر ctx یا db نبود، تابع را بدون لاگ اجرا کن
result = func(*args, **kwargs)
if hasattr(result, '__await__'):
result = await result
return result
# اجرای تابع اصلی
result = func(*args, **kwargs)
if hasattr(result, '__await__'):
result = await result
# استخراج اطلاعات برای لاگ
entity_id = None
if get_entity_id:
try:
entity_id = get_entity_id(result, *args, **kwargs)
except:
pass
business_id = None
if get_business_id:
try:
business_id = get_business_id(*args, **kwargs)
except:
pass
elif ctx and hasattr(ctx, 'business_id'):
business_id = ctx.business_id
# ساخت description
description = description_template or f"{action} performed"
if description_template and entity_id:
# اگر template داشتیم و entity_id را پیدا کردیم، می‌توانیم اطلاعات بیشتری بگیریم
try:
# می‌توانیم entity را از db بخوانیم و description را کامل کنیم
pass
except:
pass
# لاگ‌گیری
try:
log_activity(
db=db,
user_id=ctx.get_user_id() if hasattr(ctx, 'get_user_id') else ctx.user.id,
category=category,
action=action,
description=description,
business_id=business_id,
entity_type=entity_type,
entity_id=entity_id,
request=request
)
except Exception as e:
# لاگ خطا را بگیر اما endpoint را fail نکن
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to log activity: {e}")
return result
return wrapper
return decorator
```
**استفاده:**
```python
@router.post("/business/{business_id}/warehouse-docs/{wh_id}/post")
@log_activity_decorator(
category="warehouse",
action="post",
entity_type="warehouse_document",
description_template="حواله انبار پست شد",
get_entity_id=lambda result, **kwargs: kwargs.get("wh_id"),
get_business_id=lambda **kwargs: kwargs.get("business_id")
)
def post_warehouse_document(
request: Request,
business_id: int,
wh_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
# کد موجود بدون تغییر
...
```
---
## 🔧 راهکار 3: Context Manager برای عملیات پیچیده
برای عملیات‌هایی که چندین تغییر در یک transaction انجام می‌دهند:
**فایل:** `hesabixAPI/app/core/activity_log_context.py`
```python
from contextlib import contextmanager
from typing import Optional
from sqlalchemy.orm import Session
from app.services.activity_log_service import log_activity
from fastapi import Request
@contextmanager
def activity_log_context(
db: Session,
user_id: int,
category: str,
action: str,
business_id: Optional[int] = None,
entity_type: Optional[str] = None,
entity_id: Optional[int] = None,
description: Optional[str] = None,
request: Optional[Request] = None
):
"""
Context manager برای لاگ‌گیری عملیات پیچیده
استفاده:
with activity_log_context(
db=db,
user_id=user_id,
category="accounting",
action="post",
business_id=business_id,
entity_type="invoice",
entity_id=invoice_id,
description="فاکتور پست شد"
):
# عملیات پیچیده
...
"""
try:
yield
# اگر exception نیفتاد، لاگ را ثبت کن
log_activity(
db=db,
user_id=user_id,
category=category,
action=action,
description=description or f"{action} performed",
business_id=business_id,
entity_type=entity_type,
entity_id=entity_id,
request=request
)
except Exception as e:
# لاگ خطا
log_activity(
db=db,
user_id=user_id,
category=category,
action=f"{action}_failed",
description=f"{description or action} با خطا مواجه شد: {str(e)}",
business_id=business_id,
entity_type=entity_type,
entity_id=entity_id,
request=request,
extra_info={"error": str(e)}
)
raise
```
---
## 📊 مقایسه راهکارها
| راهکار | پوشش | تغییرات لازم | پیچیدگی | مناسب برای |
|--------|------|--------------|----------|------------|
| **SQLAlchemy Events** | ✅ کامل (CRUD) | ✅ حداقل (فقط dependency) | ⭐⭐ | عملیات CRUD ساده |
| **Decorator** | ⚠️ انتخابی | ⭐⭐ متوسط | ⭐⭐⭐ | عملیات خاص (post, cancel) |
| **Context Manager** | ⚠️ دستی | ⭐⭐⭐ زیاد | ⭐⭐ | عملیات پیچیده چند مرحله‌ای |
| **فراخوانی مستقیم** | ⚠️ دستی | ⭐⭐⭐⭐ زیاد | ⭐ | فعالیت‌های خاص (password change) |
---
## 🎯 راهکار پیشنهادی: ترکیبی
### 1. **SQLAlchemy Events** برای 80% عملیات
- ✅ تمام create, update, delete به صورت خودکار
- ✅ فقط یک تغییر کوچک در `get_current_user`
### 2. **Decorator** برای عملیات خاص
- ✅ post, cancel, approve, reject
- ✅ فقط decorator اضافه می‌شود، کد endpoint تغییر نمی‌کند
### 3. **فراخوانی مستقیم** برای موارد خاص
- ✅ تغییر رمز عبور
- ✅ عملیات‌های پیچیده که نیاز به لاگ خاص دارند
---
## 📝 مثال پیاده‌سازی کامل
### مرحله 1: ایجاد فایل Events (یک بار)
```python
# hesabixAPI/adapters/db/activity_log_hooks.py
# (کد کامل در بالا)
```
### مرحله 2: تغییر Dependency (یک خط)
```python
# hesabixAPI/app/core/auth_dependency.py
# در get_current_user، بعد از ساخت AuthContext:
ActivityLogContext.set_context(
session=db,
user_id=user.id,
business_id=business_id,
request=request
)
```
### مرحله 3: Import Events (یک خط)
```python
# hesabixAPI/adapters/db/__init__.py یا app/main.py
import adapters.db.activity_log_hooks # برای ثبت event handlers
```
**نتیجه:** تمام عملیات CRUD به صورت خودکار لاگ می‌شوند! 🎉
---
## ✅ مزایای این راهکار
1. **حداقل تغییرات**: فقط 2-3 خط تغییر در dependency
2. **پوشش کامل**: تمام عملیات CRUD خودکار
3. **قابل اعتماد**: حتی اگر developer فراموش کند، لاگ می‌شود
4. **عملکرد خوب**: در سطح ORM، بدون overhead اضافی
5. **قابل توسعه**: می‌توان مدل‌های جدید را به راحتی اضافه کرد
---
## ⚠️ نکات مهم
1. **Context باید تنظیم شود**: اگر `get_current_user` فراخوانی نشود، لاگ نمی‌شود
2. **مدل‌های جدید**: باید به `MODEL_CATEGORY_MAP` اضافه شوند
3. **تست**: باید تست شود که context درست تنظیم می‌شود
4. **Performance**: Events در flush اجرا می‌شوند، overhead کمی دارد اما قابل قبول است
---
## 🚀 مراحل پیاده‌سازی
1. ✅ ایجاد مدل `ActivityLog` و migration
2. ✅ ایجاد `activity_log_hooks.py` با SQLAlchemy Events
3. ✅ تغییر `get_current_user` برای تنظیم context
4. ✅ Import hooks در `app/main.py`
5. ✅ تست با یک عملیات ساده (مثلاً ایجاد محصول)
6. ✅ اضافه کردن decorator برای عملیات خاص (اختیاری)
7. ✅ اضافه کردن فراخوانی مستقیم برای موارد خاص (اختیاری)
**تغییرات لازم: فقط 2-3 فایل!** 🎉