Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/adapters/api/v1/business_notifications.py
2026-05-05 05:00:17 +03:30

515 lines
19 KiB
Python
Executable file

"""
API Endpoints برای مدیریت قالب‌های نوتیفیکیشن کسب‌وکارها
"""
from typing import Optional, Dict, Any
from fastapi import APIRouter, Depends, Request, Query, Body
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.responses import success_response, ApiError
from adapters.db.repositories.business_notification_repo import (
NotificationEventTypeRepository,
BusinessNotificationTemplateRepository,
NotificationModerationQueueRepository,
NotificationSendLogRepository
)
from app.services.business_notification_service import (
BusinessNotificationService,
TemplateRenderService
)
router = APIRouter(prefix="/business-notifications", tags=["Business Notifications"])
# ========== Schema Models ==========
class TemplateCreate(BaseModel):
"""ایجاد قالب جدید"""
code: str = Field(..., max_length=100, description="کد یکتا قالب")
name: str = Field(..., max_length=200, description="نام قالب")
description: Optional[str] = None
event_type: str = Field(..., description="نوع رویداد (مثلاً invoice.created)")
channel: str = Field(..., description="کانال: sms یا email")
recipient_type: str = Field(default="customer", description="نوع گیرنده")
subject: Optional[str] = Field(None, max_length=200, description="موضوع (برای email)")
body: str = Field(..., description="محتوای قالب")
daily_limit: int = Field(default=100, ge=1, le=10000, description="حداکثر ارسال روزانه")
is_automated: bool = Field(default=False, description="ارسال خودکار")
class TemplateUpdate(BaseModel):
"""به‌روزرسانی قالب"""
name: Optional[str] = Field(None, max_length=200)
description: Optional[str] = None
subject: Optional[str] = Field(None, max_length=200)
body: Optional[str] = None
daily_limit: Optional[int] = Field(None, ge=1, le=10000)
is_automated: Optional[bool] = None
is_active: Optional[bool] = None
class SendNotificationRequest(BaseModel):
"""درخواست ارسال نوتیفیکیشن"""
person_id: int = Field(..., description="شناسه Person")
event_type: str = Field(..., description="نوع رویداد")
context: Dict[str, Any] = Field(..., description="داده‌های متغیرها")
channel: Optional[str] = Field(None, description="کانال (None = همه)")
recipient_mobile: Optional[str] = Field(None, description="شماره موبایل مقصد (اختیاری؛ در صورت ارسال برای SMS به این شماره ارسال می‌شود)")
class PreviewTemplateRequest(BaseModel):
"""درخواست پیش‌نمایش قالب"""
sample_context: Dict[str, Any] = Field(..., description="Context نمونه")
# ========== Event Types (لیست رویدادهای قابل استفاده) ==========
@router.get("/event-types")
def list_event_types(
request: Request,
category: Optional[str] = Query(None),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
لیست انواع رویدادهای قابل استفاده
هر کسب‌وکار می‌تواند برای این رویدادها قالب تعریف کند
"""
repo = NotificationEventTypeRepository(db)
event_types = repo.list_all(category=category, is_active=True)
items = []
for et in event_types:
items.append({
"id": et.id,
"code": et.code,
"name": et.name,
"description": et.description,
"category": et.category,
"available_variables": et.available_variables or [],
"default_sms_template": et.default_sms_template,
"default_email_template": et.default_email_template,
"requires_approval": et.requires_approval
})
return success_response({"items": items, "total": len(items)}, request)
# ========== Templates Management (برای کسب‌وکار) ==========
@router.get("/businesses/{business_id}/templates")
def list_templates(
request: Request,
business_id: int,
status: Optional[str] = Query(None),
channel: Optional[str] = Query(None),
event_type: Optional[str] = Query(None),
is_active: Optional[bool] = Query(None, description="فقط قالب‌های فعال یا غیرفعال"),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""لیست قالب‌های یک کسب‌وکار"""
if not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار ندارید", http_status=403)
filters = {
"status": status,
"channel": channel,
"event_type": event_type,
"is_active": is_active,
}
repo = BusinessNotificationTemplateRepository(db)
templates, total = repo.list_by_business(business_id, filters, offset, limit)
items = []
for t in templates:
items.append({
"id": t.id,
"code": t.code,
"name": t.name,
"description": t.description,
"event_type": t.event_type,
"channel": t.channel,
"status": t.status,
"is_active": t.is_active,
"approval_status": t.approval_status,
"approved_by_ai": t.approved_by_ai,
"ai_confidence_score": float(t.ai_confidence_score) if t.ai_confidence_score else None,
"admin_review_notes": t.admin_review_notes,
"rejection_reason": t.rejection_reason,
"daily_limit": t.daily_limit,
"is_automated": t.is_automated,
"created_at": t.created_at.isoformat(),
"updated_at": t.updated_at.isoformat()
})
return success_response({
"items": items,
"total": total,
"offset": offset,
"limit": limit
}, request)
@router.get("/businesses/{business_id}/templates/{template_id}")
def get_template(
request: Request,
business_id: int,
template_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""دریافت جزئیات یک قالب"""
if not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار ندارید", http_status=403)
repo = BusinessNotificationTemplateRepository(db)
template = repo.get_by_id(template_id, business_id)
if not template:
raise ApiError("TEMPLATE_NOT_FOUND", "قالب یافت نشد", http_status=404)
data = {
"id": template.id,
"code": template.code,
"name": template.name,
"description": template.description,
"event_type": template.event_type,
"channel": template.channel,
"recipient_type": template.recipient_type,
"subject": template.subject,
"body": template.body,
"available_variables": template.available_variables or [],
"status": template.status,
"is_active": template.is_active,
"approval_status": template.approval_status,
"approved_by_ai": template.approved_by_ai,
"ai_confidence_score": float(template.ai_confidence_score) if template.ai_confidence_score else None,
"ai_review_notes": template.ai_review_notes,
"admin_review_notes": template.admin_review_notes,
"rejection_reason": template.rejection_reason,
"daily_limit": template.daily_limit,
"is_automated": template.is_automated,
"created_at": template.created_at.isoformat(),
"updated_at": template.updated_at.isoformat()
}
return success_response(data, request)
@router.get("/businesses/{business_id}/templates/{template_id}/sms-cost-estimate")
def sms_cost_estimate(
request: Request,
business_id: int,
template_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
برآورد هزینه پیامک برای ورک‌فلو / UI (بر اساس متن خام قالب و قیمت‌گذاری سیستم).
"""
if not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار ندارید", http_status=403)
svc = BusinessNotificationService(db)
data = svc.estimate_sms_template_cost(business_id, template_id)
return success_response(data, request)
@router.post("/businesses/{business_id}/templates")
def create_template(
request: Request,
business_id: int,
data: TemplateCreate,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
ایجاد قالب جدید
قالب با وضعیت 'draft' ایجاد می‌شود و باید برای تایید ارسال شود
"""
if not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار ندارید", http_status=403)
if not ctx.has_business_permission("notifications", "write"):
raise ApiError("FORBIDDEN", "دسترسی ایجاد قالب ندارید", http_status=403)
repo = BusinessNotificationTemplateRepository(db)
# بررسی تکراری بودن code
existing = repo.get_by_code(business_id, data.code)
if existing:
raise ApiError("CODE_EXISTS", "کد قالب تکراری است", http_status=409)
# بررسی event_type معتبر باشد
event_repo = NotificationEventTypeRepository(db)
event_type = event_repo.get_by_code(data.event_type)
if not event_type:
raise ApiError("INVALID_EVENT_TYPE", "نوع رویداد نامعتبر است", http_status=400)
# اعتبارسنجی قالب
renderer = TemplateRenderService()
validation = renderer.validate_template(
data.body,
[var['key'] for var in (event_type.available_variables or [])]
)
if not validation['is_valid']:
raise ApiError(
"INVALID_TEMPLATE",
f"قالب نامعتبر است: {', '.join(validation['errors'])}",
http_status=400
)
# ایجاد قالب
template_data = data.dict()
template_data.update({
"business_id": business_id,
"created_by_user_id": ctx.user.id,
"available_variables": event_type.available_variables,
"status": "draft",
"is_active": False,
"approval_status": "pending"
})
template = repo.create(template_data)
db.commit()
return success_response({
"id": template.id,
"code": template.code,
"status": template.status,
"message": "قالب ایجاد شد. برای فعال‌سازی باید آن را برای تایید ارسال کنید."
}, request)
@router.put("/businesses/{business_id}/templates/{template_id}")
def update_template(
request: Request,
business_id: int,
template_id: int,
data: TemplateUpdate,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""به‌روزرسانی قالب"""
if not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار ندارید", http_status=403)
if not ctx.has_business_permission("notifications", "write"):
raise ApiError("FORBIDDEN", "دسترسی ویرایش قالب ندارید", http_status=403)
repo = BusinessNotificationTemplateRepository(db)
template = repo.get_by_id(template_id, business_id)
if not template:
raise ApiError("TEMPLATE_NOT_FOUND", "قالب یافت نشد", http_status=404)
# قالب‌های تایید شده را نمی‌توان ویرایش کرد
if template.status == "approved":
raise ApiError(
"CANNOT_EDIT_APPROVED",
"قالب تایید شده قابل ویرایش نیست. باید قالب جدید ایجاد کنید.",
http_status=400
)
# اعتبارسنجی body در صورت تغییر
if data.body:
event_repo = NotificationEventTypeRepository(db)
event_type = event_repo.get_by_code(template.event_type)
renderer = TemplateRenderService()
validation = renderer.validate_template(
data.body,
[var['key'] for var in (event_type.available_variables or [])]
)
if not validation['is_valid']:
raise ApiError(
"INVALID_TEMPLATE",
f"قالب نامعتبر است: {', '.join(validation['errors'])}",
http_status=400
)
# به‌روزرسانی
update_data = data.dict(exclude_unset=True)
template = repo.update(template, update_data)
db.commit()
return success_response({
"id": template.id,
"message": "قالب به‌روزرسانی شد"
}, request)
@router.post("/businesses/{business_id}/templates/{template_id}/submit-for-approval")
def submit_for_approval(
request: Request,
business_id: int,
template_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
ارسال قالب برای تایید
قالب وارد صف بررسی می‌شود و ابتدا توسط AI بررسی می‌شود
"""
if not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار ندارید", http_status=403)
template_repo = BusinessNotificationTemplateRepository(db)
template = template_repo.get_by_id(template_id, business_id)
if not template:
raise ApiError("TEMPLATE_NOT_FOUND", "قالب یافت نشد", http_status=404)
if template.status not in ["draft", "rejected"]:
raise ApiError(
"INVALID_STATUS",
f"قالب در وضعیت {template.status} قابل ارسال برای تایید نیست",
http_status=400
)
# بررسی اینکه قبلاً در صف نباشد
queue_repo = NotificationModerationQueueRepository(db)
existing = queue_repo.get_by_template(template_id)
if existing:
raise ApiError(
"ALREADY_IN_QUEUE",
"این قالب در حال حاضر در صف بررسی است",
http_status=409
)
# ایجاد آیتم در صف
queue_data = {
"template_id": template_id,
"business_id": business_id,
"status": "pending",
"priority": 0
}
queue_repo.create(queue_data)
# به‌روزرسانی وضعیت قالب
template_repo.update(template, {
"status": "pending_approval"
})
db.commit()
return success_response({
"message": "قالب برای تایید ارسال شد و به زودی بررسی خواهد شد",
"estimated_time": "معمولاً کمتر از 5 دقیقه"
}, request)
@router.post("/businesses/{business_id}/templates/{template_id}/preview")
def preview_template(
request: Request,
business_id: int,
template_id: int,
data: PreviewTemplateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""پیش‌نمایش قالب با context نمونه"""
if not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار ندارید", http_status=403)
service = BusinessNotificationService(db)
result = service.preview_template(business_id, template_id, data.sample_context)
return success_response(result, request)
@router.post("/businesses/{business_id}/send")
def send_notification(
request: Request,
business_id: int,
data: SendNotificationRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
ارسال نوتیفیکیشن به یک Person
از قالب فعال مرتبط با event_type استفاده می‌کند
"""
if not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار ندارید", http_status=403)
if not ctx.has_business_permission("notifications", "send"):
raise ApiError("FORBIDDEN", "دسترسی ارسال نوتیفیکیشن ندارید", http_status=403)
service = BusinessNotificationService(db)
result = service.send_to_person(
business_id=business_id,
person_id=data.person_id,
event_type=data.event_type,
context=data.context,
channel=data.channel,
recipient_mobile_override=(data.recipient_mobile.strip() or None) if data.recipient_mobile else None,
triggered_by_user_id=ctx.user.id
)
return success_response(result, request)
@router.get("/businesses/{business_id}/logs")
def list_send_logs(
request: Request,
business_id: int,
channel: Optional[str] = Query(None),
status: Optional[str] = Query(None),
template_id: Optional[int] = Query(None),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""لیست لاگ‌های ارسال"""
if not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "دسترسی به این کسب‌وکار ندارید", http_status=403)
filters = {
"channel": channel,
"status": status,
"template_id": template_id
}
repo = NotificationSendLogRepository(db)
logs, total = repo.list_by_business(business_id, filters, offset, limit)
items = []
for log in logs:
items.append({
"id": log.id,
"template_id": log.template_id,
"recipient_type": log.recipient_type,
"recipient_id": log.recipient_id,
"recipient_identifier": log.recipient_identifier,
"channel": log.channel,
"status": log.status,
"event_type": log.event_type,
"sent_at": log.sent_at.isoformat() if log.sent_at else None,
"failed_at": log.failed_at.isoformat() if log.failed_at else None,
"failure_reason": log.failure_reason,
"cost": float(log.cost) if log.cost else None,
"created_at": log.created_at.isoformat()
})
return success_response({
"items": items,
"total": total,
"offset": offset,
"limit": limit
}, request)