Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/adapters/api/v1/business_dashboard.py

744 lines
30 KiB
Python
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.

# Removed __future__ annotations to fix OpenAPI schema generation
import structlog
from datetime import datetime
from fastapi import APIRouter, Depends, Request, HTTPException
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.api.v1.schemas import SuccessResponse
from app.core.responses import success_response, format_datetime_fields
from app.core.response_cache import cache_response
from app.core.cache import get_cache
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from app.services.business_dashboard_service import (
get_business_dashboard_data, get_business_members, get_business_statistics
)
from app.services.dashboard_widgets_service import (
get_widget_definitions,
get_dashboard_layout_profile,
save_dashboard_layout_profile,
get_widgets_batch_data,
get_business_default_layout,
save_business_default_layout,
)
from app.services.business_quick_links_service import (
get_quick_link_presets_catalog,
get_or_create_quick_links,
save_quick_links,
)
from app.services.business_menu_preferences_service import (
get_or_create_menu_preferences,
save_menu_preferences,
)
logger = structlog.get_logger()
router = APIRouter(prefix="/business", tags=["business-dashboard"])
@router.post("/{business_id}/dashboard",
summary="دریافت داشبورد کسب و کار",
description="دریافت اطلاعات کلی و آمار کسب و کار",
response_model=SuccessResponse,
responses={
200: {
"description": "داشبورد کسب و کار با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "داشبورد کسب و کار دریافت شد",
"data": {
"business_info": {
"id": 1,
"name": "شرکت نمونه",
"business_type": "شرکت",
"business_field": "تولیدی",
"owner_id": 1,
"created_at": "1403/01/01 00:00:00",
"member_count": 5
},
"statistics": {
"total_sales": 14900000.0,
"total_purchases": 500000.0,
"active_members": 5,
"recent_transactions": 12,
"fiscal_year_id": 1,
"currency": {
"id": 1,
"code": "IRR",
"title": "ریال",
"symbol": "ریال",
"decimal_places": 0
}
},
"recent_activities": [
{
"id": 1,
"title": "فروش جدید",
"description": "فروش محصول A به مبلغ 100,000 تومان",
"icon": "sell",
"time_ago": "2 ساعت پیش"
}
]
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز به کسب و کار"
},
404: {
"description": "کسب و کار یافت نشد"
}
}
)
@require_business_access("business_id")
@cache_response(ttl=60, vary_by=["business_id"])
async def get_business_dashboard(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""دریافت داشبورد کسب و کار"""
dashboard_data = get_business_dashboard_data(db, business_id, ctx)
formatted_data = format_datetime_fields(dashboard_data, request)
return success_response(formatted_data, request)
@router.post("/{business_id}/members",
summary="لیست اعضای کسب و کار",
description="دریافت لیست اعضای کسب و کار",
response_model=SuccessResponse,
responses={
200: {
"description": "لیست اعضا با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "لیست اعضا دریافت شد",
"data": {
"items": [
{
"id": 1,
"user_id": 2,
"first_name": "احمد",
"last_name": "احمدی",
"email": "ahmad@example.com",
"role": "مدیر فروش",
"permissions": {
"sales": {"write": True, "delete": True},
"reports": {"export": True}
},
"joined_at": "1403/01/01 00:00:00"
}
],
"pagination": {
"total": 1,
"page": 1,
"per_page": 10,
"total_pages": 1,
"has_next": False,
"has_prev": False
}
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز به کسب و کار"
}
}
)
@require_business_access("business_id")
def get_business_members(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""لیست اعضای کسب و کار"""
members_data = get_business_members(db, business_id, ctx)
formatted_data = format_datetime_fields(members_data, request)
return success_response(formatted_data, request)
@router.post("/{business_id}/statistics",
summary="آمار کسب و کار",
description="دریافت آمار تفصیلی کسب و کار",
response_model=SuccessResponse,
responses={
200: {
"description": "آمار با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "آمار دریافت شد",
"data": {
"sales_by_month": [
{"month": "1403/01", "amount": 500000},
{"month": "1403/02", "amount": 750000}
],
"top_products": [
{"name": "محصول A", "sales_count": 100, "revenue": 500000}
],
"member_activity": {
"active_today": 3,
"active_this_week": 5,
"total_members": 8
}
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز به کسب و کار"
}
}
)
@require_business_access("business_id")
def get_business_statistics(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""آمار کسب و کار"""
stats_data = get_business_statistics(db, business_id, ctx)
formatted_data = format_datetime_fields(stats_data, request)
return success_response(formatted_data, request)
@router.post("/{business_id}/info-with-permissions",
summary="دریافت اطلاعات کسب و کار و دسترسی‌ها",
description="دریافت اطلاعات کسب و کار همراه با دسترسی‌های کاربر",
response_model=SuccessResponse,
responses={
200: {
"description": "اطلاعات کسب و کار و دسترسی‌ها با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "اطلاعات کسب و کار و دسترسی‌ها دریافت شد",
"data": {
"business_info": {
"id": 1,
"name": "شرکت نمونه",
"business_type": "شرکت",
"business_field": "تولیدی",
"owner_id": 1,
"address": "تهران، خیابان ولیعصر",
"phone": "02112345678",
"mobile": "09123456789",
"created_at": "1403/01/01 00:00:00"
},
"user_permissions": {
"people": {"add": True, "view": True, "edit": True, "delete": False},
"products": {"add": True, "view": True, "edit": False, "delete": False},
"invoices": {"add": True, "view": True, "edit": True, "delete": True}
},
"is_owner": False,
"role": "عضو",
"has_access": True
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز به کسب و کار"
},
404: {
"description": "کسب و کار یافت نشد"
}
}
)
@require_business_access("business_id")
def get_business_info_with_permissions(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""دریافت اطلاعات کسب و کار همراه با دسترسی‌های کاربر"""
from adapters.db.models.business import Business
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
# دریافت اطلاعات کسب و کار با eager loading برای default_currency و currencies
from sqlalchemy.orm import joinedload
business = db.query(Business).options(
joinedload(Business.default_currency),
joinedload(Business.currencies)
).filter(Business.id == business_id).first()
if not business:
from app.core.responses import ApiError
raise ApiError("NOT_FOUND", "Business not found", http_status=404)
# دریافت دسترسی‌های کاربر
permissions = {}
if ctx.is_superadmin():
# SuperAdmin فقط برای مدیریت سیستم است، نه برای کسب و کارهای خاص
# باید دسترسی‌های کسب و کار را از جدول business_permissions دریافت کند
permission_repo = BusinessPermissionRepository(db)
business_permission = permission_repo.get_by_user_and_business(ctx.get_user_id(), business_id)
if business_permission:
permissions = business_permission.business_permissions or {}
else:
permissions = {}
elif ctx.is_business_owner(business_id):
# مالک کسب و کار تمام دسترسی‌ها را دارد
permissions = {
"people": {"add": True, "edit": True, "view": True, "delete": True},
"products": {"add": True, "edit": True, "view": True, "delete": True},
"bank_accounts": {"add": True, "edit": True, "view": True, "delete": True},
"invoices": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
"people_transactions": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
"expenses_income": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
"goods_expense_income": {
"add": True,
"edit": True,
"view": True,
"delete": True,
"submit": True,
"allocate": True,
"post": True,
"cancel": True,
"change_unit_cost": True,
},
"transfers": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
"checks": {"add": True, "edit": True, "view": True, "delete": True, "return": True, "collect": True, "transfer": True},
"accounting_documents": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
"chart_of_accounts": {"add": True, "edit": True, "view": True, "delete": True},
"opening_balance": {"edit": True, "view": True},
"fiscal_years": {"view": True, "edit": True, "close": True, "rollback": True},
"currency_revaluation": {"add": True, "edit": True, "view": True, "delete": True},
"settings": {"print": True, "users": True, "history": True, "business": True},
"categories": {"add": True, "edit": True, "view": True, "delete": True},
"product_attributes": {"add": True, "edit": True, "view": True, "delete": True},
"warehouses": {"add": True, "edit": True, "view": True, "delete": True},
"warehouse_transfers": {"add": True, "edit": True, "view": True, "draft": True, "delete": True},
"cash": {"add": True, "edit": True, "view": True, "delete": True},
"petty_cash": {"add": True, "edit": True, "view": True, "delete": True},
"wallet": {"view": True, "charge": True},
"storage": {"view": True, "delete": True},
"marketplace": {"buy": True, "view": True, "invoices": True},
"moadian": {
"view": True,
"operate": True,
"manage_settings": True,
"export_reports": True,
},
"price_lists": {"add": True, "edit": True, "view": True, "delete": True},
"sms": {"history": True, "templates": True},
"join": True
}
else:
# دریافت دسترسی‌های کسب و کار از business_permissions
permission_repo = BusinessPermissionRepository(db)
# ترتیب آرگومان‌ها: (user_id, business_id)
business_permission = permission_repo.get_by_user_and_business(ctx.get_user_id(), business_id)
if business_permission:
permissions = business_permission.business_permissions or {}
# استفاده از _business_to_dict برای دریافت اطلاعات کامل کسب و کار شامل default_currency و currencies
from app.services.business_service import _business_to_dict
business_dict = _business_to_dict(business)
# استخراج فیلدهای مورد نیاز برای business_info
created_at_value = business_dict.get("created_at")
if isinstance(created_at_value, datetime):
created_at_str = created_at_value.isoformat()
else:
created_at_str = str(created_at_value) if created_at_value else ""
business_info = {
"id": business_dict["id"],
"name": business_dict["name"],
"business_type": business_dict["business_type"],
"business_field": business_dict["business_field"],
"owner_id": business_dict["owner_id"],
"address": business_dict["address"],
"phone": business_dict["phone"],
"mobile": business_dict["mobile"],
"created_at": created_at_str,
}
is_owner = ctx.is_business_owner(business_id)
has_access = ctx.can_access_business(business_id)
response_data = {
"business_info": business_info,
"user_permissions": permissions,
"is_owner": is_owner,
"role": "مالک" if is_owner else "عضو",
"has_access": has_access,
# اضافه کردن default_currency و currencies به response
"default_currency": business_dict.get("default_currency"),
"currencies": business_dict.get("currencies", []),
}
logger.info(f"=== get_business_info_with_permissions END ===")
formatted_data = format_datetime_fields(response_data, request)
return success_response(formatted_data, request)
# === Dashboard Widgets (Responsive/Per-User) ===
@router.get("/{business_id}/dashboard/widgets/definitions",
summary="تعاریف ویجت‌های داشبورد",
description="لیست ویجت‌های قابل استفاده برای کاربر فعلی (بر اساس مجوزها) + ستون‌بندی رسپانسیو",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def list_dashboard_widget_definitions(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
# بهینه‌سازی: فقط business_id را تنظیم می‌کنیم و business_permissions را lazy load می‌کنیم
# این باعث می‌شود query های اضافی انجام نشود
if ctx.business_id != business_id:
# تنظیم business_id در ctx موجود (بدون ساخت instance جدید)
ctx.business_id = business_id
# Lazy load business_permissions فقط اگر نیاز باشد
ctx.business_permissions = ctx._get_business_permissions()
cache = get_cache()
cache_key = f"dashboard_widgets_definitions:{business_id}:{ctx.get_user_id()}"
if cache.enabled:
cached = cache.get(cache_key)
if cached is not None:
return success_response(cached, request)
data = get_widget_definitions(db=db, business_id=business_id, user_id=ctx.get_user_id(), ctx=ctx)
if cache.enabled:
cache.set(cache_key, data, ttl=300)
return success_response(data, request)
@router.get("/{business_id}/dashboard/layout",
summary="دریافت چیدمان داشبورد (پروفایل رسپانسیو)",
description="چیدمان کاربر برای یک breakpoint مشخص را برمی‌گرداند. در نبود، از پیش‌فرض سیستم استفاده می‌کند.",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def get_dashboard_layout(
request: Request,
business_id: int,
breakpoint: str = "md",
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
cache = get_cache()
cache_key = f"dashboard_layout:{business_id}:{ctx.get_user_id()}:{breakpoint}"
if cache.enabled:
cached = cache.get(cache_key)
if cached is not None:
return success_response(cached, request)
profile = get_dashboard_layout_profile(
db=db,
business_id=business_id,
user_id=ctx.get_user_id(),
breakpoint=breakpoint,
)
if cache.enabled:
cache.set(cache_key, profile, ttl=300)
return success_response(profile, request)
@router.put("/{business_id}/dashboard/layout",
summary="ذخیره چیدمان داشبورد (پروفایل رسپانسیو)",
description="چیدمان کاربر برای breakpoint مشخص را ذخیره می‌کند.",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def put_dashboard_layout(
request: Request,
business_id: int,
payload: dict,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
breakpoint = str(payload.get("breakpoint") or "md")
items = payload.get("items") or []
result = save_dashboard_layout_profile(
db=db,
business_id=business_id,
user_id=ctx.get_user_id(),
breakpoint=breakpoint,
items=items,
)
# Invalidate cache برای layout همین کاربر/کسب‌وکار
cache = get_cache()
if cache.enabled:
cache_key = f"dashboard_layout:{business_id}:{ctx.get_user_id()}:{breakpoint}"
cache.delete(cache_key)
return success_response(result, request)
@router.get(
"/{business_id}/dashboard/quick-links/presets",
summary="کاتالوگ presetهای دسترسی سریع",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def get_quick_link_presets(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
if ctx.business_id != business_id:
ctx.business_id = business_id
ctx.business_permissions = ctx._get_business_permissions()
return success_response(get_quick_link_presets_catalog(ctx), request)
@router.get(
"/{business_id}/dashboard/quick-links",
summary="ذخیره‌شده کاشی‌های دسترسی سریع کاربر برای این کسب‌وکار",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def get_quick_links_saved(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
data = get_or_create_quick_links(db, business_id, ctx.get_user_id())
db.commit()
return success_response(data, request)
@router.put(
"/{business_id}/dashboard/quick-links",
summary="ذخیره کاشی‌های دسترسی سریع",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def put_quick_links_saved(
request: Request,
business_id: int,
payload: dict,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
if ctx.business_id != business_id:
ctx.business_id = business_id
ctx.business_permissions = ctx._get_business_permissions()
items = payload.get("items") or []
data = save_quick_links(
db, business_id=business_id, user_id=ctx.get_user_id(), items_in=items, ctx=ctx
)
db.commit()
return success_response(data, request)
@router.get(
"/{business_id}/menu-preferences",
summary="شخصی‌سازی منوی کاربر برای این کسب‌وکار",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def get_menu_preferences(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
data = get_or_create_menu_preferences(db, business_id, ctx.get_user_id())
db.commit()
return success_response(data, request)
@router.put(
"/{business_id}/menu-preferences",
summary="ذخیره شخصی‌سازی منوی کاربر",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def put_menu_preferences(
request: Request,
business_id: int,
payload: dict,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
data = save_menu_preferences(
db,
business_id=business_id,
user_id=ctx.get_user_id(),
payload=payload if isinstance(payload, dict) else {},
)
db.commit()
return success_response(data, request)
@router.post("/{business_id}/dashboard/data",
summary="دریافت داده‌ی ویجت‌ها (Batch)",
description="کلیدهای ویجت را می‌گیرد و داده‌ی هر ویجت را در یک پاسخ برمی‌گرداند. برای query های طولانی از background job استفاده می‌کند.",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def post_dashboard_widgets_data(
request: Request,
business_id: int,
payload: dict,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
"""
دریافت داده‌های dashboard widgets
به صورت خودکار تصمیم می‌گیرد که از background job استفاده کند یا نه
"""
import json
import hashlib
cache = get_cache()
widget_keys = payload.get("widget_keys") or []
filters = dict(payload.get("filters") or {})
# تزریق سال مالی از هدر درخواست برای ویجت‌هایی که به آن نیاز دارند
fiscal_year_id_header = request.headers.get("X-Fiscal-Year-ID")
if fiscal_year_id_header and filters.get("fiscal_year_id") is None:
try:
filters["fiscal_year_id"] = int(fiscal_year_id_header)
except (ValueError, TypeError):
pass
calendar_type = ctx.calendar_type if hasattr(ctx, 'calendar_type') else "gregorian"
use_queue = bool(payload.get("use_queue", False))
# کش همیشه قبل از صف — warm load نباید منتظر job بماند
widgets_part = ",".join(sorted(str(k) for k in widget_keys))
filters_json = json.dumps(filters, sort_keys=True, ensure_ascii=False)
filters_hash = hashlib.sha256(filters_json.encode("utf-8")).hexdigest()[:16]
cache_key = (
f"dashboard_data:{business_id}:{ctx.get_user_id()}:"
f"{calendar_type}:{widgets_part}:{filters_hash}"
)
if cache.enabled:
cached = cache.get(cache_key)
if cached is not None:
return success_response(cached, request)
# فقط ویجت‌های واقعاً سنگین (یا درخواست صریح) وارد صف می‌شوند.
# آستانهٔ قدیمی «>5 ویجت» باعث می‌شد تقریباً هر لود سرد queue شود.
heavy_widgets = {"top_selling_products", "sales_bar_chart"}
has_heavy_widgets = any(key in heavy_widgets for key in widget_keys)
use_background = use_queue or has_heavy_widgets
if use_background:
from app.core.queue import get_queue_service, QUEUE_DEFAULT
from app.services.jobs.dashboard_job import process_dashboard_widgets_job
queue_service = get_queue_service()
if queue_service and queue_service.enabled:
job = queue_service.enqueue(
process_dashboard_widgets_job,
business_id=business_id,
user_id=ctx.get_user_id(),
widget_keys=[str(k) for k in widget_keys],
filters=filters,
calendar_type=calendar_type,
cache_key=cache_key,
queue_name=QUEUE_DEFAULT,
timeout=300,
result_ttl=3600,
)
if job:
return success_response({
"job_id": job.id,
"status": "queued",
"message": "Dashboard widgets are being processed in background. Use GET /api/v1/jobs/{job_id} to check status."
}, request)
# اگر queue در دسترس نبود، به صورت sync اجرا کن (fallback)
data = get_widgets_batch_data(
db=db,
business_id=business_id,
user_id=ctx.get_user_id(),
widget_keys=[str(k) for k in widget_keys],
filters=filters,
calendar_type=calendar_type,
auth_ctx=ctx,
)
formatted = format_datetime_fields(data, request)
if cache.enabled:
cache.set(cache_key, formatted, ttl=30)
return success_response(formatted, request)
@router.get("/{business_id}/dashboard/layout/default",
summary="پیش‌فرض چیدمان کسب‌وکار (GET)",
description="چیدمان پیش‌فرض منتشر شده توسط مالک کسب‌وکار را برمی‌گرداند (در صورت وجود).",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def get_business_default_dashboard_layout(
request: Request,
business_id: int,
breakpoint: str = "md",
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
profile = get_business_default_layout(db=db, business_id=business_id, breakpoint=breakpoint)
return success_response(profile or {}, request)
@router.put("/{business_id}/dashboard/layout/default",
summary="انتشار چیدمان پیش‌فرض کسب‌وکار (PUT)",
description="مالک کسب‌وکار می‌تواند چیدمان پیش‌فرض را برای breakpoint مشخص منتشر کند.",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def put_business_default_dashboard_layout(
request: Request,
business_id: int,
payload: dict,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
# فقط مالک کسب‌وکار
if not ctx.is_business_owner(business_id):
raise HTTPException(status_code=403, detail="Only business owner can publish default layout")
breakpoint = str(payload.get("breakpoint") or "md")
items = payload.get("items") or []
result = save_business_default_layout(db=db, business_id=business_id, breakpoint=breakpoint, items=items)
return success_response(result, request)