progress in products

This commit is contained in:
Hesabix 2025-10-02 03:21:43 +03:30
parent 6b908eea5d
commit 1363270445
218 changed files with 24064 additions and 361 deletions

View file

@ -146,3 +146,39 @@ def delete_category(
return success_response({"deleted": ok}, request) return success_response({"deleted": ok}, request)
# Server-side search categories with breadcrumb path
@router.post("/business/{business_id}/search")
@require_business_access("business_id")
def search_categories(
request: Request,
business_id: int,
body: Dict[str, Any] | None = None,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("categories"):
raise ApiError("FORBIDDEN", "Missing business permission: categories.view", http_status=403)
q = (body or {}).get("query") if isinstance(body, dict) else None
limit = (body or {}).get("limit") if isinstance(body, dict) else None
if not isinstance(q, str) or not q.strip():
return success_response({"items": []}, request)
try:
limit_int = int(limit) if isinstance(limit, int) or (isinstance(limit, str) and str(limit).isdigit()) else 50
limit_int = max(1, min(limit_int, 200))
except Exception:
limit_int = 50
repo = CategoryRepository(db)
items = repo.search_with_paths(business_id=business_id, query=q.strip(), limit=limit_int)
# map label consistently
mapped = [
{
"id": it.get("id"),
"parent_id": it.get("parent_id"),
"label": it.get("title") or "",
"translations": it.get("translations") or {},
"path": it.get("path") or [],
}
for it in items
]
return success_response({"items": mapped}, request)

View file

@ -1,9 +1,13 @@
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, joinedload
from adapters.db.session import get_db from adapters.db.session import get_db
from adapters.db.models.currency import Currency from adapters.db.models.currency import Currency
from app.core.responses import success_response from app.core.responses import success_response
from app.core.responses import ApiError
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from adapters.db.models.business import Business
router = APIRouter(prefix="/currencies", tags=["currencies"]) router = APIRouter(prefix="/currencies", tags=["currencies"])
@ -28,3 +32,60 @@ def list_currencies(request: Request, db: Session = Depends(get_db)) -> dict:
return success_response(items, request) return success_response(items, request)
@router.get(
"/business/{business_id}",
summary="فهرست ارزهای کسب‌وکار",
description="دریافت ارز پیش‌فرض کسب‌وکار به‌علاوه ارزهای فعال آن کسب‌وکار (بدون تکرار)",
)
@require_business_access()
def list_business_currencies(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
business = (
db.query(Business)
.options(
joinedload(Business.default_currency),
joinedload(Business.currencies),
)
.filter(Business.id == business_id)
.first()
)
if not business:
raise ApiError("NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
seen_ids = set()
result = []
# Add default currency first if exists
if business.default_currency:
c = business.default_currency
result.append({
"id": c.id,
"name": c.name,
"title": c.title,
"symbol": c.symbol,
"code": c.code,
"is_default": True,
})
seen_ids.add(c.id)
# Add active business currencies (excluding duplicates)
for c in business.currencies or []:
if c.id in seen_ids:
continue
result.append({
"id": c.id,
"name": c.name,
"title": c.title,
"symbol": c.symbol,
"code": c.code,
"is_default": False,
})
seen_ids.add(c.id)
# If nothing found, return empty list
return success_response(result, request)

View file

@ -12,6 +12,7 @@ from adapters.api.v1.schemas import QueryInfo, SuccessResponse
from app.core.responses import success_response, format_datetime_fields from app.core.responses import success_response, format_datetime_fields
from app.core.auth_dependency import get_current_user, AuthContext from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_management_dep from app.core.permissions import require_business_management_dep
from app.core.i18n import negotiate_locale
from app.services.person_service import ( from app.services.person_service import (
create_person, get_person_by_id, get_persons_by_business, create_person, get_person_by_id, get_persons_by_business,
update_person, delete_person, get_person_summary update_person, delete_person, get_person_summary
@ -207,6 +208,14 @@ async def export_persons_excel(
ws = wb.active ws = wb.active
ws.title = "Persons" ws.title = "Persons"
# Locale and RTL/LTR handling
locale = negotiate_locale(request.headers.get("Accept-Language"))
if locale == 'fa':
try:
ws.sheet_view.rightToLeft = True
except Exception:
pass
header_font = Font(bold=True, color="FFFFFF") header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center") header_alignment = Alignment(horizontal="center", vertical="center")
@ -226,7 +235,10 @@ async def export_persons_excel(
value = item.get(key, "") value = item.get(key, "")
if isinstance(value, list): if isinstance(value, list):
value = ", ".join(str(v) for v in value) value = ", ".join(str(v) for v in value)
ws.cell(row=row_idx, column=col_idx, value=value).border = border cell = ws.cell(row=row_idx, column=col_idx, value=value)
cell.border = border
if locale == 'fa':
cell.alignment = Alignment(horizontal="right")
# Auto-width columns # Auto-width columns
for column in ws.columns: for column in ws.columns:
@ -344,7 +356,12 @@ async def export_persons_pdf(
except Exception: except Exception:
business_name = "" business_name = ""
# Styled HTML (A4 landscape, RTL) # Styled HTML with dynamic direction/locale
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = (locale == 'fa')
html_lang = 'fa' if is_fa else 'en'
html_dir = 'rtl' if is_fa else 'ltr'
def escape(s: Any) -> str: def escape(s: Any) -> str:
try: try:
return str(s).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;') return str(s).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
@ -373,16 +390,24 @@ async def export_persons_pdf(
now = formatted_now.get('formatted', formatted_now.get('date_time', '')) now = formatted_now.get('formatted', formatted_now.get('date_time', ''))
except Exception: except Exception:
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M') now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
title_text = "گزارش لیست اشخاص" if is_fa else "Persons List Report"
label_biz = "نام کسب‌وکار" if is_fa else "Business Name"
label_date = "تاریخ گزارش" if is_fa else "Report Date"
footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix"
page_label_left = "صفحه " if is_fa else "Page "
page_label_of = " از " if is_fa else " of "
table_html = f""" table_html = f"""
<html lang=\"fa\" dir=\"rtl\"> <html lang=\"{html_lang}\" dir=\"{html_dir}\">
<head> <head>
<meta charset='utf-8'> <meta charset='utf-8'>
<style> <style>
@page {{ @page {{
size: A4 landscape; size: A4 landscape;
margin: 12mm; margin: 12mm;
@bottom-right {{ @bottom-{ 'left' if is_fa else 'right' } {{
content: "صفحه " counter(page) " از " counter(pages); content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages);
font-size: 10px; font-size: 10px;
color: #666; color: #666;
}} }}
@ -437,17 +462,17 @@ async def export_persons_pdf(
font-size: 10px; font-size: 10px;
color: #666; color: #666;
margin-top: 8px; margin-top: 8px;
text-align: left; text-align: {'left' if is_fa else 'right'};
}} }}
</style> </style>
</head> </head>
<body> <body>
<div class=\"header\"> <div class=\"header\">
<div> <div>
<div class=\"title\">گزارش لیست اشخاص</div> <div class=\"title\">{title_text}</div>
<div class=\"meta\">نام کسب‌وکار: {escape(business_name)}</div> <div class=\"meta\">{label_biz}: {escape(business_name)}</div>
</div> </div>
<div class=\"meta\">تاریخ گزارش: {escape(now)}</div> <div class=\"meta\">{label_date}: {escape(now)}</div>
</div> </div>
<div class=\"table-wrapper\"> <div class=\"table-wrapper\">
<table class=\"report-table\"> <table class=\"report-table\">
@ -459,7 +484,7 @@ async def export_persons_pdf(
</tbody> </tbody>
</table> </table>
</div> </div>
<div class=\"footer\">تولید شده توسط Hesabix</div> <div class=\"footer\">{footer_text}</div>
</body> </body>
</html> </html>
""" """

View file

@ -137,12 +137,14 @@ def list_price_items_endpoint(
request: Request, request: Request,
business_id: int, business_id: int,
price_list_id: int, price_list_id: int,
product_id: int | None = None,
currency_id: int | None = None,
ctx: AuthContext = Depends(get_current_user), ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> Dict[str, Any]: ) -> Dict[str, Any]:
if not ctx.can_read_section("inventory"): if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
result = list_price_items(db, business_id, price_list_id) result = list_price_items(db, business_id, price_list_id, product_id=product_id, currency_id=currency_id)
return success_response(data=format_datetime_fields(result, request), request=request) return success_response(data=format_datetime_fields(result, request), request=request)

View file

@ -12,6 +12,8 @@ from adapters.api.v1.schemas import QueryInfo
from adapters.api.v1.schema_models.product import ( from adapters.api.v1.schema_models.product import (
ProductCreateRequest, ProductCreateRequest,
ProductUpdateRequest, ProductUpdateRequest,
BulkPriceUpdateRequest,
BulkPriceUpdatePreviewResponse,
) )
from app.services.product_service import ( from app.services.product_service import (
create_product, create_product,
@ -20,6 +22,13 @@ from app.services.product_service import (
update_product, update_product,
delete_product, delete_product,
) )
from app.services.bulk_price_update_service import (
preview_bulk_price_update,
apply_bulk_price_update,
)
from adapters.db.models.business import Business
from app.core.i18n import negotiate_locale
from fastapi import UploadFile, File, Form
router = APIRouter(prefix="/products", tags=["products"]) router = APIRouter(prefix="/products", tags=["products"])
@ -125,6 +134,8 @@ async def export_products_excel(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
import io import io
import re
import datetime
from fastapi.responses import Response from fastapi.responses import Response
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
@ -145,6 +156,22 @@ async def export_products_excel(
items = result.get("items", []) if isinstance(result, dict) else result.get("items", []) items = result.get("items", []) if isinstance(result, dict) else result.get("items", [])
items = [format_datetime_fields(item, request) for item in items] items = [format_datetime_fields(item, request) for item in items]
# Apply selected indices filter if requested
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None and isinstance(items, list):
indices = None
if isinstance(selected_indices, str):
try:
import json as _json
indices = _json.loads(selected_indices)
except Exception:
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
export_columns = body.get("export_columns") export_columns = body.get("export_columns")
if export_columns and isinstance(export_columns, list): if export_columns and isinstance(export_columns, list):
headers = [col.get("label") or col.get("key") for col in export_columns] headers = [col.get("label") or col.get("key") for col in export_columns]
@ -169,6 +196,14 @@ async def export_products_excel(
ws = wb.active ws = wb.active
ws.title = "Products" ws.title = "Products"
# Locale and RTL/LTR handling for Excel
locale = negotiate_locale(request.headers.get("Accept-Language"))
if locale == 'fa':
try:
ws.sheet_view.rightToLeft = True
except Exception:
pass
# Header style # Header style
header_font = Font(bold=True) header_font = Font(bold=True)
header_fill = PatternFill(start_color="DDDDDD", end_color="DDDDDD", fill_type="solid") header_fill = PatternFill(start_color="DDDDDD", end_color="DDDDDD", fill_type="solid")
@ -188,21 +223,336 @@ async def export_products_excel(
ws.append(row) ws.append(row)
for cell in ws[ws.max_row]: for cell in ws[ws.max_row]:
cell.border = thin_border cell.border = thin_border
# Align data cells based on locale
if locale == 'fa':
cell.alignment = Alignment(horizontal="right")
# Auto width columns
try:
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if cell.value is not None and len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
except Exception:
pass
output = io.BytesIO() output = io.BytesIO()
wb.save(output) wb.save(output)
data = output.getvalue() data = output.getvalue()
# Build meaningful filename
biz_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
biz_name = b.name or ""
except Exception:
biz_name = ""
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "products"
if biz_name:
base += f"_{slugify(biz_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return Response( return Response(
content=data, content=data,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={ headers={
"Content-Disposition": "attachment; filename=products.xlsx", "Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(data)), "Content-Length": str(len(data)),
"Access-Control-Expose-Headers": "Content-Disposition",
}, },
) )
@router.post("/business/{business_id}/import/template",
summary="دانلود تمپلیت ایمپورت محصولات",
description="فایل Excel تمپلیت برای ایمپورت کالا/خدمت را برمی‌گرداند",
)
@require_business_access("business_id")
async def download_products_import_template(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
import io
import datetime
from fastapi.responses import Response
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment
if not ctx.has_business_permission("inventory", "write"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
wb = Workbook()
ws = wb.active
ws.title = "Template"
headers = [
"code","name","item_type","description","category_id",
"main_unit_id","secondary_unit_id","unit_conversion_factor",
"base_sales_price","base_purchase_price","track_inventory",
"reorder_point","min_order_qty","lead_time_days",
"is_sales_taxable","is_purchase_taxable","sales_tax_rate","purchase_tax_rate",
"tax_type_id","tax_code","tax_unit_id",
# attribute_ids can be comma-separated ids
"attribute_ids",
]
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal="center")
sample = [
"P1001","نمونه کالا","کالا","توضیح اختیاری", "",
"", "", "",
"150000", "120000", "TRUE",
"0", "0", "",
"FALSE", "FALSE", "", "",
"", "", "",
"1,2,3",
]
for col, val in enumerate(sample, 1):
ws.cell(row=2, column=col, value=val)
# Auto width
for column in ws.columns:
try:
letter = column[0].column_letter
max_len = max(len(str(c.value)) if c.value is not None else 0 for c in column)
ws.column_dimensions[letter].width = min(max_len + 2, 50)
except Exception:
pass
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
filename = f"products_import_template_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return Response(
content=buf.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post("/business/{business_id}/import/excel",
summary="ایمپورت محصولات از فایل Excel",
description="فایل اکسل را دریافت می‌کند و به‌صورت dry-run یا واقعی پردازش می‌کند",
)
@require_business_access("business_id")
async def import_products_excel(
request: Request,
business_id: int,
file: UploadFile = File(...),
dry_run: str = Form(default="true"),
match_by: str = Form(default="code"),
conflict_policy: str = Form(default="upsert"),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
import io
import json
import logging
import re
import zipfile
from decimal import Decimal
from typing import Optional
from openpyxl import load_workbook
if not ctx.has_business_permission("inventory", "write"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
logger = logging.getLogger(__name__)
def _validate_excel_signature(content: bytes) -> bool:
try:
if not content.startswith(b'PK'):
return False
with zipfile.ZipFile(io.BytesIO(content), 'r') as zf:
return any(n.startswith('xl/') for n in zf.namelist())
except Exception:
return False
try:
is_dry_run = str(dry_run).lower() in ("true","1","yes","on")
if not file.filename or not file.filename.lower().endswith('.xlsx'):
raise ApiError("INVALID_FILE", "فرمت فایل معتبر نیست. تنها xlsx پشتیبانی می‌شود", http_status=400)
content = await file.read()
if len(content) < 100 or not _validate_excel_signature(content):
raise ApiError("INVALID_FILE", "فایل Excel معتبر نیست یا خالی است", http_status=400)
try:
wb = load_workbook(filename=io.BytesIO(content), data_only=True)
except zipfile.BadZipFile:
raise ApiError("INVALID_FILE", "فایل Excel خراب است یا فرمت آن معتبر نیست", http_status=400)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
return success_response(data={"summary": {"total": 0}}, request=request, message="EMPTY_FILE")
headers = [str(h).strip() if h is not None else "" for h in rows[0]]
data_rows = rows[1:]
def _parse_bool(v: object) -> Optional[bool]:
if v is None: return None
s = str(v).strip().lower()
if s in ("true","1","yes","on","بله","هست"):
return True
if s in ("false","0","no","off","خیر","نیست"):
return False
return None
def _parse_decimal(v: object) -> Optional[Decimal]:
if v is None or str(v).strip() == "":
return None
try:
return Decimal(str(v).replace(",",""))
except Exception:
return None
def _parse_int(v: object) -> Optional[int]:
if v is None or str(v).strip() == "":
return None
try:
return int(str(v).split(".")[0])
except Exception:
return None
def _normalize_item_type(v: object) -> Optional[str]:
if v is None: return None
s = str(v).strip()
mapping = {"product": "کالا", "service": "خدمت"}
low = s.lower()
if low in mapping: return mapping[low]
if s in ("کالا","خدمت"): return s
return None
errors: list[dict] = []
valid_items: list[dict] = []
for idx, row in enumerate(data_rows, start=2):
item: dict[str, Any] = {}
row_errors: list[str] = []
for ci, key in enumerate(headers):
if not key:
continue
val = row[ci] if ci < len(row) else None
if isinstance(val, str):
val = val.strip()
item[key] = val
# normalize & cast
if 'item_type' in item:
item['item_type'] = _normalize_item_type(item.get('item_type')) or 'کالا'
for k in ['base_sales_price','base_purchase_price','sales_tax_rate','purchase_tax_rate','unit_conversion_factor']:
if k in item:
item[k] = _parse_decimal(item.get(k))
for k in ['reorder_point','min_order_qty','lead_time_days','category_id','main_unit_id','secondary_unit_id','tax_type_id','tax_unit_id']:
if k in item:
item[k] = _parse_int(item.get(k))
for k in ['track_inventory','is_sales_taxable','is_purchase_taxable']:
if k in item:
item[k] = _parse_bool(item.get(k)) if item.get(k) is not None else None
# attribute_ids: comma-separated
if 'attribute_ids' in item and item['attribute_ids']:
try:
parts = [p.strip() for p in str(item['attribute_ids']).split(',') if p and p.strip()]
item['attribute_ids'] = [int(p) for p in parts if p.isdigit()]
except Exception:
item['attribute_ids'] = []
# validations
name = item.get('name')
if not name or str(name).strip() == "":
row_errors.append('name الزامی است')
# if code is empty, it will be auto-generated in service
code = item.get('code')
if code is not None and str(code).strip() == "":
item['code'] = None
if row_errors:
errors.append({"row": idx, "errors": row_errors})
continue
valid_items.append(item)
inserted = 0
updated = 0
skipped = 0
if not is_dry_run and valid_items:
from sqlalchemy import and_ as _and
from adapters.db.models.product import Product
from adapters.api.v1.schema_models.product import ProductCreateRequest, ProductUpdateRequest
from app.services.product_service import create_product, update_product
def _find_existing(session: Session, data: dict) -> Optional[Product]:
if match_by == 'code' and data.get('code'):
return session.query(Product).filter(_and(Product.business_id == business_id, Product.code == str(data['code']).strip())).first()
if match_by == 'name' and data.get('name'):
return session.query(Product).filter(_and(Product.business_id == business_id, Product.name == str(data['name']).strip())).first()
return None
for data in valid_items:
existing = _find_existing(db, data)
if existing is None:
try:
create_product(db, business_id, ProductCreateRequest(**data))
inserted += 1
except Exception as e:
logger.error(f"Create product failed: {e}")
skipped += 1
else:
if conflict_policy == 'insert':
skipped += 1
elif conflict_policy in ('update','upsert'):
try:
update_product(db, existing.id, business_id, ProductUpdateRequest(**data))
updated += 1
except Exception as e:
logger.error(f"Update product failed: {e}")
skipped += 1
summary = {
"total": len(data_rows),
"valid": len(valid_items),
"invalid": len(errors),
"inserted": inserted,
"updated": updated,
"skipped": skipped,
"dry_run": is_dry_run,
}
return success_response(
data={"summary": summary, "errors": errors},
request=request,
message="PRODUCTS_IMPORT_RESULT",
)
except ApiError:
raise
except Exception as e:
logger.error(f"Import error: {e}", exc_info=True)
raise ApiError("IMPORT_ERROR", f"خطا در پردازش فایل: {e}", http_status=500)
@router.post("/business/{business_id}/export/pdf", @router.post("/business/{business_id}/export/pdf",
summary="خروجی PDF لیست محصولات", summary="خروجی PDF لیست محصولات",
description="خروجی PDF لیست محصولات با قابلیت فیلتر و انتخاب ستون‌ها", description="خروجی PDF لیست محصولات با قابلیت فیلتر و انتخاب ستون‌ها",
@ -215,8 +565,9 @@ async def export_products_pdf(
ctx: AuthContext = Depends(get_current_user), ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
import io import json
import datetime import datetime
import re
from fastapi.responses import Response from fastapi.responses import Response
from weasyprint import HTML, CSS from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration from weasyprint.text.fonts import FontConfiguration
@ -237,6 +588,21 @@ async def export_products_pdf(
items = result.get("items", []) items = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items] items = [format_datetime_fields(item, request) for item in items]
# Apply selected indices filter if requested
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
export_columns = body.get("export_columns") export_columns = body.get("export_columns")
if export_columns and isinstance(export_columns, list): if export_columns and isinstance(export_columns, list):
headers = [col.get("label") or col.get("key") for col in export_columns] headers = [col.get("label") or col.get("key") for col in export_columns]
@ -257,44 +623,211 @@ async def export_products_pdf(
keys = [k for k, _ in default_cols] keys = [k for k, _ in default_cols]
headers = [v for _, v in default_cols] headers = [v for _, v in default_cols]
# Build simple HTML table # Locale and direction
head_html = """ locale = negotiate_locale(request.headers.get("Accept-Language"))
<style> is_fa = (locale == 'fa')
table { width: 100%; border-collapse: collapse; } html_lang = 'fa' if is_fa else 'en'
th, td { border: 1px solid #777; padding: 6px; font-size: 12px; } html_dir = 'rtl' if is_fa else 'ltr'
th { background: #eee; }
h1 { font-size: 16px; } # Load business info for header
.meta { font-size: 12px; color: #666; margin-bottom: 10px; } business_name = ""
</style> try:
""" biz = db.query(Business).filter(Business.id == business_id).first()
title = "گزارش فهرست محصولات" if biz is not None:
now = datetime.datetime.utcnow().isoformat() business_name = biz.name or ""
header_row = "".join([f"<th>{h}</th>" for h in headers]) except Exception:
body_rows = "".join([ business_name = ""
"<tr>" + "".join([f"<td>{(it.get(k) if it.get(k) is not None else '')}</td>" for k in keys]) + "</tr>"
for it in items # Escape helper
]) def escape(s: Any) -> str:
html = f""" try:
<html><head>{head_html}</head><body> return str(s).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
<h1>{title}</h1> except Exception:
<div class=meta>زمان تولید: {now}</div> return str(s)
<table>
<thead><tr>{header_row}</tr></thead> # Build rows
<tbody>{body_rows}</tbody> rows_html = []
</table> for item in items:
</body></html> tds = []
for key in keys:
value = item.get(key)
if value is None:
value = ""
elif isinstance(value, list):
value = ", ".join(str(v) for v in value)
tds.append(f"<td>{escape(value)}</td>")
rows_html.append(f"<tr>{''.join(tds)}</tr>")
headers_html = ''.join(f"<th>{escape(h)}</th>" for h in headers)
# Format report datetime based on X-Calendar-Type header
calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
try:
from app.core.calendar import CalendarConverter
formatted_now = CalendarConverter.format_datetime(datetime.datetime.now(),
"jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian")
now_str = formatted_now.get('formatted', formatted_now.get('date_time', ''))
except Exception:
now_str = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
title_text = "گزارش فهرست محصولات" if is_fa else "Products List Report"
label_biz = "نام کسب‌وکار" if is_fa else "Business Name"
label_date = "تاریخ گزارش" if is_fa else "Report Date"
footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix"
page_label_left = "صفحه " if is_fa else "Page "
page_label_of = " از " if is_fa else " of "
table_html = f"""
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
<head>
<meta charset='utf-8'>
<style>
@page {{
size: A4 landscape;
margin: 12mm;
@bottom-{ 'left' if is_fa else 'right' } {{
content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages);
font-size: 10px;
color: #666;
}}
}}
body {{
font-family: sans-serif;
font-size: 11px;
color: #222;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 2px solid #444;
padding-bottom: 6px;
}}
.title {{
font-size: 16px;
font-weight: 700;
}}
.meta {{
font-size: 11px;
color: #555;
}}
.table-wrapper {{
width: 100%;
}}
table.report-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}}
thead th {{
background: #f0f3f7;
border: 1px solid #c7cdd6;
padding: 6px 4px;
text-align: center;
font-weight: 700;
white-space: nowrap;
}}
tbody td {{
border: 1px solid #d7dde6;
padding: 5px 4px;
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}}
.footer {{
position: running(footer);
font-size: 10px;
color: #666;
margin-top: 8px;
text-align: {'left' if is_fa else 'right'};
}}
</style>
</head>
<body>
<div class=\"header\">
<div>
<div class=\"title\">{title_text}</div>
<div class=\"meta\">{label_biz}: {escape(business_name)}</div>
</div>
<div class=\"meta\">{label_date}: {escape(now_str)}</div>
</div>
<div class=\"table-wrapper\">
<table class=\"report-table\">
<thead>
<tr>{headers_html}</tr>
</thead>
<tbody>
{''.join(rows_html)}
</tbody>
</table>
</div>
<div class=\"footer\">{footer_text}</div>
</body>
</html>
""" """
font_config = FontConfiguration() font_config = FontConfiguration()
pdf_bytes = HTML(string=html).write_pdf(stylesheets=[CSS(string="@page { size: A4 landscape; margin: 10mm; }")], font_config=font_config) pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
# Build meaningful filename
biz_name = business_name
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "products"
if biz_name:
base += f"_{slugify(biz_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response( return Response(
content=pdf_bytes, content=pdf_bytes,
media_type="application/pdf", media_type="application/pdf",
headers={ headers={
"Content-Disposition": "attachment; filename=products.pdf", "Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes)), "Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
}, },
) )
@router.post("/business/{business_id}/bulk-price-update/preview",
summary="پیش‌نمایش تغییر قیمت‌های گروهی",
description="پیش‌نمایش تغییرات قیمت قبل از اعمال",
)
@require_business_access("business_id")
def preview_bulk_price_update_endpoint(
request: Request,
business_id: int,
payload: BulkPriceUpdateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "write"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
result = preview_bulk_price_update(db, business_id, payload)
return success_response(data=result.dict(), request=request)
@router.post("/business/{business_id}/bulk-price-update/apply",
summary="اعمال تغییر قیمت‌های گروهی",
description="اعمال تغییرات قیمت بر روی کالاهای انتخاب شده",
)
@require_business_access("business_id")
def apply_bulk_price_update_endpoint(
request: Request,
business_id: int,
payload: BulkPriceUpdateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "write"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
result = apply_bulk_price_update(db, business_id, payload)
return success_response(data=result, request=request)

View file

@ -7,23 +7,19 @@ from pydantic import BaseModel, Field
class PriceListCreateRequest(BaseModel): class PriceListCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
currency_id: Optional[int] = None
default_unit_id: Optional[int] = None
is_active: bool = True is_active: bool = True
class PriceListUpdateRequest(BaseModel): class PriceListUpdateRequest(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=255) name: Optional[str] = Field(default=None, min_length=1, max_length=255)
currency_id: Optional[int] = None
default_unit_id: Optional[int] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
class PriceItemUpsertRequest(BaseModel): class PriceItemUpsertRequest(BaseModel):
product_id: int product_id: int
unit_id: Optional[int] = None unit_id: Optional[int] = None
currency_id: Optional[int] = None currency_id: int
tier_name: str = Field(..., min_length=1, max_length=64) tier_name: Optional[str] = Field(default=None, min_length=1, max_length=64)
min_qty: Decimal = Field(default=0) min_qty: Decimal = Field(default=0)
price: Decimal price: Decimal
@ -32,8 +28,6 @@ class PriceListResponse(BaseModel):
id: int id: int
business_id: int business_id: int
name: str name: str
currency_id: Optional[int] = None
default_unit_id: Optional[int] = None
is_active: bool is_active: bool
created_at: str created_at: str
updated_at: str updated_at: str
@ -47,7 +41,7 @@ class PriceItemResponse(BaseModel):
price_list_id: int price_list_id: int
product_id: int product_id: int
unit_id: Optional[int] = None unit_id: Optional[int] = None
currency_id: Optional[int] = None currency_id: int
tier_name: str tier_name: str
min_qty: Decimal min_qty: Decimal
price: Decimal price: Decimal

View file

@ -108,3 +108,59 @@ class ProductResponse(BaseModel):
from_attributes = True from_attributes = True
class BulkPriceUpdateType(str, Enum):
PERCENTAGE = "percentage"
AMOUNT = "amount"
class BulkPriceUpdateDirection(str, Enum):
INCREASE = "increase"
DECREASE = "decrease"
class BulkPriceUpdateTarget(str, Enum):
SALES_PRICE = "sales_price"
PURCHASE_PRICE = "purchase_price"
BOTH = "both"
class BulkPriceUpdateRequest(BaseModel):
"""درخواست تغییر قیمت‌های گروهی"""
update_type: BulkPriceUpdateType = Field(..., description="نوع تغییر: درصدی یا مقداری")
direction: BulkPriceUpdateDirection = Field(default=BulkPriceUpdateDirection.INCREASE, description="جهت تغییر: افزایش یا کاهش")
target: BulkPriceUpdateTarget = Field(..., description="هدف تغییر: قیمت فروش، خرید یا هر دو")
value: Decimal = Field(..., description="مقدار تغییر (درصد یا مبلغ)")
# فیلترهای انتخاب کالاها
category_ids: Optional[List[int]] = Field(default=None, description="شناسه‌های دسته‌بندی")
currency_ids: Optional[List[int]] = Field(default=None, description="شناسه‌های ارز")
price_list_ids: Optional[List[int]] = Field(default=None, description="شناسه‌های لیست قیمت")
item_types: Optional[List[ProductItemType]] = Field(default=None, description="نوع آیتم‌ها")
product_ids: Optional[List[int]] = Field(default=None, description="شناسه‌های کالاهای خاص")
# گزینه‌های اضافی
only_products_with_inventory: Optional[bool] = Field(default=None, description="فقط کالاهای با موجودی")
only_products_with_base_price: Optional[bool] = Field(default=True, description="فقط کالاهای با قیمت پایه")
class BulkPriceUpdatePreview(BaseModel):
"""پیش‌نمایش تغییرات قیمت"""
product_id: int
product_name: str
product_code: str
category_name: Optional[str] = None
current_sales_price: Optional[Decimal] = None
current_purchase_price: Optional[Decimal] = None
new_sales_price: Optional[Decimal] = None
new_purchase_price: Optional[Decimal] = None
sales_price_change: Optional[Decimal] = None
purchase_price_change: Optional[Decimal] = None
class BulkPriceUpdatePreviewResponse(BaseModel):
"""پاسخ پیش‌نمایش تغییرات قیمت"""
total_products: int
affected_products: List[BulkPriceUpdatePreview]
summary: dict = Field(..., description="خلاصه تغییرات")

View file

@ -13,6 +13,7 @@ from pydantic import BaseModel, Field
router = APIRouter(prefix="/tax-units", tags=["tax-units"]) router = APIRouter(prefix="/tax-units", tags=["tax-units"])
alias_router = APIRouter(prefix="/units", tags=["units"])
class TaxUnitCreateRequest(BaseModel): class TaxUnitCreateRequest(BaseModel):
@ -86,6 +87,7 @@ class TaxUnitResponse(BaseModel):
} }
} }
) )
@alias_router.get("/business/{business_id}")
@require_business_access() @require_business_access()
def get_tax_units( def get_tax_units(
request: Request, request: Request,
@ -160,6 +162,7 @@ def get_tax_units(
} }
} }
) )
@alias_router.post("/business/{business_id}")
@require_business_access() @require_business_access()
def create_tax_unit( def create_tax_unit(
request: Request, request: Request,
@ -255,6 +258,7 @@ def create_tax_unit(
} }
} }
) )
@alias_router.put("/{tax_unit_id}")
@require_business_access() @require_business_access()
def update_tax_unit( def update_tax_unit(
request: Request, request: Request,
@ -345,6 +349,7 @@ def update_tax_unit(
} }
} }
) )
@alias_router.delete("/{tax_unit_id}")
@require_business_access() @require_business_access()
def delete_tax_unit( def delete_tax_unit(
request: Request, request: Request,

View file

@ -26,8 +26,6 @@ class PriceList(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True) business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
currency_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=True, index=True)
default_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@ -36,14 +34,14 @@ class PriceList(Base):
class PriceItem(Base): class PriceItem(Base):
__tablename__ = "price_items" __tablename__ = "price_items"
__table_args__ = ( __table_args__ = (
UniqueConstraint("price_list_id", "product_id", "unit_id", "tier_name", "min_qty", name="uq_price_items_unique_tier"), UniqueConstraint("price_list_id", "product_id", "unit_id", "tier_name", "min_qty", "currency_id", name="uq_price_items_unique_tier_currency"),
) )
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
price_list_id: Mapped[int] = mapped_column(Integer, ForeignKey("price_lists.id", ondelete="CASCADE"), nullable=False, index=True) price_list_id: Mapped[int] = mapped_column(Integer, ForeignKey("price_lists.id", ondelete="CASCADE"), nullable=False, index=True)
product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True) product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True)
unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
currency_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=True, index=True) currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
tier_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="نام پله قیمت (تکی/عمده/همکار/...)" ) tier_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="نام پله قیمت (تکی/عمده/همکار/...)" )
min_qty: Mapped[Decimal] = mapped_column(Numeric(18, 3), nullable=False, default=0) min_qty: Mapped[Decimal] = mapped_column(Numeric(18, 3), nullable=False, default=0)
price: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False) price: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)

View file

@ -89,7 +89,32 @@ class BusinessPermissionRepository(BaseRepository[BusinessPermission]):
# سپس فیلتر می‌کنیم # سپس فیلتر می‌کنیم
member_permissions = [] member_permissions = []
for perm in all_permissions: for perm in all_permissions:
if perm.business_permissions and perm.business_permissions.get('join') == True: # Normalize legacy/non-dict JSON values to dict before access
raw = perm.business_permissions
normalized = {}
if isinstance(raw, dict):
normalized = raw
elif isinstance(raw, list):
# If legacy stored as list, try to coerce to dict if it looks like key-value pairs
try:
# e.g., [["join", true], ["sales", {"read": true}]] or [{"join": true}, ...]
if all(isinstance(item, list) and len(item) == 2 for item in raw):
normalized = {k: v for k, v in raw if isinstance(k, str)}
elif all(isinstance(item, dict) for item in raw):
# Merge list of dicts
merged: dict = {}
for item in raw:
merged.update({k: v for k, v in item.items()})
normalized = merged
except Exception:
normalized = {}
elif raw is None:
normalized = {}
else:
# Unsupported type, skip safely
normalized = {}
if normalized.get('join') == True:
member_permissions.append(perm) member_permissions.append(perm)
return member_permissions return member_permissions

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from typing import List, Dict, Any from typing import List, Dict, Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import select, and_, or_ from sqlalchemy import select, and_, or_, func
from .base_repo import BaseRepository from .base_repo import BaseRepository
from ..models.category import BusinessCategory from ..models.category import BusinessCategory
@ -90,3 +90,55 @@ class CategoryRepository(BaseRepository[BusinessCategory]):
return True return True
def search_with_paths(self, *, business_id: int, query: str, limit: int = 50) -> list[Dict[str, Any]]:
q = (query or "").strip()
if not q:
return []
# Basic ILIKE search over fa/en translations by JSON string casting
# Note: For performance, consider a materialized path or FTS in future
stmt = (
select(BusinessCategory)
.where(BusinessCategory.business_id == business_id)
)
rows = list(self.db.execute(stmt).scalars().all())
# Build in-memory tree index
by_id: dict[int, BusinessCategory] = {r.id: r for r in rows}
def get_title(r: BusinessCategory) -> str:
trans = r.title_translations or {}
return (trans.get("fa") or trans.get("en") or "").strip()
# Filter by query
q_lower = q.lower()
matched: list[BusinessCategory] = []
for r in rows:
if q_lower in get_title(r).lower():
matched.append(r)
matched = matched[: max(1, min(limit, 200))]
# Build path for each match
def build_path(r: BusinessCategory) -> list[Dict[str, Any]]:
path: list[Dict[str, Any]] = []
current = r
seen: set[int] = set()
while current is not None and current.id not in seen:
seen.add(current.id)
title = get_title(current)
path.append({
"id": current.id,
"parent_id": current.parent_id,
"title": title,
"translations": current.title_translations or {},
})
pid = current.parent_id
current = by_id.get(pid) if pid else None
path.reverse()
return path
result: list[Dict[str, Any]] = []
for r in matched:
result.append({
"id": r.id,
"parent_id": r.parent_id,
"title": get_title(r),
"translations": r.title_translations or {},
"path": build_path(r),
})
return result

View file

@ -69,8 +69,6 @@ class PriceListRepository(BaseRepository[PriceList]):
"id": pl.id, "id": pl.id,
"business_id": pl.business_id, "business_id": pl.business_id,
"name": pl.name, "name": pl.name,
"currency_id": pl.currency_id,
"default_unit_id": pl.default_unit_id,
"is_active": pl.is_active, "is_active": pl.is_active,
"created_at": pl.created_at, "created_at": pl.created_at,
"updated_at": pl.updated_at, "updated_at": pl.updated_at,
@ -81,8 +79,12 @@ class PriceItemRepository(BaseRepository[PriceItem]):
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
super().__init__(db, PriceItem) super().__init__(db, PriceItem)
def list_for_price_list(self, *, price_list_id: int, take: int = 50, skip: int = 0) -> dict[str, Any]: def list_for_price_list(self, *, price_list_id: int, take: int = 50, skip: int = 0, product_id: int | None = None, currency_id: int | None = None) -> dict[str, Any]:
stmt = select(PriceItem).where(PriceItem.price_list_id == price_list_id) stmt = select(PriceItem).where(PriceItem.price_list_id == price_list_id)
if product_id is not None:
stmt = stmt.where(PriceItem.product_id == product_id)
if currency_id is not None:
stmt = stmt.where(PriceItem.currency_id == currency_id)
total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0 total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
rows = list(self.db.execute(stmt.offset(skip).limit(take)).scalars().all()) rows = list(self.db.execute(stmt.offset(skip).limit(take)).scalars().all())
items = [self._to_dict(pi) for pi in rows] items = [self._to_dict(pi) for pi in rows]
@ -98,22 +100,22 @@ class PriceItemRepository(BaseRepository[PriceItem]):
}, },
} }
def upsert(self, *, price_list_id: int, product_id: int, unit_id: int | None, currency_id: int | None, tier_name: str, min_qty, price) -> PriceItem: def upsert(self, *, price_list_id: int, product_id: int, unit_id: int | None, currency_id: int, tier_name: str | None, min_qty, price) -> PriceItem:
# Try find existing unique combination # Try find existing unique combination
stmt = select(PriceItem).where( stmt = select(PriceItem).where(
and_( and_(
PriceItem.price_list_id == price_list_id, PriceItem.price_list_id == price_list_id,
PriceItem.product_id == product_id, PriceItem.product_id == product_id,
PriceItem.unit_id.is_(unit_id) if unit_id is None else PriceItem.unit_id == unit_id, PriceItem.unit_id.is_(unit_id) if unit_id is None else PriceItem.unit_id == unit_id,
PriceItem.tier_name == tier_name, PriceItem.tier_name == (tier_name or 'پیش‌فرض'),
PriceItem.min_qty == min_qty, PriceItem.min_qty == min_qty,
PriceItem.currency_id == currency_id,
) )
) )
existing = self.db.execute(stmt).scalars().first() existing = self.db.execute(stmt).scalars().first()
if existing: if existing:
existing.price = price existing.price = price
if currency_id is not None: existing.currency_id = currency_id
existing.currency_id = currency_id
self.db.commit() self.db.commit()
self.db.refresh(existing) self.db.refresh(existing)
return existing return existing
@ -122,7 +124,7 @@ class PriceItemRepository(BaseRepository[PriceItem]):
product_id=product_id, product_id=product_id,
unit_id=unit_id, unit_id=unit_id,
currency_id=currency_id, currency_id=currency_id,
tier_name=tier_name, tier_name=(tier_name or 'پیش‌فرض'),
min_qty=min_qty, min_qty=min_qty,
price=price, price=price,
) )

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import select, and_, func from sqlalchemy import select, and_, or_, func
from .base_repo import BaseRepository from .base_repo import BaseRepository
from ..models.product import Product from ..models.product import Product
@ -25,6 +25,38 @@ class ProductRepository(BaseRepository[Product]):
) )
) )
# Apply filters (supports minimal set used by clients)
if filters:
for f in filters:
# Support both dict and pydantic-like objects
if isinstance(f, dict):
field = f.get("property")
operator = f.get("operator")
value = f.get("value")
else:
field = getattr(f, "property", None)
operator = getattr(f, "operator", None)
value = getattr(f, "value", None)
if not field or not operator:
continue
# Code filters
if field == "code":
if operator == "=":
stmt = stmt.where(Product.code == value)
elif operator == "in" and isinstance(value, (list, tuple)):
stmt = stmt.where(Product.code.in_(list(value)))
continue
# Name contains
if field == "name":
if operator in {"contains", "ilike"} and isinstance(value, str):
stmt = stmt.where(Product.name.ilike(f"%{value}%"))
elif operator == "=":
stmt = stmt.where(Product.name == value)
continue
total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0 total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
# Sorting # Sorting

View file

@ -45,6 +45,26 @@ class AuthContext:
# ایجاد translator برای زبان تشخیص داده شده # ایجاد translator برای زبان تشخیص داده شده
self._translator = Translator(language) self._translator = Translator(language)
@staticmethod
def _normalize_permissions_value(value) -> dict:
"""نرمال‌سازی مقدار JSON دسترسی‌ها به dict برای سازگاری با داده‌های legacy"""
if isinstance(value, dict):
return value
if isinstance(value, list):
try:
# لیست جفت‌ها مانند [["join", true], ["sales", {..}]]
if all(isinstance(item, list) and len(item) == 2 for item in value):
return {k: v for k, v in value if isinstance(k, str)}
# لیست دیکشنری‌ها مانند [{"join": true}, {"sales": {...}}]
if all(isinstance(item, dict) for item in value):
merged = {}
for item in value:
merged.update({k: v for k, v in item.items()})
return merged
except Exception:
return {}
return {}
def get_translator(self) -> Translator: def get_translator(self) -> Translator:
"""دریافت translator برای ترجمه""" """دریافت translator برای ترجمه"""
return self._translator return self._translator
@ -89,7 +109,7 @@ class AuthContext:
permission_obj = repo.get_by_user_and_business(self.user.id, self.business_id) permission_obj = repo.get_by_user_and_business(self.user.id, self.business_id)
if permission_obj and permission_obj.business_permissions: if permission_obj and permission_obj.business_permissions:
return permission_obj.business_permissions return AuthContext._normalize_permissions_value(permission_obj.business_permissions)
return {} return {}
# بررسی دسترسی‌های اپلیکیشن # بررسی دسترسی‌های اپلیکیشن
@ -278,7 +298,7 @@ class AuthContext:
return False return False
# بررسی دسترسی join # بررسی دسترسی join
business_perms = permission_obj.business_permissions or {} business_perms = AuthContext._normalize_permissions_value(permission_obj.business_permissions)
has_join_access = business_perms.get('join', False) has_join_access = business_perms.get('join', False)
logger.info(f"Business membership check: user {self.user.id} join access to business {business_id}: {has_join_access}") logger.info(f"Business membership check: user {self.user.id} join access to business {business_id}: {has_join_access}")
return has_join_access return has_join_access

View file

@ -74,7 +74,7 @@ def require_business_access(business_id_param: str = "business_id"):
""" """
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs) -> Any: async def wrapper(*args, **kwargs) -> Any:
import logging import logging
from fastapi import Request from fastapi import Request
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -108,7 +108,11 @@ def require_business_access(business_id_param: str = "business_id"):
logger.warning(f"User {ctx.get_user_id()} does not have access to business {business_id}") logger.warning(f"User {ctx.get_user_id()} does not have access to business {business_id}")
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403) raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
return func(*args, **kwargs) # فراخوانی تابع اصلی و await در صورت نیاز
result = func(*args, **kwargs)
if inspect.isawaitable(result):
result = await result
return result
# Preserve original signature so FastAPI sees correct parameters (including Request) # Preserve original signature so FastAPI sees correct parameters (including Request)
wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined] wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined]
return wrapper return wrapper

View file

@ -14,9 +14,15 @@ def success_response(data: Any, request: Request = None, message: str = None) ->
if data is not None: if data is not None:
response["data"] = data response["data"] = data
# Add message if provided # Add message if provided (translate if translator exists)
if message is not None: if message is not None:
response["message"] = message translated = message
try:
if request is not None and hasattr(request.state, 'translator') and request.state.translator is not None:
translated = request.state.translator.t(message, default=message)
except Exception:
translated = message
response["message"] = translated
# Add calendar type information if request is available # Add calendar type information if request is available
if request and hasattr(request.state, 'calendar_type'): if request and hasattr(request.state, 'calendar_type'):

View file

@ -17,6 +17,7 @@ from adapters.api.v1.products import router as products_router
from adapters.api.v1.price_lists import router as price_lists_router from adapters.api.v1.price_lists import router as price_lists_router
from adapters.api.v1.persons import router as persons_router from adapters.api.v1.persons import router as persons_router
from adapters.api.v1.tax_units import router as tax_units_router from adapters.api.v1.tax_units import router as tax_units_router
from adapters.api.v1.tax_units import alias_router as units_alias_router
from adapters.api.v1.tax_types import router as tax_types_router from adapters.api.v1.tax_types import router as tax_types_router
from adapters.api.v1.support.tickets import router as support_tickets_router from adapters.api.v1.support.tickets import router as support_tickets_router
from adapters.api.v1.support.operator import router as support_operator_router from adapters.api.v1.support.operator import router as support_operator_router
@ -292,6 +293,7 @@ def create_app() -> FastAPI:
application.include_router(price_lists_router, prefix=settings.api_v1_prefix) application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
application.include_router(persons_router, prefix=settings.api_v1_prefix) application.include_router(persons_router, prefix=settings.api_v1_prefix)
application.include_router(tax_units_router, prefix=settings.api_v1_prefix) application.include_router(tax_units_router, prefix=settings.api_v1_prefix)
application.include_router(units_alias_router, prefix=settings.api_v1_prefix)
application.include_router(tax_types_router, prefix=settings.api_v1_prefix) application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
# Support endpoints # Support endpoints

View file

@ -0,0 +1,233 @@
from __future__ import annotations
from typing import Dict, Any, List, Optional
from decimal import Decimal
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from adapters.db.models.product import Product
from adapters.db.models.price_list import PriceItem
from adapters.db.models.category import BusinessCategory
from adapters.db.models.currency import Currency
from adapters.api.v1.schema_models.product import (
BulkPriceUpdateRequest,
BulkPriceUpdatePreview,
BulkPriceUpdatePreviewResponse,
BulkPriceUpdateType,
BulkPriceUpdateTarget,
BulkPriceUpdateDirection,
ProductItemType
)
def _quantize_non_negative_integer(value: Decimal) -> Decimal:
"""رُند کردن به عدد صحیح غیرمنفی (بدون اعشار)."""
# حذف اعشار: round-half-up به نزدیک‌ترین عدد صحیح
quantized = value.quantize(Decimal('1'))
if quantized < 0:
return Decimal('0')
return quantized
def _quantize_integer_keep_sign(value: Decimal) -> Decimal:
"""رُند کردن به عدد صحیح با حفظ علامت (بدون اعشار)."""
return value.quantize(Decimal('1'))
def calculate_new_price(current_price: Optional[Decimal], update_type: BulkPriceUpdateType, direction: BulkPriceUpdateDirection, value: Decimal) -> Optional[Decimal]:
"""محاسبه قیمت جدید بر اساس نوع تغییر با جهت، سپس رُند و کلَمپ به صفر"""
if current_price is None:
return None
delta = Decimal('0')
if update_type == BulkPriceUpdateType.PERCENTAGE:
sign = Decimal('1') if direction == BulkPriceUpdateDirection.INCREASE else Decimal('-1')
multiplier = Decimal('1') + (sign * (value / Decimal('100')))
new_value = current_price * multiplier
else:
sign = Decimal('1') if direction == BulkPriceUpdateDirection.INCREASE else Decimal('-1')
delta = sign * value
new_value = current_price + delta
# رُند به عدد صحیح و کلَمپ به صفر
return _quantize_non_negative_integer(new_value)
def get_filtered_products(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> List[Product]:
"""دریافت کالاهای فیلتر شده بر اساس معیارهای درخواست"""
query = db.query(Product).filter(Product.business_id == business_id)
# فیلتر بر اساس دسته‌بندی
if request.category_ids:
query = query.filter(Product.category_id.in_(request.category_ids))
# فیلتر بر اساس نوع آیتم
if request.item_types:
query = query.filter(Product.item_type.in_([t.value for t in request.item_types]))
# فیلتر بر اساس ارز: محصولی که قیمت‌های لیست مرتبط با ارزهای انتخابی دارد
if request.currency_ids:
query = query.filter(
db.query(PriceItem.id)
.filter(
PriceItem.product_id == Product.id,
PriceItem.currency_id.in_(request.currency_ids)
).exists()
)
# فیلتر بر اساس لیست قیمت: محصولی که در هر یک از لیست‌های انتخابی آیتم قیمت دارد
if request.price_list_ids:
query = query.filter(
db.query(PriceItem.id)
.filter(
PriceItem.product_id == Product.id,
PriceItem.price_list_id.in_(request.price_list_ids)
).exists()
)
# فیلتر بر اساس شناسه‌های کالاهای خاص
if request.product_ids:
query = query.filter(Product.id.in_(request.product_ids))
# فیلتر بر اساس موجودی
if request.only_products_with_inventory is not None:
if request.only_products_with_inventory:
query = query.filter(Product.track_inventory == True)
else:
query = query.filter(Product.track_inventory == False)
# فیلتر بر اساس وجود قیمت پایه
if request.only_products_with_base_price:
if request.target == BulkPriceUpdateTarget.SALES_PRICE:
query = query.filter(Product.base_sales_price.isnot(None))
elif request.target == BulkPriceUpdateTarget.PURCHASE_PRICE:
query = query.filter(Product.base_purchase_price.isnot(None))
else:
# در حالت هر دو، حداقل یکی موجود باشد
query = query.filter(or_(Product.base_sales_price.isnot(None), Product.base_purchase_price.isnot(None)))
return query.all()
def preview_bulk_price_update(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> BulkPriceUpdatePreviewResponse:
"""پیش‌نمایش تغییرات قیمت گروهی"""
products = get_filtered_products(db, business_id, request)
# کش نام دسته‌ها برای کاهش کوئری
category_titles: Dict[int, str] = {}
def _resolve_category_name(cid: Optional[int]) -> Optional[str]:
if cid is None:
return None
if cid in category_titles:
return category_titles[cid]
try:
cat = db.query(BusinessCategory).filter(BusinessCategory.id == cid, BusinessCategory.business_id == business_id).first()
if cat and isinstance(cat.title_translations, dict):
title = cat.title_translations.get('fa') or cat.title_translations.get('default') or ''
category_titles[cid] = title
return title
except Exception:
return None
return None
affected_products = []
total_sales_change = Decimal('0')
total_purchase_change = Decimal('0')
products_with_sales_change = 0
products_with_purchase_change = 0
for product in products:
preview = BulkPriceUpdatePreview(
product_id=product.id,
product_name=product.name or "بدون نام",
product_code=product.code or "بدون کد",
category_name=_resolve_category_name(product.category_id),
current_sales_price=product.base_sales_price,
current_purchase_price=product.base_purchase_price,
new_sales_price=None,
new_purchase_price=None,
sales_price_change=None,
purchase_price_change=None
)
# محاسبه تغییرات قیمت فروش
if request.target in [BulkPriceUpdateTarget.SALES_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_sales_price is not None:
new_sales_price = calculate_new_price(product.base_sales_price, request.update_type, request.direction, request.value)
preview.new_sales_price = new_sales_price
preview.sales_price_change = (new_sales_price - product.base_sales_price) if new_sales_price is not None else None
total_sales_change += (preview.sales_price_change or Decimal('0'))
products_with_sales_change += 1
# محاسبه تغییرات قیمت خرید
if request.target in [BulkPriceUpdateTarget.PURCHASE_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_purchase_price is not None:
new_purchase_price = calculate_new_price(product.base_purchase_price, request.update_type, request.direction, request.value)
preview.new_purchase_price = new_purchase_price
preview.purchase_price_change = (new_purchase_price - product.base_purchase_price) if new_purchase_price is not None else None
total_purchase_change += (preview.purchase_price_change or Decimal('0'))
products_with_purchase_change += 1
affected_products.append(preview)
summary = {
"total_products": len(products),
"affected_products": len(affected_products),
"products_with_sales_change": products_with_sales_change,
"products_with_purchase_change": products_with_purchase_change,
"total_sales_change": float(_quantize_integer_keep_sign(total_sales_change)),
"total_purchase_change": float(_quantize_integer_keep_sign(total_purchase_change)),
"update_type": request.update_type.value,
"direction": request.direction.value,
"target": request.target.value,
"value": float(_quantize_non_negative_integer(request.value)) if request.update_type == BulkPriceUpdateType.AMOUNT else float(request.value)
}
return BulkPriceUpdatePreviewResponse(
total_products=len(products),
affected_products=affected_products,
summary=summary
)
def apply_bulk_price_update(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> Dict[str, Any]:
"""اعمال تغییرات قیمت گروهی"""
products = get_filtered_products(db, business_id, request)
updated_count = 0
errors = []
# اگر price_list_ids مشخص شده باشد، هم قیمت پایه و هم PriceItemها باید به روزرسانی شوند
for product in products:
try:
# بروزرسانی قیمت فروش
if request.target in [BulkPriceUpdateTarget.SALES_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_sales_price is not None:
new_sales_price = calculate_new_price(product.base_sales_price, request.update_type, request.direction, request.value)
product.base_sales_price = new_sales_price
# بروزرسانی قیمت خرید
if request.target in [BulkPriceUpdateTarget.PURCHASE_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_purchase_price is not None:
new_purchase_price = calculate_new_price(product.base_purchase_price, request.update_type, request.direction, request.value)
product.base_purchase_price = new_purchase_price
# بروزرسانی آیتم‌های لیست قیمت مرتبط (در صورت مشخص بودن فیلترها)
q = db.query(PriceItem).filter(PriceItem.product_id == product.id)
if request.currency_ids:
q = q.filter(PriceItem.currency_id.in_(request.currency_ids))
if request.price_list_ids:
q = q.filter(PriceItem.price_list_id.in_(request.price_list_ids))
# اگر هدف فقط فروش/خرید نیست چون PriceItem فقط یک فیلد price دارد، همان price را تغییر می‌دهیم
for pi in q.all():
new_pi_price = calculate_new_price(Decimal(pi.price), request.update_type, request.direction, request.value)
pi.price = new_pi_price
updated_count += 1
except Exception as e:
errors.append(f"خطا در بروزرسانی کالای {product.name}: {str(e)}")
db.commit()
return {
"message": f"تغییرات قیمت برای {updated_count} کالا اعمال شد",
"updated_count": updated_count,
"total_products": len(products),
"errors": errors
}

View file

@ -192,7 +192,28 @@ def get_user_businesses(db: Session, user_id: int, query_info: Dict[str, Any]) -
business_dict['role'] = 'عضو' business_dict['role'] = 'عضو'
# دریافت دسترسی‌های کاربر برای این کسب و کار # دریافت دسترسی‌های کاربر برای این کسب و کار
permission_obj = permission_repo.get_by_user_and_business(user_id, business.id) permission_obj = permission_repo.get_by_user_and_business(user_id, business.id)
business_dict['permissions'] = permission_obj.business_permissions if permission_obj else {} if permission_obj and permission_obj.business_permissions:
perms = permission_obj.business_permissions
# Normalize to dict to avoid legacy list format
if isinstance(perms, dict):
business_dict['permissions'] = perms
elif isinstance(perms, list):
try:
if all(isinstance(item, list) and len(item) == 2 for item in perms):
business_dict['permissions'] = {k: v for k, v in perms if isinstance(k, str)}
elif all(isinstance(item, dict) for item in perms):
merged = {}
for it in perms:
merged.update({k: v for k, v in it.items()})
business_dict['permissions'] = merged
else:
business_dict['permissions'] = {}
except Exception:
business_dict['permissions'] = {}
else:
business_dict['permissions'] = {}
else:
business_dict['permissions'] = {}
all_businesses.append(business_dict) all_businesses.append(business_dict)
# اعمال فیلترها # اعمال فیلترها

View file

@ -20,11 +20,9 @@ def create_price_list(db: Session, business_id: int, payload: PriceListCreateReq
obj = repo.create( obj = repo.create(
business_id=business_id, business_id=business_id,
name=payload.name.strip(), name=payload.name.strip(),
currency_id=payload.currency_id,
default_unit_id=payload.default_unit_id,
is_active=payload.is_active, is_active=payload.is_active,
) )
return {"message": "لیست قیمت ایجاد شد", "data": _pl_to_dict(obj)} return {"message": "PRICE_LIST_CREATED", "data": _pl_to_dict(obj)}
def list_price_lists(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]: def list_price_lists(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
@ -53,10 +51,10 @@ def update_price_list(db: Session, business_id: int, id: int, payload: PriceList
dup = db.query(PriceList).filter(and_(PriceList.business_id == business_id, PriceList.name == payload.name.strip(), PriceList.id != id)).first() dup = db.query(PriceList).filter(and_(PriceList.business_id == business_id, PriceList.name == payload.name.strip(), PriceList.id != id)).first()
if dup: if dup:
raise ApiError("DUPLICATE_PRICE_LIST_NAME", "نام لیست قیمت تکراری است", http_status=400) raise ApiError("DUPLICATE_PRICE_LIST_NAME", "نام لیست قیمت تکراری است", http_status=400)
updated = repo.update(id, name=payload.name.strip() if isinstance(payload.name, str) else None, currency_id=payload.currency_id, default_unit_id=payload.default_unit_id, is_active=payload.is_active) updated = repo.update(id, name=payload.name.strip() if isinstance(payload.name, str) else None, is_active=payload.is_active)
if not updated: if not updated:
return None return None
return {"message": "لیست قیمت بروزرسانی شد", "data": _pl_to_dict(updated)} return {"message": "PRICE_LIST_UPDATED", "data": _pl_to_dict(updated)}
def delete_price_list(db: Session, business_id: int, id: int) -> bool: def delete_price_list(db: Session, business_id: int, id: int) -> bool:
@ -67,13 +65,13 @@ def delete_price_list(db: Session, business_id: int, id: int) -> bool:
return repo.delete(id) return repo.delete(id)
def list_price_items(db: Session, business_id: int, price_list_id: int, take: int = 50, skip: int = 0) -> Dict[str, Any]: def list_price_items(db: Session, business_id: int, price_list_id: int, take: int = 50, skip: int = 0, product_id: int | None = None, currency_id: int | None = None) -> Dict[str, Any]:
# مالکیت را از روی price_list بررسی می‌کنیم # مالکیت را از روی price_list بررسی می‌کنیم
pl = db.get(PriceList, price_list_id) pl = db.get(PriceList, price_list_id)
if not pl or pl.business_id != business_id: if not pl or pl.business_id != business_id:
raise ApiError("NOT_FOUND", "لیست قیمت یافت نشد", http_status=404) raise ApiError("NOT_FOUND", "لیست قیمت یافت نشد", http_status=404)
repo = PriceItemRepository(db) repo = PriceItemRepository(db)
return repo.list_for_price_list(price_list_id=price_list_id, take=take, skip=skip) return repo.list_for_price_list(price_list_id=price_list_id, take=take, skip=skip, product_id=product_id, currency_id=currency_id)
def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload: PriceItemUpsertRequest) -> Dict[str, Any]: def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload: PriceItemUpsertRequest) -> Dict[str, Any]:
@ -93,12 +91,12 @@ def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload
price_list_id=price_list_id, price_list_id=price_list_id,
product_id=payload.product_id, product_id=payload.product_id,
unit_id=payload.unit_id, unit_id=payload.unit_id,
currency_id=payload.currency_id or pl.currency_id, currency_id=payload.currency_id,
tier_name=payload.tier_name.strip(), tier_name=(payload.tier_name.strip() if isinstance(payload.tier_name, str) and payload.tier_name.strip() else 'پیش‌فرض'),
min_qty=payload.min_qty, min_qty=payload.min_qty,
price=payload.price, price=payload.price,
) )
return {"message": "قیمت ثبت شد", "data": _pi_to_dict(obj)} return {"message": "PRICE_ITEM_UPSERTED", "data": _pi_to_dict(obj)}
def delete_price_item(db: Session, business_id: int, id: int) -> bool: def delete_price_item(db: Session, business_id: int, id: int) -> bool:
@ -118,8 +116,6 @@ def _pl_to_dict(obj: PriceList) -> Dict[str, Any]:
"id": obj.id, "id": obj.id,
"business_id": obj.business_id, "business_id": obj.business_id,
"name": obj.name, "name": obj.name,
"currency_id": obj.currency_id,
"default_unit_id": obj.default_unit_id,
"is_active": obj.is_active, "is_active": obj.is_active,
"created_at": obj.created_at, "created_at": obj.created_at,
"updated_at": obj.updated_at, "updated_at": obj.updated_at,

View file

@ -103,7 +103,7 @@ def create_product(db: Session, business_id: int, payload: ProductCreateRequest)
_upsert_attributes(db, obj.id, business_id, payload.attribute_ids) _upsert_attributes(db, obj.id, business_id, payload.attribute_ids)
return {"message": "آیتم با موفقیت ایجاد شد", "data": _to_dict(obj)} return {"message": "PRODUCT_CREATED", "data": _to_dict(obj)}
def list_products(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]: def list_products(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
@ -178,7 +178,7 @@ def update_product(db: Session, product_id: int, business_id: int, payload: Prod
return None return None
_upsert_attributes(db, product_id, business_id, payload.attribute_ids) _upsert_attributes(db, product_id, business_id, payload.attribute_ids)
return {"message": "آیتم با موفقیت ویرایش شد", "data": _to_dict(updated)} return {"message": "PRODUCT_UPDATED", "data": _to_dict(updated)}
def delete_product(db: Session, product_id: int, business_id: int) -> bool: def delete_product(db: Session, product_id: int, business_id: int) -> bool:

View file

@ -0,0 +1,5 @@
from .health import router as health # noqa: F401
from .categories import router as categories # noqa: F401
from .products import router as products # noqa: F401
from .price_lists import router as price_lists # noqa: F401

View file

@ -0,0 +1,57 @@
from typing import List, Dict, Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.api.v1.schemas import SuccessResponse
from adapters.api.v1.schema_models.account import AccountTreeNode
from app.core.responses import success_response
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from adapters.db.models.account import Account
router = APIRouter(prefix="/accounts", tags=["accounts"])
def _build_tree(nodes: list[Dict[str, Any]]) -> list[AccountTreeNode]:
by_id: dict[int, AccountTreeNode] = {}
roots: list[AccountTreeNode] = []
for n in nodes:
node = AccountTreeNode(
id=n['id'], code=n['code'], name=n['name'], account_type=n.get('account_type'), parent_id=n.get('parent_id')
)
by_id[node.id] = node
for node in list(by_id.values()):
pid = node.parent_id
if pid and pid in by_id:
by_id[pid].children.append(node)
else:
roots.append(node)
return roots
@router.get("/business/{business_id}/tree",
summary="دریافت درخت حساب‌ها برای یک کسب و کار",
description="لیست حساب‌های عمومی و حساب‌های اختصاصی کسب و کار به صورت درختی",
)
@require_business_access("business_id")
def get_accounts_tree(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
# دریافت حساب‌های عمومی (business_id IS NULL) و حساب‌های مختص این کسب و کار
rows = db.query(Account).filter(
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
).order_by(Account.code.asc()).all()
flat = [
{"id": r.id, "code": r.code, "name": r.name, "account_type": r.account_type, "parent_id": r.parent_id}
for r in rows
]
tree = _build_tree(flat)
return success_response({"items": [n.model_dump() for n in tree]}, request)

View file

@ -0,0 +1,349 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from typing import List
from adapters.db.session import get_db
from adapters.db.models.email_config import EmailConfig
from adapters.db.repositories.email_config_repository import EmailConfigRepository
from adapters.api.v1.schema_models.email import (
EmailConfigCreate,
EmailConfigUpdate,
EmailConfigResponse,
SendEmailRequest,
TestConnectionRequest
)
from adapters.api.v1.schemas import SuccessResponse
from app.core.responses import success_response, format_datetime_fields
from app.core.permissions import require_app_permission
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.i18n import gettext, negotiate_locale
router = APIRouter(prefix="/admin/email", tags=["Email Configuration"])
@router.get("/configs", response_model=SuccessResponse)
@require_app_permission("superadmin")
async def get_email_configs(
request: Request,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user)
):
"""Get all email configurations"""
try:
email_repo = EmailConfigRepository(db)
configs = email_repo.get_all_configs()
config_responses = [
EmailConfigResponse.model_validate(config) for config in configs
]
# Format datetime fields based on calendar type
formatted_data = format_datetime_fields(config_responses, request)
return success_response(
data=formatted_data,
request=request
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/configs/{config_id}", response_model=SuccessResponse)
@require_app_permission("superadmin")
async def get_email_config(
config_id: int,
request: Request,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user)
):
"""Get specific email configuration"""
try:
email_repo = EmailConfigRepository(db)
config = email_repo.get_by_id(config_id)
if not config:
locale = negotiate_locale(request.headers.get("Accept-Language"))
raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale))
config_response = EmailConfigResponse.model_validate(config)
# Format datetime fields based on calendar type
formatted_data = format_datetime_fields(config_response.model_dump(), request)
return success_response(
data=formatted_data,
request=request
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/configs", response_model=SuccessResponse)
@require_app_permission("superadmin")
async def create_email_config(
request_data: EmailConfigCreate,
request: Request,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user)
):
"""Create new email configuration"""
try:
email_repo = EmailConfigRepository(db)
# Get locale from request
locale = negotiate_locale(request.headers.get("Accept-Language"))
# Check if name already exists
existing_config = email_repo.get_by_name(request_data.name)
if existing_config:
raise HTTPException(status_code=400, detail=gettext("Configuration name already exists", locale))
# Create new config
config = EmailConfig(**request_data.model_dump())
email_repo.db.add(config)
email_repo.db.commit()
email_repo.db.refresh(config)
# If this is the first config, set it as default
if not email_repo.get_default_config():
email_repo.set_default_config(config.id)
config_response = EmailConfigResponse.model_validate(config)
# Format datetime fields based on calendar type
formatted_data = format_datetime_fields(config_response.model_dump(), request)
return success_response(
data=formatted_data,
request=request
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/configs/{config_id}", response_model=SuccessResponse)
@require_app_permission("superadmin")
async def update_email_config(
config_id: int,
request_data: EmailConfigUpdate,
request: Request,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user)
):
"""Update email configuration"""
try:
email_repo = EmailConfigRepository(db)
config = email_repo.get_by_id(config_id)
# Get locale from request
locale = negotiate_locale(request.headers.get("Accept-Language"))
if not config:
raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale))
# Check name uniqueness if name is being updated
if request_data.name and request_data.name != config.name:
existing_config = email_repo.get_by_name(request_data.name)
if existing_config:
raise HTTPException(status_code=400, detail=gettext("Configuration name already exists", locale))
# Update config
update_data = request_data.model_dump(exclude_unset=True)
# Prevent changing is_default through update - use set-default endpoint instead
if 'is_default' in update_data:
del update_data['is_default']
for field, value in update_data.items():
setattr(config, field, value)
email_repo.update(config)
config_response = EmailConfigResponse.model_validate(config)
# Format datetime fields based on calendar type
formatted_data = format_datetime_fields(config_response.model_dump(), request)
return success_response(
data=formatted_data,
request=request
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/configs/{config_id}", response_model=SuccessResponse)
@require_app_permission("superadmin")
async def delete_email_config(
config_id: int,
request: Request,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user)
):
"""Delete email configuration"""
try:
email_repo = EmailConfigRepository(db)
config = email_repo.get_by_id(config_id)
# Get locale from request
locale = negotiate_locale(request.headers.get("Accept-Language"))
if not config:
raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale))
# Prevent deletion of default config
if config.is_default:
raise HTTPException(status_code=400, detail=gettext("Cannot delete default configuration", locale))
email_repo.delete(config)
return success_response(
data=None,
request=request
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/configs/{config_id}/test", response_model=SuccessResponse)
@require_app_permission("superadmin")
async def test_email_config(
config_id: int,
request: Request,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user)
):
"""Test email configuration connection"""
try:
email_repo = EmailConfigRepository(db)
config = email_repo.get_by_id(config_id)
# Get locale from request
locale = negotiate_locale(request.headers.get("Accept-Language"))
if not config:
raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale))
is_connected = email_repo.test_connection(config)
return success_response(
data={"connected": is_connected},
request=request
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/configs/{config_id}/activate", response_model=SuccessResponse)
@require_app_permission("superadmin")
async def activate_email_config(
config_id: int,
request: Request,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user)
):
"""Activate email configuration"""
try:
email_repo = EmailConfigRepository(db)
config = email_repo.get_by_id(config_id)
# Get locale from request
locale = negotiate_locale(request.headers.get("Accept-Language"))
if not config:
raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale))
success = email_repo.set_active_config(config_id)
if not success:
raise HTTPException(status_code=500, detail=gettext("Failed to activate configuration", locale))
return success_response(
data=None,
request=request
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/configs/{config_id}/set-default", response_model=SuccessResponse)
@require_app_permission("superadmin")
async def set_default_email_config(
config_id: int,
request: Request,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user)
):
"""Set email configuration as default"""
try:
email_repo = EmailConfigRepository(db)
config = email_repo.get_by_id(config_id)
# Get locale from request
locale = negotiate_locale(request.headers.get("Accept-Language"))
if not config:
raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale))
success = email_repo.set_default_config(config_id)
if not success:
raise HTTPException(status_code=500, detail=gettext("Failed to set default configuration", locale))
return success_response(
data=None,
request=request
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/send", response_model=SuccessResponse)
@require_app_permission("superadmin")
async def send_email(
request_data: SendEmailRequest,
request: Request,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user)
):
"""Send email using configured SMTP"""
try:
from app.services.email_service import EmailService
email_service = EmailService(db)
success = email_service.send_email(
to=request_data.to,
subject=request_data.subject,
body=request_data.body,
html_body=request_data.html_body,
config_id=request_data.config_id
)
# Get locale from request
locale = negotiate_locale(request.headers.get("Accept-Language"))
if not success:
raise HTTPException(status_code=500, detail=gettext("Failed to send email", locale))
return success_response(
data={"sent": True},
request=request
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,725 @@
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Request
from sqlalchemy.orm import Session
from sqlalchemy import and_
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_app_permission
from app.core.responses import success_response
from app.core.responses import ApiError
from app.core.i18n import locale_dependency
from app.services.file_storage_service import FileStorageService
from adapters.db.repositories.file_storage_repository import StorageConfigRepository, FileStorageRepository
from adapters.db.models.user import User
from adapters.db.models.file_storage import StorageConfig, FileStorage
from adapters.api.v1.schema_models.file_storage import (
StorageConfigCreateRequest,
StorageConfigUpdateRequest,
FileUploadRequest,
FileVerificationRequest,
FileInfo,
FileUploadResponse,
StorageConfigResponse,
FileStatisticsResponse,
CleanupResponse
)
router = APIRouter(prefix="/admin/files", tags=["Admin File Management"])
@router.get("/", response_model=dict)
@require_app_permission("superadmin")
async def list_all_files(
request: Request,
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=100),
module_context: Optional[str] = Query(None),
is_temporary: Optional[bool] = Query(None),
is_verified: Optional[bool] = Query(None),
db: Session = Depends(get_db),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""لیست تمام فایل‌ها با فیلتر"""
try:
file_repo = FileStorageRepository(db)
# محاسبه offset برای pagination
offset = (page - 1) * size
# ساخت فیلترها
filters = []
if module_context:
filters.append(FileStorage.module_context == module_context)
if is_temporary is not None:
filters.append(FileStorage.is_temporary == is_temporary)
if is_verified is not None:
filters.append(FileStorage.is_verified == is_verified)
# اضافه کردن فیلتر حذف نشده
filters.append(FileStorage.deleted_at.is_(None))
# دریافت فایل‌ها با فیلتر و pagination
files_query = db.query(FileStorage).filter(and_(*filters))
total_count = files_query.count()
files = files_query.order_by(FileStorage.created_at.desc()).offset(offset).limit(size).all()
# تبدیل به فرمت مناسب
files_data = []
for file in files:
files_data.append({
"id": str(file.id),
"original_name": file.original_name,
"stored_name": file.stored_name,
"file_size": file.file_size,
"mime_type": file.mime_type,
"storage_type": file.storage_type,
"module_context": file.module_context,
"context_id": str(file.context_id) if file.context_id else None,
"is_temporary": file.is_temporary,
"is_verified": file.is_verified,
"is_active": file.is_active,
"created_at": file.created_at.isoformat(),
"updated_at": file.updated_at.isoformat(),
"expires_at": file.expires_at.isoformat() if file.expires_at else None,
"uploaded_by": file.uploaded_by,
"checksum": file.checksum
})
# محاسبه pagination info
total_pages = (total_count + size - 1) // size
has_next = page < total_pages
has_prev = page > 1
data = {
"files": files_data,
"pagination": {
"page": page,
"size": size,
"total_count": total_count,
"total_pages": total_pages,
"has_next": has_next,
"has_prev": has_prev
},
"filters": {
"module_context": module_context,
"is_temporary": is_temporary,
"is_verified": is_verified
}
}
return success_response(data, request)
except Exception as e:
raise ApiError(
code="FILE_LIST_ERROR",
message=translator.t("FILE_LIST_ERROR", f"خطا در دریافت لیست فایل‌ها: {str(e)}"),
http_status=500,
translator=translator
)
@router.get("/unverified", response_model=dict)
@require_app_permission("superadmin")
async def get_unverified_files(
request: Request,
db: Session = Depends(get_db),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""فایل‌های تایید نشده"""
try:
file_service = FileStorageService(db)
unverified_files = await file_service.file_repo.get_unverified_temporary_files()
data = {
"unverified_files": [
{
"file_id": str(file.id),
"original_name": file.original_name,
"file_size": file.file_size,
"module_context": file.module_context,
"created_at": file.created_at.isoformat(),
"expires_at": file.expires_at.isoformat() if file.expires_at else None
}
for file in unverified_files
],
"count": len(unverified_files)
}
return success_response(data, request)
except Exception as e:
raise ApiError(
code="UNVERIFIED_FILES_ERROR",
message=translator.t("UNVERIFIED_FILES_ERROR", f"خطا در دریافت فایل‌های تایید نشده: {str(e)}"),
http_status=500,
translator=translator
)
@router.post("/cleanup-temporary", response_model=dict)
@require_app_permission("superadmin")
async def cleanup_temporary_files(
request: Request,
db: Session = Depends(get_db),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""پاکسازی فایل‌های موقت"""
try:
file_service = FileStorageService(db)
cleanup_result = await file_service.cleanup_unverified_files()
data = {
"message": translator.t("CLEANUP_COMPLETED", "Temporary files cleanup completed"),
"result": cleanup_result
}
return success_response(data, request)
except Exception as e:
raise ApiError(
code="CLEANUP_ERROR",
message=translator.t("CLEANUP_ERROR", f"خطا در پاکسازی فایل‌های موقت: {str(e)}"),
http_status=500,
translator=translator
)
@router.delete("/{file_id}", response_model=dict)
@require_app_permission("superadmin")
async def force_delete_file(
file_id: UUID,
request: Request,
db: Session = Depends(get_db),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""حذف اجباری فایل"""
try:
file_service = FileStorageService(db)
success = await file_service.delete_file(file_id)
if not success:
raise ApiError(
code="FILE_NOT_FOUND",
message=translator.t("FILE_NOT_FOUND", "فایل یافت نشد"),
http_status=404,
translator=translator
)
data = {"message": translator.t("FILE_DELETED_SUCCESS", "File deleted successfully")}
return success_response(data, request)
except ApiError:
raise
except Exception as e:
raise ApiError(
code="DELETE_FILE_ERROR",
message=translator.t("DELETE_FILE_ERROR", f"خطا در حذف فایل: {str(e)}"),
http_status=500,
translator=translator
)
@router.put("/{file_id}/restore", response_model=dict)
@require_app_permission("superadmin")
async def restore_file(
file_id: UUID,
request: Request,
db: Session = Depends(get_db),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""بازیابی فایل حذف شده"""
try:
file_repo = FileStorageRepository(db)
success = await file_repo.restore_file(file_id)
if not success:
raise ApiError(
code="FILE_NOT_FOUND",
message=translator.t("FILE_NOT_FOUND", "فایل یافت نشد"),
http_status=404,
translator=translator
)
data = {"message": translator.t("FILE_RESTORED_SUCCESS", "File restored successfully")}
return success_response(data, request)
except ApiError:
raise
except Exception as e:
raise ApiError(
code="RESTORE_FILE_ERROR",
message=translator.t("RESTORE_FILE_ERROR", f"خطا در بازیابی فایل: {str(e)}"),
http_status=500,
translator=translator
)
@router.get("/statistics", response_model=dict)
@require_app_permission("superadmin")
async def get_file_statistics(
request: Request,
db: Session = Depends(get_db),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""آمار استفاده از فضای ذخیره‌سازی"""
try:
file_service = FileStorageService(db)
statistics = await file_service.get_storage_statistics()
return success_response(statistics, request)
except Exception as e:
raise ApiError(
code="STATISTICS_ERROR",
message=translator.t("STATISTICS_ERROR", f"خطا در دریافت آمار: {str(e)}"),
http_status=500,
translator=translator
)
# Storage Configuration Management
@router.get("/storage-configs/", response_model=dict)
@require_app_permission("superadmin")
async def get_storage_configs(
request: Request,
db: Session = Depends(get_db),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""لیست تنظیمات ذخیره‌سازی"""
try:
config_repo = StorageConfigRepository(db)
configs = config_repo.get_all_configs()
data = {
"configs": [
{
"id": str(config.id),
"name": config.name,
"storage_type": config.storage_type,
"is_default": config.is_default,
"is_active": config.is_active,
"config_data": config.config_data,
"created_at": config.created_at.isoformat()
}
for config in configs
]
}
return success_response(data, request)
except Exception as e:
raise ApiError(
code="STORAGE_CONFIGS_ERROR",
message=translator.t("STORAGE_CONFIGS_ERROR", f"خطا در دریافت تنظیمات ذخیره‌سازی: {str(e)}"),
http_status=500,
translator=translator
)
@router.post("/storage-configs/", response_model=dict)
@require_app_permission("superadmin")
async def create_storage_config(
request: Request,
config_request: StorageConfigCreateRequest,
db: Session = Depends(get_db),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""ایجاد تنظیمات ذخیره‌سازی جدید"""
try:
config_repo = StorageConfigRepository(db)
config = await config_repo.create_config(
name=config_request.name,
storage_type=config_request.storage_type,
config_data=config_request.config_data,
created_by=current_user.get_user_id(),
is_default=config_request.is_default,
is_active=config_request.is_active
)
data = {
"message": translator.t("STORAGE_CONFIG_CREATED", "Storage configuration created successfully"),
"config_id": str(config.id)
}
return success_response(data, request)
except Exception as e:
raise ApiError(
code="CREATE_STORAGE_CONFIG_ERROR",
message=translator.t("CREATE_STORAGE_CONFIG_ERROR", f"خطا در ایجاد تنظیمات ذخیره‌سازی: {str(e)}"),
http_status=400,
translator=translator
)
@router.put("/storage-configs/{config_id}", response_model=dict)
@require_app_permission("superadmin")
async def update_storage_config(
config_id: UUID,
request: Request,
config_request: StorageConfigUpdateRequest,
db: Session = Depends(get_db),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""بروزرسانی تنظیمات ذخیره‌سازی"""
try:
config_repo = StorageConfigRepository(db)
# TODO: پیاده‌سازی بروزرسانی
data = {"message": translator.t("STORAGE_CONFIG_UPDATE_NOT_IMPLEMENTED", "Storage configuration update - to be implemented")}
return success_response(data, request)
except Exception as e:
raise ApiError(
code="UPDATE_STORAGE_CONFIG_ERROR",
message=translator.t("UPDATE_STORAGE_CONFIG_ERROR", f"خطا در بروزرسانی تنظیمات ذخیره‌سازی: {str(e)}"),
http_status=500,
translator=translator
)
@router.put("/storage-configs/{config_id}/set-default", response_model=dict)
@require_app_permission("superadmin")
async def set_default_storage_config(
config_id: UUID,
request: Request,
db: Session = Depends(get_db),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""تنظیم به عنوان پیش‌فرض"""
try:
config_repo = StorageConfigRepository(db)
success = await config_repo.set_default_config(config_id)
if not success:
raise ApiError(
code="STORAGE_CONFIG_NOT_FOUND",
message=translator.t("STORAGE_CONFIG_NOT_FOUND", "تنظیمات ذخیره‌سازی یافت نشد"),
http_status=404,
translator=translator
)
data = {"message": translator.t("DEFAULT_STORAGE_CONFIG_UPDATED", "Default storage configuration updated successfully")}
return success_response(data, request)
except ApiError:
raise
except Exception as e:
raise ApiError(
code="SET_DEFAULT_STORAGE_CONFIG_ERROR",
message=translator.t("SET_DEFAULT_STORAGE_CONFIG_ERROR", f"خطا در تنظیم پیش‌فرض: {str(e)}"),
http_status=500,
translator=translator
)
@router.delete("/storage-configs/{config_id}", response_model=dict)
@require_app_permission("superadmin")
async def delete_storage_config(
config_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""حذف تنظیمات ذخیره‌سازی"""
try:
config_repo = StorageConfigRepository(db)
# بررسی وجود فایل‌ها قبل از حذف
file_count = config_repo.count_files_by_storage_config(config_id)
if file_count > 0:
raise ApiError(
code="STORAGE_CONFIG_HAS_FILES",
message=translator.t("STORAGE_CONFIG_HAS_FILES", f"این تنظیمات ذخیره‌سازی دارای {file_count} فایل است و قابل حذف نیست"),
http_status=400,
translator=translator
)
success = config_repo.delete_config(config_id)
if not success:
raise ApiError(
code="STORAGE_CONFIG_NOT_FOUND",
message=translator.t("STORAGE_CONFIG_NOT_FOUND", "تنظیمات ذخیره‌سازی یافت نشد"),
http_status=404,
translator=translator
)
data = {"message": translator.t("STORAGE_CONFIG_DELETED", "Storage configuration deleted successfully")}
return success_response(data, request)
except ApiError:
raise
except Exception as e:
raise ApiError(
code="DELETE_STORAGE_CONFIG_ERROR",
message=translator.t("DELETE_STORAGE_CONFIG_ERROR", f"خطا در حذف تنظیمات ذخیره‌سازی: {str(e)}"),
http_status=500,
translator=translator
)
@router.post("/storage-configs/{config_id}/test", response_model=dict)
@require_app_permission("superadmin")
async def test_storage_config(
config_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: AuthContext = Depends(get_current_user),
translator = Depends(locale_dependency)
):
"""تست اتصال به storage"""
try:
config_repo = StorageConfigRepository(db)
config = db.query(StorageConfig).filter(StorageConfig.id == config_id).first()
if not config:
raise ApiError(
code="STORAGE_CONFIG_NOT_FOUND",
message=translator.t("STORAGE_CONFIG_NOT_FOUND", "تنظیمات ذخیره‌سازی یافت نشد"),
http_status=404,
translator=translator
)
# تست اتصال بر اساس نوع storage
test_result = await _test_storage_connection(config)
if test_result["success"]:
data = {
"message": translator.t("STORAGE_CONNECTION_SUCCESS", "اتصال به storage موفقیت‌آمیز بود"),
"test_result": test_result
}
else:
data = {
"message": translator.t("STORAGE_CONNECTION_FAILED", "اتصال به storage ناموفق بود"),
"test_result": test_result
}
return success_response(data, request)
except ApiError:
raise
except Exception as e:
raise ApiError(
code="TEST_STORAGE_CONFIG_ERROR",
message=translator.t("TEST_STORAGE_CONFIG_ERROR", f"خطا در تست اتصال: {str(e)}"),
http_status=500,
translator=translator
)
# Helper function for testing storage connections
async def _test_storage_connection(config: StorageConfig) -> dict:
"""تست اتصال به storage بر اساس نوع آن"""
import os
import tempfile
from datetime import datetime
try:
if config.storage_type == "local":
return await _test_local_storage(config)
elif config.storage_type == "ftp":
return await _test_ftp_storage(config)
else:
return {
"success": False,
"error": f"نوع storage پشتیبانی نشده: {config.storage_type}",
"tested_at": datetime.utcnow().isoformat()
}
except Exception as e:
return {
"success": False,
"error": str(e),
"tested_at": datetime.utcnow().isoformat()
}
async def _test_local_storage(config: StorageConfig) -> dict:
"""تست اتصال به local storage"""
import os
from datetime import datetime
try:
base_path = config.config_data.get("base_path", "/tmp/hesabix_files")
# بررسی وجود مسیر
if not os.path.exists(base_path):
# تلاش برای ایجاد مسیر
os.makedirs(base_path, exist_ok=True)
# بررسی دسترسی نوشتن
test_file_path = os.path.join(base_path, f"test_connection_{datetime.utcnow().timestamp()}.txt")
# نوشتن فایل تست
with open(test_file_path, "w") as f:
f.write("Test connection file")
# خواندن فایل تست
with open(test_file_path, "r") as f:
content = f.read()
# حذف فایل تست
os.remove(test_file_path)
if content == "Test connection file":
return {
"success": True,
"message": "اتصال به local storage موفقیت‌آمیز بود",
"storage_type": "local",
"base_path": base_path,
"tested_at": datetime.utcnow().isoformat()
}
else:
return {
"success": False,
"error": "خطا در خواندن فایل تست",
"tested_at": datetime.utcnow().isoformat()
}
except PermissionError:
return {
"success": False,
"error": "دسترسی به مسیر ذخیره‌سازی وجود ندارد",
"tested_at": datetime.utcnow().isoformat()
}
except Exception as e:
return {
"success": False,
"error": f"خطا در تست local storage: {str(e)}",
"tested_at": datetime.utcnow().isoformat()
}
async def _test_ftp_storage(config: StorageConfig) -> dict:
"""تست اتصال به FTP storage"""
import ftplib
import tempfile
import os
from datetime import datetime
try:
# دریافت تنظیمات FTP
config_data = config.config_data
host = config_data.get("host")
port = int(config_data.get("port", 21))
username = config_data.get("username")
password = config_data.get("password")
directory = config_data.get("directory", "/")
use_tls = config_data.get("use_tls", False)
# بررسی وجود پارامترهای ضروری
if not all([host, username, password]):
return {
"success": False,
"error": "پارامترهای ضروری FTP (host, username, password) موجود نیست",
"storage_type": "ftp",
"tested_at": datetime.utcnow().isoformat()
}
# اتصال به FTP
if use_tls:
ftp = ftplib.FTP_TLS()
else:
ftp = ftplib.FTP()
# تنظیم timeout
ftp.connect(host, port, timeout=10)
ftp.login(username, password)
# تغییر به دایرکتوری مورد نظر
if directory and directory != "/":
try:
ftp.cwd(directory)
except ftplib.error_perm:
return {
"success": False,
"error": f"دسترسی به دایرکتوری {directory} وجود ندارد",
"storage_type": "ftp",
"tested_at": datetime.utcnow().isoformat()
}
# تست نوشتن فایل
test_filename = f"test_connection_{datetime.utcnow().timestamp()}.txt"
test_content = "Test FTP connection file"
# ایجاد فایل موقت
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as temp_file:
temp_file.write(test_content)
temp_file_path = temp_file.name
try:
# آپلود فایل
with open(temp_file_path, 'rb') as file:
ftp.storbinary(f'STOR {test_filename}', file)
# بررسی وجود فایل
file_list = []
ftp.retrlines('LIST', file_list.append)
file_exists = any(test_filename in line for line in file_list)
if not file_exists:
return {
"success": False,
"error": "فایل تست آپلود نشد",
"storage_type": "ftp",
"tested_at": datetime.utcnow().isoformat()
}
# حذف فایل تست
try:
ftp.delete(test_filename)
except ftplib.error_perm:
pass # اگر نتوانست حذف کند، مهم نیست
# بستن اتصال
ftp.quit()
return {
"success": True,
"message": "اتصال به FTP server موفقیت‌آمیز بود",
"storage_type": "ftp",
"host": host,
"port": port,
"directory": directory,
"use_tls": use_tls,
"tested_at": datetime.utcnow().isoformat()
}
finally:
# حذف فایل موقت
try:
os.unlink(temp_file_path)
except:
pass
except ftplib.error_perm as e:
return {
"success": False,
"error": f"خطا در احراز هویت FTP: {str(e)}",
"storage_type": "ftp",
"tested_at": datetime.utcnow().isoformat()
}
except ftplib.error_temp as e:
return {
"success": False,
"error": f"خطای موقت FTP: {str(e)}",
"storage_type": "ftp",
"tested_at": datetime.utcnow().isoformat()
}
except ConnectionRefusedError:
return {
"success": False,
"error": "اتصال به سرور FTP رد شد. بررسی کنید که سرور در حال اجرا باشد",
"storage_type": "ftp",
"tested_at": datetime.utcnow().isoformat()
}
except Exception as e:
return {
"success": False,
"error": f"خطا در تست FTP storage: {str(e)}",
"storage_type": "ftp",
"tested_at": datetime.utcnow().isoformat()
}

View file

@ -0,0 +1,936 @@
# Removed __future__ annotations to fix OpenAPI schema generation
import datetime
from fastapi import APIRouter, Depends, Request, Query
from fastapi.responses import Response
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.responses import success_response, format_datetime_fields
from app.services.captcha_service import create_captcha
from app.services.auth_service import register_user, login_user, create_password_reset, reset_password, change_password, referral_stats
from app.services.pdf import PDFService
from .schemas import (
RegisterRequest, LoginRequest, ForgotPasswordRequest, ResetPasswordRequest,
ChangePasswordRequest, CreateApiKeyRequest, QueryInfo, FilterItem,
SuccessResponse, CaptchaResponse, LoginResponse, ApiKeyResponse,
ReferralStatsResponse, UserResponse
)
from app.core.auth_dependency import get_current_user, AuthContext
from app.services.api_key_service import list_personal_keys, create_personal_key, revoke_key
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/captcha",
summary="تولید کپچای عددی",
description="تولید کپچای عددی برای تأیید هویت در عملیات حساس",
response_model=SuccessResponse,
responses={
200: {
"description": "کپچا با موفقیت تولید شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کپچا تولید شد",
"data": {
"captcha_id": "abc123def456",
"image_base64": "iVBORw0KGgoAAAANSUhEUgAA...",
"ttl_seconds": 180
}
}
}
}
}
}
)
def generate_captcha(db: Session = Depends(get_db)) -> dict:
captcha_id, image_base64, ttl = create_captcha(db)
return success_response({
"captcha_id": captcha_id,
"image_base64": image_base64,
"ttl_seconds": ttl,
})
@router.get("/me",
summary="دریافت اطلاعات کاربر کنونی",
description="دریافت اطلاعات کامل کاربری که در حال حاضر وارد سیستم شده است",
response_model=SuccessResponse,
responses={
200: {
"description": "اطلاعات کاربر با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "اطلاعات کاربر دریافت شد",
"data": {
"id": 1,
"email": "user@example.com",
"mobile": "09123456789",
"first_name": "احمد",
"last_name": "احمدی",
"is_active": True,
"referral_code": "ABC123",
"referred_by_user_id": None,
"app_permissions": {"admin": True},
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است",
"content": {
"application/json": {
"example": {
"success": False,
"message": "احراز هویت مورد نیاز است",
"error_code": "UNAUTHORIZED"
}
}
}
}
}
)
def get_current_user_info(
request: Request,
ctx: AuthContext = Depends(get_current_user)
) -> dict:
"""دریافت اطلاعات کاربر کنونی"""
return success_response(ctx.to_dict(), request)
@router.post("/register",
summary="ثبت‌نام کاربر جدید",
description="ثبت‌نام کاربر جدید در سیستم با تأیید کپچا",
response_model=SuccessResponse,
responses={
200: {
"description": "کاربر با موفقیت ثبت‌نام شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "ثبت‌نام با موفقیت انجام شد",
"data": {
"api_key": "sk_1234567890abcdef",
"expires_at": None,
"user": {
"id": 1,
"first_name": "احمد",
"last_name": "احمدی",
"email": "ahmad@example.com",
"mobile": "09123456789",
"referral_code": "ABC123",
"app_permissions": None
}
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کپچا نامعتبر است",
"error_code": "INVALID_CAPTCHA"
}
}
}
},
409: {
"description": "کاربر با این ایمیل یا موبایل قبلاً ثبت‌نام کرده است",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کاربر با این ایمیل قبلاً ثبت‌نام کرده است",
"error_code": "USER_EXISTS"
}
}
}
}
}
)
def register(request: Request, payload: RegisterRequest, db: Session = Depends(get_db)) -> dict:
user_id = register_user(
db=db,
first_name=payload.first_name,
last_name=payload.last_name,
email=payload.email,
mobile=payload.mobile,
password=payload.password,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
referrer_code=payload.referrer_code,
)
# Create a session api key similar to login
user_agent = request.headers.get("User-Agent")
ip = request.client.host if request.client else None
from app.core.security import generate_api_key
from adapters.db.repositories.api_key_repo import ApiKeyRepository
api_key, key_hash = generate_api_key()
api_repo = ApiKeyRepository(db)
api_repo.create_session_key(user_id=user_id, key_hash=key_hash, device_id=payload.device_id, user_agent=user_agent, ip=ip, expires_at=None)
from adapters.db.models.user import User
user_obj = db.get(User, user_id)
user = {"id": user_id, "first_name": payload.first_name, "last_name": payload.last_name, "email": payload.email, "mobile": payload.mobile, "referral_code": getattr(user_obj, "referral_code", None), "app_permissions": getattr(user_obj, "app_permissions", None)}
response_data = {"api_key": api_key, "expires_at": None, "user": user}
formatted_data = format_datetime_fields(response_data, request)
return success_response(formatted_data, request)
@router.post("/login",
summary="ورود با ایمیل یا موبایل",
description="ورود کاربر به سیستم با استفاده از ایمیل یا شماره موبایل و رمز عبور",
response_model=SuccessResponse,
responses={
200: {
"description": "ورود با موفقیت انجام شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "ورود با موفقیت انجام شد",
"data": {
"api_key": "sk_1234567890abcdef",
"expires_at": "2024-01-02T00:00:00Z",
"user": {
"id": 1,
"first_name": "احمد",
"last_name": "احمدی",
"email": "ahmad@example.com",
"mobile": "09123456789",
"referral_code": "ABC123",
"app_permissions": {"admin": True}
}
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کپچا نامعتبر است",
"error_code": "INVALID_CAPTCHA"
}
}
}
},
401: {
"description": "اطلاعات ورود نامعتبر است",
"content": {
"application/json": {
"example": {
"success": False,
"message": "ایمیل یا رمز عبور اشتباه است",
"error_code": "INVALID_CREDENTIALS"
}
}
}
}
}
)
def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
user_agent = request.headers.get("User-Agent")
ip = request.client.host if request.client else None
api_key, expires_at, user = login_user(
db=db,
identifier=payload.identifier,
password=payload.password,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
device_id=payload.device_id,
user_agent=user_agent,
ip=ip,
)
# Ensure referral_code is included
from adapters.db.repositories.user_repo import UserRepository
repo = UserRepository(db)
from adapters.db.models.user import User
user_obj = None
if 'id' in user and user['id']:
user_obj = repo.db.get(User, user['id'])
if user_obj is not None:
user["referral_code"] = getattr(user_obj, "referral_code", None)
response_data = {"api_key": api_key, "expires_at": expires_at, "user": user}
formatted_data = format_datetime_fields(response_data, request)
return success_response(formatted_data, request)
@router.post("/forgot-password",
summary="ایجاد توکن بازنشانی رمز عبور",
description="ایجاد توکن برای بازنشانی رمز عبور کاربر",
response_model=SuccessResponse,
responses={
200: {
"description": "توکن بازنشانی با موفقیت ایجاد شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "توکن بازنشانی ارسال شد",
"data": {
"ok": True,
"token": "reset_token_1234567890abcdef"
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کپچا نامعتبر است",
"error_code": "INVALID_CAPTCHA"
}
}
}
},
404: {
"description": "کاربر یافت نشد",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کاربر با این ایمیل یا موبایل یافت نشد",
"error_code": "USER_NOT_FOUND"
}
}
}
}
}
)
def forgot_password(payload: ForgotPasswordRequest, db: Session = Depends(get_db)) -> dict:
# In production do not return token; send via email/SMS. Here we return for dev/testing.
token = create_password_reset(db=db, identifier=payload.identifier, captcha_id=payload.captcha_id, captcha_code=payload.captcha_code)
return success_response({"ok": True, "token": token if token else None})
@router.post("/reset-password",
summary="بازنشانی رمز عبور با توکن",
description="بازنشانی رمز عبور کاربر با استفاده از توکن دریافتی",
response_model=SuccessResponse,
responses={
200: {
"description": "رمز عبور با موفقیت بازنشانی شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "رمز عبور با موفقیت تغییر کرد",
"data": {
"ok": True
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کپچا نامعتبر است",
"error_code": "INVALID_CAPTCHA"
}
}
}
},
404: {
"description": "توکن نامعتبر یا منقضی شده است",
"content": {
"application/json": {
"example": {
"success": False,
"message": "توکن نامعتبر یا منقضی شده است",
"error_code": "INVALID_TOKEN"
}
}
}
}
}
)
def reset_password_endpoint(payload: ResetPasswordRequest, db: Session = Depends(get_db)) -> dict:
reset_password(db=db, token=payload.token, new_password=payload.new_password, captcha_id=payload.captcha_id, captcha_code=payload.captcha_code)
return success_response({"ok": True})
@router.get("/api-keys",
summary="لیست کلیدهای API شخصی",
description="دریافت لیست کلیدهای API شخصی کاربر",
response_model=SuccessResponse,
responses={
200: {
"description": "لیست کلیدهای API با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "لیست کلیدهای API دریافت شد",
"data": [
{
"id": 1,
"name": "کلید اصلی",
"scopes": "read,write",
"device_id": "device123",
"user_agent": "Mozilla/5.0...",
"ip": "192.168.1.1",
"expires_at": None,
"last_used_at": "2024-01-01T12:00:00Z",
"created_at": "2024-01-01T00:00:00Z"
}
]
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def list_keys(request: Request, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
items = list_personal_keys(db, ctx.user.id)
return success_response(items)
@router.post("/api-keys",
summary="ایجاد کلید API شخصی",
description="ایجاد کلید API جدید برای کاربر",
response_model=SuccessResponse,
responses={
200: {
"description": "کلید API با موفقیت ایجاد شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کلید API ایجاد شد",
"data": {
"id": 1,
"api_key": "sk_1234567890abcdef"
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def create_key(request: Request, payload: CreateApiKeyRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
id_, api_key = create_personal_key(db, ctx.user.id, payload.name, payload.scopes, None)
return success_response({"id": id_, "api_key": api_key})
@router.post("/change-password",
summary="تغییر رمز عبور",
description="تغییر رمز عبور کاربر با تأیید رمز عبور فعلی",
response_model=SuccessResponse,
responses={
200: {
"description": "رمز عبور با موفقیت تغییر کرد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "رمز عبور با موفقیت تغییر کرد",
"data": {
"ok": True
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها",
"content": {
"application/json": {
"example": {
"success": False,
"message": "رمز عبور فعلی اشتباه است",
"error_code": "INVALID_CURRENT_PASSWORD"
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def change_password_endpoint(request: Request, payload: ChangePasswordRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
# دریافت translator از request state
translator = getattr(request.state, "translator", None)
change_password(
db=db,
user_id=ctx.user.id,
current_password=payload.current_password,
new_password=payload.new_password,
confirm_password=payload.confirm_password,
translator=translator
)
return success_response({"ok": True})
@router.delete("/api-keys/{key_id}",
summary="حذف کلید API",
description="حذف کلید API مشخص شده",
response_model=SuccessResponse,
responses={
200: {
"description": "کلید API با موفقیت حذف شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کلید API حذف شد",
"data": {
"ok": True
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
404: {
"description": "کلید API یافت نشد",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کلید API یافت نشد",
"error_code": "API_KEY_NOT_FOUND"
}
}
}
}
}
)
def delete_key(request: Request, key_id: int, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
revoke_key(db, ctx.user.id, key_id)
return success_response({"ok": True})
@router.get("/referrals/stats",
summary="آمار معرفی‌ها",
description="دریافت آمار معرفی‌های کاربر فعلی",
response_model=SuccessResponse,
responses={
200: {
"description": "آمار معرفی‌ها با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "آمار معرفی‌ها دریافت شد",
"data": {
"total_referrals": 25,
"active_referrals": 20,
"recent_referrals": 5,
"referral_rate": 0.8
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def get_referral_stats(request: Request, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), start: str = Query(None, description="تاریخ شروع (ISO format)"), end: str = Query(None, description="تاریخ پایان (ISO format)")):
from datetime import datetime
start_dt = datetime.fromisoformat(start) if start else None
end_dt = datetime.fromisoformat(end) if end else None
stats = referral_stats(db=db, user_id=ctx.user.id, start=start_dt, end=end_dt)
return success_response(stats)
@router.post("/referrals/list",
summary="لیست معرفی‌ها با فیلتر پیشرفته",
description="دریافت لیست معرفی‌ها با قابلیت فیلتر، جستجو، مرتب‌سازی و صفحه‌بندی",
response_model=SuccessResponse,
responses={
200: {
"description": "لیست معرفی‌ها با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "لیست معرفی‌ها دریافت شد",
"data": {
"items": [
{
"id": 1,
"first_name": "علی",
"last_name": "احمدی",
"email": "ali@example.com",
"mobile": "09123456789",
"created_at": "2024-01-01T00:00:00Z"
}
],
"total": 1,
"page": 1,
"limit": 10,
"total_pages": 1,
"has_next": False,
"has_prev": False
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def get_referral_list_advanced(
request: Request,
query_info: QueryInfo,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""
دریافت لیست معرفیها با قابلیت فیلتر پیشرفته
پارامترهای QueryInfo:
- sort_by: فیلد مرتبسازی (مثال: created_at, first_name, last_name, email)
- sort_desc: ترتیب نزولی (true/false)
- take: تعداد رکورد در هر صفحه (پیشفرض: 10)
- skip: تعداد رکورد صرفنظر شده (پیشفرض: 0)
- search: عبارت جستجو
- search_fields: فیلدهای جستجو (مثال: ["first_name", "last_name", "email"])
- filters: آرایه فیلترها با ساختار:
[
{
"property": "created_at",
"operator": ">=",
"value": "2024-01-01T00:00:00"
},
{
"property": "first_name",
"operator": "*",
"value": "احمد"
}
]
"""
from adapters.db.repositories.user_repo import UserRepository
from adapters.db.models.user import User
from datetime import datetime
# Create a custom query for referrals
repo = UserRepository(db)
# Add filter for referrals only (users with referred_by_user_id = current user)
referral_filter = FilterItem(
property="referred_by_user_id",
operator="=",
value=ctx.user.id
)
# Add referral filter to existing filters
if query_info.filters is None:
query_info.filters = [referral_filter]
else:
query_info.filters.append(referral_filter)
# Set default search fields for referrals
if query_info.search_fields is None:
query_info.search_fields = ["first_name", "last_name", "email"]
# Execute query with filters
referrals, total = repo.query_with_filters(query_info)
# Convert to dictionary format
referral_dicts = [repo.to_dict(referral) for referral in referrals]
# Format datetime fields
formatted_referrals = format_datetime_fields(referral_dicts, request)
# Calculate pagination info
page = (query_info.skip // query_info.take) + 1
total_pages = (total + query_info.take - 1) // query_info.take
return success_response({
"items": formatted_referrals,
"total": total,
"page": page,
"limit": query_info.take,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1
}, request)
@router.post("/referrals/export/pdf",
summary="خروجی PDF لیست معرفی‌ها",
description="خروجی PDF لیست معرفی‌ها با قابلیت فیلتر و انتخاب سطرهای خاص",
responses={
200: {
"description": "فایل PDF با موفقیت تولید شد",
"content": {
"application/pdf": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def export_referrals_pdf(
request: Request,
query_info: QueryInfo,
selected_only: bool = False,
selected_indices: str | None = None,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> Response:
"""
خروجی PDF لیست معرفیها
پارامترها:
- selected_only: آیا فقط سطرهای انتخاب شده export شوند
- selected_indices: لیست ایندکسهای انتخاب شده (JSON string)
- سایر پارامترهای QueryInfo برای فیلتر
"""
from app.services.pdf import PDFService
from app.services.auth_service import referral_stats
import json
# Parse selected indices if provided
indices = None
if selected_only and selected_indices:
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
# Get stats for the report
stats = None
try:
# Extract date range from filters if available
start_date = None
end_date = None
if query_info.filters:
for filter_item in query_info.filters:
if filter_item.property == 'created_at':
if filter_item.operator == '>=':
start_date = filter_item.value
elif filter_item.operator == '<':
end_date = filter_item.value
stats = referral_stats(
db=db,
user_id=ctx.user.id,
start=start_date,
end=end_date
)
except Exception:
pass # Continue without stats
# Get calendar type from request headers
calendar_header = request.headers.get("X-Calendar-Type", "jalali")
calendar_type = "jalali" if calendar_header.lower() in ["jalali", "persian", "shamsi"] else "gregorian"
# Generate PDF using new modular service
pdf_service = PDFService()
# Get locale from request headers
locale_header = request.headers.get("Accept-Language", "fa")
locale = "fa" if locale_header.startswith("fa") else "en"
pdf_bytes = pdf_service.generate_pdf(
module_name='marketing',
data={}, # Empty data - module will fetch its own data
calendar_type=calendar_type,
locale=locale,
db=db,
user_id=ctx.user.id,
query_info=query_info,
selected_indices=indices,
stats=stats
)
# Return PDF response
from fastapi.responses import Response
import datetime
filename = f"referrals_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes))
}
)
@router.post("/referrals/export/excel",
summary="خروجی Excel لیست معرفی‌ها",
description="خروجی Excel لیست معرفی‌ها با قابلیت فیلتر و انتخاب سطرهای خاص",
responses={
200: {
"description": "فایل Excel با موفقیت تولید شد",
"content": {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def export_referrals_excel(
request: Request,
query_info: QueryInfo,
selected_only: bool = False,
selected_indices: str | None = None,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> Response:
"""
خروجی Excel لیست معرفیها (فایل Excel واقعی برای دانلود)
پارامترها:
- selected_only: آیا فقط سطرهای انتخاب شده export شوند
- selected_indices: لیست ایندکسهای انتخاب شده (JSON string)
- سایر پارامترهای QueryInfo برای فیلتر
"""
from app.services.pdf import PDFService
import json
import io
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
# Parse selected indices if provided
indices = None
if selected_only and selected_indices:
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
# Get calendar type from request headers
calendar_header = request.headers.get("X-Calendar-Type", "jalali")
calendar_type = "jalali" if calendar_header.lower() in ["jalali", "persian", "shamsi"] else "gregorian"
# Generate Excel data using new modular service
pdf_service = PDFService()
# Get locale from request headers
locale_header = request.headers.get("Accept-Language", "fa")
locale = "fa" if locale_header.startswith("fa") else "en"
excel_data = pdf_service.generate_excel_data(
module_name='marketing',
data={}, # Empty data - module will fetch its own data
calendar_type=calendar_type,
locale=locale,
db=db,
user_id=ctx.user.id,
query_info=query_info,
selected_indices=indices
)
# Create Excel workbook
wb = Workbook()
ws = wb.active
ws.title = "Referrals"
# Define styles
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# Add headers
if excel_data:
headers = list(excel_data[0].keys())
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
# Add data rows
for row, data in enumerate(excel_data, 2):
for col, header in enumerate(headers, 1):
cell = ws.cell(row=row, column=col, value=data.get(header, ""))
cell.border = border
# Center align for numbers and dates
if header in ["ردیف", "Row", "تاریخ ثبت", "Registration Date"]:
cell.alignment = Alignment(horizontal="center")
# Auto-adjust column widths
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
# Save to BytesIO
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
# Generate filename
filename = f"referrals_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
# Return Excel file as response
return Response(
content=excel_buffer.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
}
)

View file

@ -0,0 +1,293 @@
# Removed __future__ annotations to fix OpenAPI schema generation
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.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
)
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": 1000000.0,
"total_purchases": 500000.0,
"active_members": 5,
"recent_transactions": 25
},
"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")
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
# دریافت اطلاعات کسب و کار
business = db.get(Business, business_id)
if not business:
from app.core.responses import ApiError
raise ApiError("NOT_FOUND", "Business not found", http_status=404)
# دریافت دسترسی‌های کاربر
permissions = {}
if not ctx.is_superadmin() and not ctx.is_business_owner(business_id):
# دریافت دسترسی‌های کسب و کار از 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_info = {
"id": business.id,
"name": business.name,
"business_type": business.business_type.value,
"business_field": business.business_field.value,
"owner_id": business.owner_id,
"address": business.address,
"phone": business.phone,
"mobile": business.mobile,
"created_at": business.created_at.isoformat(),
}
response_data = {
"business_info": business_info,
"user_permissions": permissions,
"is_owner": ctx.is_business_owner(business_id),
"role": "مالک" if ctx.is_business_owner(business_id) else "عضو",
"has_access": ctx.can_access_business(business_id)
}
formatted_data = format_datetime_fields(response_data, request)
return success_response(formatted_data, request)

View file

@ -0,0 +1,564 @@
# Removed __future__ annotations to fix OpenAPI schema generation
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 (
BusinessUsersListResponse, AddUserRequest, AddUserResponse,
UpdatePermissionsRequest, UpdatePermissionsResponse, RemoveUserResponse
)
from app.core.responses import success_response, format_datetime_fields
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
from adapters.db.models.user import User
from adapters.db.models.business import Business
router = APIRouter(prefix="/business", tags=["business-users"])
@router.get("/{business_id}/users/{user_id}",
summary="دریافت جزئیات کاربر",
description="دریافت جزئیات کاربر و دسترسی‌هایش در کسب و کار",
responses={
200: {
"description": "جزئیات کاربر با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "جزئیات کاربر دریافت شد",
"user": {
"id": 1,
"business_id": 1,
"user_id": 2,
"user_name": "علی احمدی",
"user_email": "ali@example.com",
"user_phone": "09123456789",
"role": "member",
"status": "active",
"added_at": "2024-01-01T00:00:00Z",
"last_active": "2024-01-01T12:00:00Z",
"permissions": {
"people": {
"add": True,
"view": True,
"edit": False,
"delete": False
}
}
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز به کسب و کار"
},
404: {
"description": "کاربر یافت نشد"
}
}
)
@require_business_access("business_id")
def get_user_details(
request: Request,
business_id: int,
user_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""دریافت جزئیات کاربر و دسترسی‌هایش"""
import logging
logger = logging.getLogger(__name__)
current_user_id = ctx.get_user_id()
logger.info(f"Getting user details for user {user_id} in business {business_id}, current user: {current_user_id}")
# Check if user is business owner or has permission to manage users
business = db.get(Business, business_id)
if not business:
logger.error(f"Business {business_id} not found")
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
is_owner = business.owner_id == current_user_id
can_manage = ctx.can_manage_business_users()
logger.info(f"Business owner: {business.owner_id}, is_owner: {is_owner}, can_manage: {can_manage}")
if not is_owner and not can_manage:
logger.warning(f"User {current_user_id} does not have permission to view user details for business {business_id}")
raise HTTPException(status_code=403, detail="شما مجوز مشاهده جزئیات کاربران ندارید")
# Get user details
user = db.get(User, user_id)
if not user:
logger.warning(f"User {user_id} not found")
raise HTTPException(status_code=404, detail="کاربر یافت نشد")
# Get user permissions for this business
permission_repo = BusinessPermissionRepository(db)
permission_obj = permission_repo.get_by_user_and_business(user_id, business_id)
# Determine role and permissions
if business.owner_id == user_id:
role = "owner"
permissions = {} # Owner has all permissions
else:
role = "member"
permissions = permission_obj.business_permissions if permission_obj else {}
# Format user data
user_data = {
"id": permission_obj.id if permission_obj else user_id,
"business_id": business_id,
"user_id": user_id,
"user_name": f"{user.first_name or ''} {user.last_name or ''}".strip(),
"user_email": user.email or "",
"user_phone": user.mobile,
"role": role,
"status": "active",
"added_at": permission_obj.created_at if permission_obj else business.created_at,
"last_active": permission_obj.updated_at if permission_obj else business.updated_at,
"permissions": permissions,
}
logger.info(f"Returning user data: {user_data}")
# Format datetime fields based on calendar type
formatted_user_data = format_datetime_fields(user_data, request)
return success_response(
data={"user": formatted_user_data},
request=request,
message="جزئیات کاربر دریافت شد"
)
@router.get("/{business_id}/users",
summary="لیست کاربران کسب و کار",
description="دریافت لیست کاربران یک کسب و کار",
response_model=BusinessUsersListResponse,
responses={
200: {
"description": "لیست کاربران با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "لیست کاربران دریافت شد",
"users": [
{
"id": 1,
"business_id": 1,
"user_id": 2,
"user_name": "علی احمدی",
"user_email": "ali@example.com",
"user_phone": "09123456789",
"role": "member",
"status": "active",
"added_at": "2024-01-01T00:00:00Z",
"last_active": "2024-01-01T12:00:00Z",
"permissions": {
"sales": {
"read": True,
"write": True,
"delete": False
},
"reports": {
"read": True,
"export": True
}
}
}
],
"total_count": 1
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز به کسب و کار"
}
}
)
@require_business_access("business_id")
def get_users(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""دریافت لیست کاربران کسب و کار"""
import logging
logger = logging.getLogger(__name__)
current_user_id = ctx.get_user_id()
logger.info(f"Getting users for business {business_id}, current user: {current_user_id}")
# Check if user is business owner or has permission to manage users
business = db.get(Business, business_id)
if not business:
logger.error(f"Business {business_id} not found")
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
is_owner = business.owner_id == current_user_id
can_manage = ctx.can_manage_business_users()
logger.info(f"Business owner: {business.owner_id}, is_owner: {is_owner}, can_manage: {can_manage}")
if not is_owner and not can_manage:
logger.warning(f"User {current_user_id} does not have permission to manage users for business {business_id}")
raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید")
# Get business permissions for this business
permission_repo = BusinessPermissionRepository(db)
business_permissions = permission_repo.get_business_users(business_id)
logger.info(f"Found {len(business_permissions)} business permissions for business {business_id}")
# Format users data
formatted_users = []
# Add business owner first
owner = db.get(User, business.owner_id)
if owner:
logger.info(f"Adding business owner: {owner.id} - {owner.email}")
owner_data = {
"id": business.owner_id, # Use owner_id as id
"business_id": business_id,
"user_id": business.owner_id,
"user_name": f"{owner.first_name or ''} {owner.last_name or ''}".strip(),
"user_email": owner.email or "",
"user_phone": owner.mobile,
"role": "owner",
"status": "active",
"added_at": business.created_at,
"last_active": business.updated_at,
"permissions": {}, # Owner has all permissions
}
formatted_users.append(owner_data)
else:
logger.warning(f"Business owner {business.owner_id} not found in users table")
# Add other users with permissions
for perm in business_permissions:
# Skip if this is the owner (already added)
if perm.user_id == business.owner_id:
logger.info(f"Skipping owner user {perm.user_id} as already added")
continue
user = db.get(User, perm.user_id)
if user:
logger.info(f"Adding user with permissions: {user.id} - {user.email}")
user_data = {
"id": perm.id,
"business_id": perm.business_id,
"user_id": perm.user_id,
"user_name": f"{user.first_name or ''} {user.last_name or ''}".strip(),
"user_email": user.email or "",
"user_phone": user.mobile,
"role": "member",
"status": "active",
"added_at": perm.created_at,
"last_active": perm.updated_at,
"permissions": perm.business_permissions or {},
}
formatted_users.append(user_data)
else:
logger.warning(f"User {perm.user_id} not found in users table")
logger.info(f"Returning {len(formatted_users)} users for business {business_id}")
# Format datetime fields based on calendar type
formatted_users = format_datetime_fields(formatted_users, request)
return success_response(
data={
"users": formatted_users,
"total_count": len(formatted_users)
},
request=request,
message="لیست کاربران دریافت شد"
)
@router.post("/{business_id}/users",
summary="افزودن کاربر به کسب و کار",
description="افزودن کاربر جدید به کسب و کار با ایمیل یا شماره تلفن",
response_model=AddUserResponse,
responses={
200: {
"description": "کاربر با موفقیت اضافه شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کاربر با موفقیت اضافه شد",
"user": {
"id": 1,
"business_id": 1,
"user_id": 2,
"user_name": "علی احمدی",
"user_email": "ali@example.com",
"user_phone": "09123456789",
"role": "member",
"status": "active",
"added_at": "2024-01-01T00:00:00Z",
"last_active": None,
"permissions": {}
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها"
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز یا مجوز کافی نیست"
},
404: {
"description": "کاربر یافت نشد"
}
}
)
@require_business_access("business_id")
def add_user(
request: Request,
business_id: int,
add_request: AddUserRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""افزودن کاربر به کسب و کار"""
import logging
logger = logging.getLogger(__name__)
current_user_id = ctx.get_user_id()
logger.info(f"Adding user to business {business_id}, current user: {current_user_id}")
logger.info(f"Add request: {add_request.email_or_phone}")
# Check if user is business owner or has permission to manage users
business = db.get(Business, business_id)
if not business:
logger.error(f"Business {business_id} not found")
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
is_owner = business.owner_id == current_user_id
can_manage = ctx.can_manage_business_users(business_id)
logger.info(f"Business owner: {business.owner_id}, is_owner: {is_owner}, can_manage: {can_manage}")
logger.info(f"User {current_user_id} business_id from context: {ctx.business_id}")
logger.info(f"User {current_user_id} is superadmin: {ctx.is_superadmin()}")
if not is_owner and not can_manage:
logger.warning(f"User {current_user_id} does not have permission to add users to business {business_id}")
raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید")
# Find user by email or phone
logger.info(f"Searching for user with email/phone: {add_request.email_or_phone}")
user = db.query(User).filter(
(User.email == add_request.email_or_phone) |
(User.mobile == add_request.email_or_phone)
).first()
if not user:
logger.warning(f"User not found with email/phone: {add_request.email_or_phone}")
raise HTTPException(status_code=404, detail="کاربر یافت نشد")
logger.info(f"Found user: {user.id} - {user.email}")
# Check if user is already added to this business
permission_repo = BusinessPermissionRepository(db)
existing_permission = permission_repo.get_by_user_and_business(user.id, business_id)
if existing_permission:
logger.warning(f"User {user.id} already exists in business {business_id}")
raise HTTPException(status_code=400, detail="کاربر قبلاً به این کسب و کار اضافه شده است")
# Add user to business with default permissions
logger.info(f"Adding user {user.id} to business {business_id}")
permission_obj = permission_repo.create_or_update(
user_id=user.id,
business_id=business_id,
permissions={'join': True} # Default permissions with join access
)
logger.info(f"Created permission object: {permission_obj.id}")
# Format user data
user_data = {
"id": permission_obj.id,
"business_id": permission_obj.business_id,
"user_id": permission_obj.user_id,
"user_name": f"{user.first_name or ''} {user.last_name or ''}".strip(),
"user_email": user.email or "",
"user_phone": user.mobile,
"role": "member",
"status": "active",
"added_at": permission_obj.created_at,
"last_active": None,
"permissions": permission_obj.business_permissions or {},
}
logger.info(f"Returning user data: {user_data}")
# Format datetime fields based on calendar type
formatted_user_data = format_datetime_fields(user_data, request)
return success_response(
data={"user": formatted_user_data},
request=request,
message="کاربر با موفقیت اضافه شد"
)
@router.put("/{business_id}/users/{user_id}/permissions",
summary="به‌روزرسانی دسترسی‌های کاربر",
description="به‌روزرسانی دسترسی‌های یک کاربر در کسب و کار",
response_model=UpdatePermissionsResponse,
responses={
200: {
"description": "دسترسی‌ها با موفقیت به‌روزرسانی شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "دسترسی‌ها با موفقیت به‌روزرسانی شد"
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها"
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز یا مجوز کافی نیست"
},
404: {
"description": "کاربر یافت نشد"
}
}
)
@require_business_access("business_id")
def update_permissions(
request: Request,
business_id: int,
user_id: int,
update_request: UpdatePermissionsRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""به‌روزرسانی دسترسی‌های کاربر"""
current_user_id = ctx.get_user_id()
# Check if user is business owner or has permission to manage users
business = db.get(Business, business_id)
if not business:
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
is_owner = business.owner_id == current_user_id
can_manage = ctx.can_manage_business_users()
if not is_owner and not can_manage:
raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید")
# Check if target user exists
target_user = db.get(User, user_id)
if not target_user:
raise HTTPException(status_code=404, detail="کاربر یافت نشد")
# Update permissions
permission_repo = BusinessPermissionRepository(db)
permission_obj = permission_repo.create_or_update(
user_id=user_id,
business_id=business_id,
permissions=update_request.permissions
)
return success_response(
data={},
request=request,
message="دسترسی‌ها با موفقیت به‌روزرسانی شد"
)
@router.delete("/{business_id}/users/{user_id}",
summary="حذف کاربر از کسب و کار",
description="حذف کاربر از کسب و کار",
response_model=RemoveUserResponse,
responses={
200: {
"description": "کاربر با موفقیت حذف شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کاربر با موفقیت حذف شد"
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز یا مجوز کافی نیست"
},
404: {
"description": "کاربر یافت نشد"
}
}
)
@require_business_access("business_id")
def remove_user(
request: Request,
business_id: int,
user_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""حذف کاربر از کسب و کار"""
current_user_id = ctx.get_user_id()
# Check if user is business owner or has permission to manage users
business = db.get(Business, business_id)
if not business:
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
is_owner = business.owner_id == current_user_id
can_manage = ctx.can_manage_business_users()
if not is_owner and not can_manage:
raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید")
# Check if target user is business owner
business = db.get(Business, business_id)
if business and business.owner_id == user_id:
raise HTTPException(status_code=400, detail="نمی‌توان مالک کسب و کار را حذف کرد")
# Remove user permissions
permission_repo = BusinessPermissionRepository(db)
success = permission_repo.delete_by_user_and_business(user_id, business_id)
if not success:
raise HTTPException(status_code=404, detail="کاربر یافت نشد")
return success_response(
data={},
request=request,
message="کاربر با موفقیت حذف شد"
)

View file

@ -0,0 +1,320 @@
# Removed __future__ annotations to fix OpenAPI schema generation
from fastapi import APIRouter, Depends, Request, Query, HTTPException
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.api.v1.schemas import (
BusinessCreateRequest, BusinessUpdateRequest, BusinessResponse,
BusinessListResponse, BusinessSummaryResponse, SuccessResponse
)
from app.core.responses import success_response, format_datetime_fields
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_management
from app.services.business_service import (
create_business, get_business_by_id, get_businesses_by_owner, get_user_businesses,
update_business, delete_business, get_business_summary
)
router = APIRouter(prefix="/businesses", tags=["businesses"])
@router.post("",
summary="ایجاد کسب و کار جدید",
description="ایجاد کسب و کار جدید برای کاربر جاری",
response_model=SuccessResponse,
responses={
200: {
"description": "کسب و کار با موفقیت ایجاد شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کسب و کار با موفقیت ایجاد شد",
"data": {
"id": 1,
"name": "شرکت نمونه",
"business_type": "شرکت",
"business_field": "تولیدی",
"owner_id": 1,
"created_at": "2024-01-01T00:00:00Z"
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها"
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def create_new_business(
request: Request,
business_data: BusinessCreateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""ایجاد کسب و کار جدید"""
owner_id = ctx.get_user_id()
business = create_business(db, business_data, owner_id)
formatted_data = format_datetime_fields(business, request)
return success_response(formatted_data, request)
@router.post("/list",
summary="لیست کسب و کارهای کاربر",
description="دریافت لیست کسب و کارهای کاربر جاری با قابلیت فیلتر و جستجو",
response_model=SuccessResponse,
responses={
200: {
"description": "لیست کسب و کارها با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "لیست کسب و کارها دریافت شد",
"data": {
"items": [
{
"id": 1,
"name": "شرکت نمونه",
"business_type": "شرکت",
"business_field": "تولیدی",
"owner_id": 1,
"created_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": "کاربر احراز هویت نشده است"
}
}
)
def list_user_businesses(
request: Request,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
take: int = 10,
skip: int = 0,
sort_by: str = "created_at",
sort_desc: bool = True,
search: str = None
) -> dict:
"""لیست کسب و کارهای کاربر (مالک + عضو)"""
user_id = ctx.get_user_id()
query_dict = {
"take": take,
"skip": skip,
"sort_by": sort_by,
"sort_desc": sort_desc,
"search": search
}
businesses = get_user_businesses(db, user_id, query_dict)
formatted_data = format_datetime_fields(businesses, request)
return success_response(formatted_data, request)
@router.post("/{business_id}/details",
summary="جزئیات کسب و کار",
description="دریافت جزئیات یک کسب و کار خاص",
response_model=SuccessResponse,
responses={
200: {
"description": "جزئیات کسب و کار با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "جزئیات کسب و کار دریافت شد",
"data": {
"id": 1,
"name": "شرکت نمونه",
"business_type": "شرکت",
"business_field": "تولیدی",
"owner_id": 1,
"address": "تهران، خیابان ولیعصر",
"phone": "02112345678",
"created_at": "1403/01/01 00:00:00"
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
404: {
"description": "کسب و کار یافت نشد"
}
}
)
def get_business(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""دریافت جزئیات کسب و کار"""
owner_id = ctx.get_user_id()
business = get_business_by_id(db, business_id, owner_id)
if not business:
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
formatted_data = format_datetime_fields(business, request)
return success_response(formatted_data, request)
@router.put("/{business_id}",
summary="ویرایش کسب و کار",
description="ویرایش اطلاعات یک کسب و کار",
response_model=SuccessResponse,
responses={
200: {
"description": "کسب و کار با موفقیت ویرایش شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کسب و کار با موفقیت ویرایش شد",
"data": {
"id": 1,
"name": "شرکت نمونه ویرایش شده",
"business_type": "شرکت",
"business_field": "تولیدی",
"owner_id": 1,
"updated_at": "2024-01-01T12:00:00Z"
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها"
},
401: {
"description": "کاربر احراز هویت نشده است"
},
404: {
"description": "کسب و کار یافت نشد"
}
}
)
def update_business_info(
request: Request,
business_id: int,
business_data: BusinessUpdateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""ویرایش کسب و کار"""
owner_id = ctx.get_user_id()
business = update_business(db, business_id, business_data, owner_id)
if not business:
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
formatted_data = format_datetime_fields(business, request)
return success_response(formatted_data, request, "کسب و کار با موفقیت ویرایش شد")
@router.delete("/{business_id}",
summary="حذف کسب و کار",
description="حذف یک کسب و کار",
response_model=SuccessResponse,
responses={
200: {
"description": "کسب و کار با موفقیت حذف شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "کسب و کار با موفقیت حذف شد",
"data": {"ok": True}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
404: {
"description": "کسب و کار یافت نشد"
}
}
)
def delete_business_info(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""حذف کسب و کار"""
owner_id = ctx.get_user_id()
success = delete_business(db, business_id, owner_id)
if not success:
raise HTTPException(status_code=404, detail="کسب و کار یافت نشد")
return success_response({"ok": True}, request, "کسب و کار با موفقیت حذف شد")
@router.post("/stats",
summary="آمار کسب و کارها",
description="دریافت آمار کلی کسب و کارهای کاربر",
response_model=SuccessResponse,
responses={
200: {
"description": "آمار کسب و کارها با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "آمار کسب و کارها دریافت شد",
"data": {
"total_businesses": 5,
"by_type": {
"شرکت": 2,
"مغازه": 1,
"فروشگاه": 2
},
"by_field": {
"تولیدی": 3,
"خدماتی": 2
}
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
}
}
)
def get_business_stats(
request: Request,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""آمار کسب و کارها"""
owner_id = ctx.get_user_id()
stats = get_business_summary(db, owner_id)
return success_response(stats, request)

View file

@ -0,0 +1,148 @@
from typing import Any, Dict
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from app.core.responses import success_response, ApiError
from adapters.db.repositories.category_repository import CategoryRepository
router = APIRouter(prefix="/categories", tags=["categories"])
@router.post("/business/{business_id}/tree")
@require_business_access("business_id")
def get_categories_tree(
request: Request,
business_id: int,
body: Dict[str, Any] | None = None,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
# اجازه مشاهده نیاز به view روی سکشن categories دارد
if not ctx.can_read_section("categories"):
raise ApiError("FORBIDDEN", "Missing business permission: categories.view", http_status=403)
repo = CategoryRepository(db)
# درخت سراسری: بدون فیلتر نوع
tree = repo.get_tree(business_id, None)
# تبدیل کلید title به label به صورت بازگشتی
def _map_label(nodes: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
mapped: list[Dict[str, Any]] = []
for n in nodes:
children = n.get("children") or []
mapped.append({
"id": n.get("id"),
"parent_id": n.get("parent_id"),
"label": n.get("title", ""),
"translations": n.get("translations", {}),
"children": _map_label(children) if isinstance(children, list) else [],
})
return mapped
items = _map_label(tree)
return success_response({"items": items}, request)
@router.post("/business/{business_id}")
@require_business_access("business_id")
def create_category(
request: Request,
business_id: int,
body: Dict[str, Any],
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("categories", "add"):
raise ApiError("FORBIDDEN", "Missing business permission: categories.add", http_status=403)
parent_id = body.get("parent_id")
label: str = (body.get("label") or "").strip()
# ساخت ترجمه‌ها از روی برچسب واحد
translations: Dict[str, str] = {"fa": label, "en": label} if label else {}
repo = CategoryRepository(db)
obj = repo.create_category(business_id=business_id, parent_id=parent_id, translations=translations)
item = {
"id": obj.id,
"parent_id": obj.parent_id,
"label": (obj.title_translations or {}).get(ctx.language)
or (obj.title_translations or {}).get("fa")
or (obj.title_translations or {}).get("en"),
"translations": obj.title_translations,
}
return success_response({"item": item}, request)
@router.post("/business/{business_id}/update")
@require_business_access("business_id")
def update_category(
request: Request,
business_id: int,
body: Dict[str, Any],
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("categories", "edit"):
raise ApiError("FORBIDDEN", "Missing business permission: categories.edit", http_status=403)
category_id = body.get("category_id")
label = body.get("label")
translations = {"fa": label, "en": label} if isinstance(label, str) and label.strip() else None
repo = CategoryRepository(db)
obj = repo.update_category(category_id=category_id, translations=translations)
if not obj:
raise ApiError("NOT_FOUND", "Category not found", http_status=404)
item = {
"id": obj.id,
"parent_id": obj.parent_id,
"label": (obj.title_translations or {}).get(ctx.language)
or (obj.title_translations or {}).get("fa")
or (obj.title_translations or {}).get("en"),
"translations": obj.title_translations,
}
return success_response({"item": item}, request)
@router.post("/business/{business_id}/move")
@require_business_access("business_id")
def move_category(
request: Request,
business_id: int,
body: Dict[str, Any],
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("categories", "edit"):
raise ApiError("FORBIDDEN", "Missing business permission: categories.edit", http_status=403)
category_id = body.get("category_id")
new_parent_id = body.get("new_parent_id")
repo = CategoryRepository(db)
obj = repo.move_category(category_id=category_id, new_parent_id=new_parent_id)
if not obj:
raise ApiError("NOT_FOUND", "Category not found", http_status=404)
item = {
"id": obj.id,
"parent_id": obj.parent_id,
"label": (obj.title_translations or {}).get(ctx.language)
or (obj.title_translations or {}).get("fa")
or (obj.title_translations or {}).get("en"),
"translations": obj.title_translations,
}
return success_response({"item": item}, request)
@router.post("/business/{business_id}/delete")
@require_business_access("business_id")
def delete_category(
request: Request,
business_id: int,
body: Dict[str, Any],
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("categories", "delete"):
raise ApiError("FORBIDDEN", "Missing business permission: categories.delete", http_status=403)
repo = CategoryRepository(db)
category_id = body.get("category_id")
ok = repo.delete_category(category_id=category_id)
return success_response({"deleted": ok}, request)

View file

@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.models.currency import Currency
from app.core.responses import success_response
router = APIRouter(prefix="/currencies", tags=["currencies"])
@router.get(
"",
summary="فهرست ارزها",
description="دریافت فهرست ارزهای قابل استفاده",
)
def list_currencies(request: Request, db: Session = Depends(get_db)) -> dict:
items = [
{
"id": c.id,
"name": c.name,
"title": c.title,
"symbol": c.symbol,
"code": c.code,
}
for c in db.query(Currency).order_by(Currency.title.asc()).all()
]
return success_response(items, request)

View file

@ -0,0 +1,30 @@
from fastapi import APIRouter
from adapters.api.v1.schemas import SuccessResponse
router = APIRouter(prefix="/health", tags=["health"])
@router.get("",
summary="بررسی وضعیت سرویس",
description="بررسی وضعیت کلی سرویس و در دسترس بودن آن",
response_model=SuccessResponse,
responses={
200: {
"description": "سرویس در دسترس است",
"content": {
"application/json": {
"example": {
"success": True,
"message": "سرویس در دسترس است",
"data": {
"status": "ok",
"timestamp": "2024-01-01T00:00:00Z"
}
}
}
}
}
}
)
def health() -> dict[str, str]:
return {"status": "ok"}

View file

@ -0,0 +1,945 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Body, Form
from fastapi import UploadFile, File
from sqlalchemy.orm import Session
from typing import Dict, Any, List, Optional
from adapters.db.session import get_db
from adapters.api.v1.schema_models.person import (
PersonCreateRequest, PersonUpdateRequest, PersonResponse,
PersonListResponse, PersonSummaryResponse, PersonBankAccountCreateRequest
)
from adapters.api.v1.schemas import QueryInfo, SuccessResponse
from app.core.responses import success_response, format_datetime_fields
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_management_dep
from app.core.i18n import negotiate_locale
from app.services.person_service import (
create_person, get_person_by_id, get_persons_by_business,
update_person, delete_person, get_person_summary
)
from adapters.db.models.person import Person
from adapters.db.models.business import Business
router = APIRouter(prefix="/persons", tags=["persons"])
@router.post("/businesses/{business_id}/persons/create",
summary="ایجاد شخص جدید",
description="ایجاد شخص جدید برای کسب و کار مشخص",
response_model=SuccessResponse,
responses={
200: {
"description": "شخص با موفقیت ایجاد شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "شخص با موفقیت ایجاد شد",
"data": {
"id": 1,
"business_id": 1,
"alias_name": "علی احمدی",
"person_type": "مشتری",
"created_at": "2024-01-01T00:00:00Z"
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها"
},
401: {
"description": "عدم احراز هویت"
},
403: {
"description": "عدم دسترسی به کسب و کار"
}
}
)
async def create_person_endpoint(
request: Request,
business_id: int,
person_data: PersonCreateRequest,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""ایجاد شخص جدید برای کسب و کار"""
result = create_person(db, business_id, person_data)
return success_response(
data=format_datetime_fields(result['data'], request),
request=request,
message=result['message'],
)
@router.post("/businesses/{business_id}/persons",
summary="لیست اشخاص کسب و کار",
description="دریافت لیست اشخاص یک کسب و کار با امکان جستجو و فیلتر",
response_model=SuccessResponse,
responses={
200: {
"description": "لیست اشخاص با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "لیست اشخاص با موفقیت دریافت شد",
"data": {
"items": [],
"pagination": {
"total": 0,
"page": 1,
"per_page": 20,
"total_pages": 0,
"has_next": False,
"has_prev": False
},
"query_info": {}
}
}
}
}
}
}
)
async def get_persons_endpoint(
request: Request,
business_id: int,
query_info: QueryInfo,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
):
"""دریافت لیست اشخاص کسب و کار"""
query_dict = {
"take": query_info.take,
"skip": query_info.skip,
"sort_by": query_info.sort_by,
"sort_desc": query_info.sort_desc,
"search": query_info.search,
"search_fields": query_info.search_fields,
"filters": query_info.filters,
}
result = get_persons_by_business(db, business_id, query_dict)
# فرمت کردن تاریخ‌ها
result['items'] = [
format_datetime_fields(item, request) for item in result['items']
]
return success_response(
data=result,
request=request,
message="لیست اشخاص با موفقیت دریافت شد",
)
@router.post("/businesses/{business_id}/persons/export/excel",
summary="خروجی Excel لیست اشخاص",
description="خروجی Excel لیست اشخاص با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
async def export_persons_excel(
business_id: int,
request: Request,
body: Dict[str, Any] = Body(...),
auth_context: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
import io
import json
import datetime
import re
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from fastapi.responses import Response
# Build query dict similar to list endpoint from flat body
query_dict = {
"take": int(body.get("take", 20)),
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by"),
"sort_desc": bool(body.get("sort_desc", False)),
"search": body.get("search"),
"search_fields": body.get("search_fields"),
"filters": body.get("filters"),
}
result = get_persons_by_business(db, business_id, query_dict)
items = result.get('items', [])
# Format date/time fields using existing helper
items = [format_datetime_fields(item, request) for item in items]
# Apply selected indices filter if requested
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
# Prepare headers based on export_columns (order + visibility)
headers: List[str] = []
keys: List[str] = []
export_columns = body.get('export_columns')
if export_columns:
for col in export_columns:
key = col.get('key')
label = col.get('label', key)
if key:
keys.append(str(key))
headers.append(str(label))
else:
# Fallback to item keys if no columns provided
if items:
keys = list(items[0].keys())
headers = keys
# Create workbook
wb = Workbook()
ws = wb.active
ws.title = "Persons"
# Locale and RTL/LTR handling
locale = negotiate_locale(request.headers.get("Accept-Language"))
if locale == 'fa':
try:
ws.sheet_view.rightToLeft = True
except Exception:
pass
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
# Write header row
for col_idx, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_idx, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
# Write data rows
for row_idx, item in enumerate(items, 2):
for col_idx, key in enumerate(keys, 1):
value = item.get(key, "")
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
cell = ws.cell(row=row_idx, column=col_idx, value=value)
cell.border = border
if locale == 'fa':
cell.alignment = Alignment(horizontal="right")
# Auto-width columns
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
# Save to bytes
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
# Build meaningful filename
biz_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
biz_name = b.name or ""
except Exception:
biz_name = ""
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "persons"
if biz_name:
base += f"_{slugify(biz_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
content = buffer.getvalue()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(content)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post("/businesses/{business_id}/persons/export/pdf",
summary="خروجی PDF لیست اشخاص",
description="خروجی PDF لیست اشخاص با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
async def export_persons_pdf(
business_id: int,
request: Request,
body: Dict[str, Any] = Body(...),
auth_context: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
import json
import datetime
import re
from fastapi.responses import Response
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
# Build query dict from flat body
query_dict = {
"take": int(body.get("take", 20)),
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by"),
"sort_desc": bool(body.get("sort_desc", False)),
"search": body.get("search"),
"search_fields": body.get("search_fields"),
"filters": body.get("filters"),
}
result = get_persons_by_business(db, business_id, query_dict)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
headers: List[str] = []
keys: List[str] = []
export_columns = body.get('export_columns')
if export_columns:
for col in export_columns:
key = col.get('key')
label = col.get('label', key)
if key:
keys.append(str(key))
headers.append(str(label))
else:
if items:
keys = list(items[0].keys())
headers = keys
# Load business info for header
business_name = ""
try:
biz = db.query(Business).filter(Business.id == business_id).first()
if biz is not None:
business_name = biz.name
except Exception:
business_name = ""
# Styled HTML with dynamic direction/locale
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = (locale == 'fa')
html_lang = 'fa' if is_fa else 'en'
html_dir = 'rtl' if is_fa else 'ltr'
def escape(s: Any) -> str:
try:
return str(s).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
except Exception:
return str(s)
rows_html = []
for item in items:
tds = []
for key in keys:
value = item.get(key)
if value is None:
value = ""
elif isinstance(value, list):
value = ", ".join(str(v) for v in value)
tds.append(f"<td>{escape(value)}</td>")
rows_html.append(f"<tr>{''.join(tds)}</tr>")
headers_html = ''.join(f"<th>{escape(h)}</th>" for h in headers)
# Format report datetime based on X-Calendar-Type header
calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
try:
from app.core.calendar import CalendarConverter
formatted_now = CalendarConverter.format_datetime(datetime.datetime.now(),
"jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian")
now = formatted_now.get('formatted', formatted_now.get('date_time', ''))
except Exception:
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
title_text = "گزارش لیست اشخاص" if is_fa else "Persons List Report"
label_biz = "نام کسب‌وکار" if is_fa else "Business Name"
label_date = "تاریخ گزارش" if is_fa else "Report Date"
footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix"
page_label_left = "صفحه " if is_fa else "Page "
page_label_of = " از " if is_fa else " of "
table_html = f"""
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
<head>
<meta charset='utf-8'>
<style>
@page {{
size: A4 landscape;
margin: 12mm;
@bottom-{ 'left' if is_fa else 'right' } {{
content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages);
font-size: 10px;
color: #666;
}}
}}
body {{
font-family: sans-serif;
font-size: 11px;
color: #222;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 2px solid #444;
padding-bottom: 6px;
}}
.title {{
font-size: 16px;
font-weight: 700;
}}
.meta {{
font-size: 11px;
color: #555;
}}
.table-wrapper {{
width: 100%;
}}
table.report-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}}
thead th {{
background: #f0f3f7;
border: 1px solid #c7cdd6;
padding: 6px 4px;
text-align: center;
font-weight: 700;
white-space: nowrap;
}}
tbody td {{
border: 1px solid #d7dde6;
padding: 5px 4px;
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}}
.footer {{
position: running(footer);
font-size: 10px;
color: #666;
margin-top: 8px;
text-align: {'left' if is_fa else 'right'};
}}
</style>
</head>
<body>
<div class=\"header\">
<div>
<div class=\"title\">{title_text}</div>
<div class=\"meta\">{label_biz}: {escape(business_name)}</div>
</div>
<div class=\"meta\">{label_date}: {escape(now)}</div>
</div>
<div class=\"table-wrapper\">
<table class=\"report-table\">
<thead>
<tr>{headers_html}</tr>
</thead>
<tbody>
{''.join(rows_html)}
</tbody>
</table>
</div>
<div class=\"footer\">{footer_text}</div>
</body>
</html>
"""
font_config = FontConfiguration()
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
# Build meaningful filename
biz_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
biz_name = b.name or ""
except Exception:
biz_name = ""
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "persons"
if biz_name:
base += f"_{slugify(biz_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post("/businesses/{business_id}/persons/import/template",
summary="دانلود تمپلیت ایمپورت اشخاص",
description="فایل Excel تمپلیت برای ایمپورت اشخاص را برمی‌گرداند",
)
async def download_persons_import_template(
business_id: int,
request: Request,
auth_context: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
import io
import datetime
from fastapi.responses import Response
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment
wb = Workbook()
ws = wb.active
ws.title = "Template"
headers = [
'code','alias_name','first_name','last_name','person_type','person_types','company_name','payment_id',
'national_id','registration_number','economic_id','country','province','city','address','postal_code',
'phone','mobile','fax','email','website','share_count','commission_sale_percent','commission_sales_return_percent',
'commission_sales_amount','commission_sales_return_amount'
]
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal="center")
# Sample row
sample = [
'', 'نمونه نام مستعار', 'علی', 'احمدی', 'مشتری', 'مشتری, فروشنده', 'نمونه شرکت', 'PID123',
'0012345678', '12345', 'ECO-1', 'ایران', 'تهران', 'تهران', 'خیابان مثال ۱', '1234567890',
'02112345678', '09120000000', '', 'test@example.com', 'example.com', '', '5', '0', '0', '0'
]
for col, val in enumerate(sample, 1):
ws.cell(row=2, column=col, value=val)
# Auto width
for column in ws.columns:
try:
letter = column[0].column_letter
max_len = max(len(str(c.value)) if c.value is not None else 0 for c in column)
ws.column_dimensions[letter].width = min(max_len + 2, 50)
except Exception:
pass
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
filename = f"persons_import_template_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return Response(
content=buf.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.get("/persons/{person_id}",
summary="جزئیات شخص",
description="دریافت جزئیات یک شخص",
response_model=SuccessResponse,
responses={
200: {
"description": "جزئیات شخص با موفقیت دریافت شد"
},
404: {
"description": "شخص یافت نشد"
}
}
)
async def get_person_endpoint(
request: Request,
person_id: int,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""دریافت جزئیات شخص"""
# ابتدا باید business_id را از person دریافت کنیم
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(status_code=404, detail="شخص یافت نشد")
result = get_person_by_id(db, person_id, person.business_id)
if not result:
raise HTTPException(status_code=404, detail="شخص یافت نشد")
return success_response(
data=format_datetime_fields(result, request),
request=request,
message="جزئیات شخص با موفقیت دریافت شد",
)
@router.put("/persons/{person_id}",
summary="ویرایش شخص",
description="ویرایش اطلاعات یک شخص",
response_model=SuccessResponse,
responses={
200: {
"description": "شخص با موفقیت ویرایش شد"
},
404: {
"description": "شخص یافت نشد"
}
}
)
async def update_person_endpoint(
request: Request,
person_id: int,
person_data: PersonUpdateRequest,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""ویرایش شخص"""
# ابتدا باید business_id را از person دریافت کنیم
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(status_code=404, detail="شخص یافت نشد")
result = update_person(db, person_id, person.business_id, person_data)
if not result:
raise HTTPException(status_code=404, detail="شخص یافت نشد")
return success_response(
data=format_datetime_fields(result['data'], request),
request=request,
message=result['message'],
)
@router.delete("/persons/{person_id}",
summary="حذف شخص",
description="حذف یک شخص",
response_model=SuccessResponse,
responses={
200: {
"description": "شخص با موفقیت حذف شد"
},
404: {
"description": "شخص یافت نشد"
}
}
)
async def delete_person_endpoint(
request: Request,
person_id: int,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""حذف شخص"""
# ابتدا باید business_id را از person دریافت کنیم
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(status_code=404, detail="شخص یافت نشد")
success = delete_person(db, person_id, person.business_id)
if not success:
raise HTTPException(status_code=404, detail="شخص یافت نشد")
return success_response(message="شخص با موفقیت حذف شد", request=request)
@router.get("/businesses/{business_id}/persons/summary",
summary="خلاصه اشخاص کسب و کار",
description="دریافت خلاصه آماری اشخاص یک کسب و کار",
response_model=SuccessResponse,
responses={
200: {
"description": "خلاصه اشخاص با موفقیت دریافت شد"
}
}
)
async def get_persons_summary_endpoint(
request: Request,
business_id: int,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""دریافت خلاصه اشخاص کسب و کار"""
result = get_person_summary(db, business_id)
return success_response(
data=result,
request=request,
message="خلاصه اشخاص با موفقیت دریافت شد",
)
@router.post("/businesses/{business_id}/persons/import/excel",
summary="ایمپورت اشخاص از فایل Excel",
description="فایل اکسل را دریافت می‌کند و به‌صورت dry-run یا واقعی پردازش می‌کند",
)
async def import_persons_excel(
business_id: int,
request: Request,
file: UploadFile = File(...),
dry_run: str = Form(default="true"),
match_by: str = Form(default="code"),
conflict_policy: str = Form(default="upsert"),
auth_context: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
import io
import json
import re
from openpyxl import load_workbook
from fastapi import HTTPException
import logging
import zipfile
logger = logging.getLogger(__name__)
def validate_excel_file(content: bytes) -> bool:
"""
Validate if the content is a valid Excel file
"""
try:
# Check if it starts with PK signature (zip file)
if not content.startswith(b'PK'):
return False
# Try to open as zip file
with zipfile.ZipFile(io.BytesIO(content), 'r') as zip_file:
file_list = zip_file.namelist()
# Check for Excel structure (xl/ folder for .xlsx files)
excel_structure = any(f.startswith('xl/') for f in file_list)
if excel_structure:
return True
# Check for older Excel format (.xls) - this would be a different structure
# But since we only support .xlsx, we'll return False for .xls
return False
except zipfile.BadZipFile:
logger.error("File is not a valid zip file")
return False
except Exception as e:
logger.error(f"Error validating Excel file: {str(e)}")
return False
try:
# Convert dry_run string to boolean
dry_run_bool = dry_run.lower() in ('true', '1', 'yes', 'on')
logger.info(f"Import request: business_id={business_id}, dry_run={dry_run_bool}, match_by={match_by}, conflict_policy={conflict_policy}")
logger.info(f"File info: filename={file.filename}, content_type={file.content_type}")
if not file.filename or not file.filename.lower().endswith('.xlsx'):
logger.error(f"Invalid file format: {file.filename}")
raise HTTPException(status_code=400, detail="فرمت فایل معتبر نیست. تنها xlsx پشتیبانی می‌شود")
content = await file.read()
logger.info(f"File content size: {len(content)} bytes")
# Log first few bytes for debugging
logger.info(f"File header (first 20 bytes): {content[:20].hex()}")
logger.info(f"File header (first 20 bytes as text): {content[:20]}")
# Check if content is empty or too small
if len(content) < 100:
logger.error(f"File too small: {len(content)} bytes")
raise HTTPException(status_code=400, detail="فایل خیلی کوچک است یا خالی است")
# Validate Excel file format
if not validate_excel_file(content):
logger.error("File is not a valid Excel file")
raise HTTPException(status_code=400, detail="فرمت فایل معتبر نیست. فایل Excel معتبر نیست")
try:
# Try to load the workbook with additional error handling
wb = load_workbook(filename=io.BytesIO(content), data_only=True)
logger.info(f"Successfully loaded workbook with {len(wb.worksheets)} worksheets")
except zipfile.BadZipFile as e:
logger.error(f"Bad zip file error: {str(e)}")
raise HTTPException(status_code=400, detail="فایل Excel خراب است یا فرمت آن معتبر نیست")
except Exception as e:
logger.error(f"Error loading workbook: {str(e)}")
raise HTTPException(status_code=400, detail=f"امکان خواندن فایل وجود ندارد: {str(e)}")
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
return success_response(data={"summary": {"total": 0}}, request=request, message="فایل خالی است")
headers = [str(h).strip() if h is not None else "" for h in rows[0]]
data_rows = rows[1:]
# helper to map enum strings (fa/en) to internal value
def normalize_person_type(value: str) -> Optional[str]:
if not value:
return None
value = str(value).strip()
mapping = {
'customer': 'مشتری', 'marketer': 'بازاریاب', 'employee': 'کارمند', 'supplier': 'تامین‌کننده',
'partner': 'همکار', 'seller': 'فروشنده', 'shareholder': 'سهامدار'
}
for en, fa in mapping.items():
if value.lower() == en or value == fa:
return fa
return value # assume already fa
errors: list[dict] = []
valid_items: list[dict] = []
for idx, row in enumerate(data_rows, start=2):
item: dict[str, Any] = {}
row_errors: list[str] = []
for ci, key in enumerate(headers):
if not key:
continue
val = row[ci] if ci < len(row) else None
if isinstance(val, str):
val = val.strip()
item[key] = val
# normalize types
if 'person_type' in item and item['person_type']:
item['person_type'] = normalize_person_type(item['person_type'])
if 'person_types' in item and item['person_types']:
# split by comma
parts = [normalize_person_type(p.strip()) for p in str(item['person_types']).split(',') if str(p).strip()]
item['person_types'] = parts
# alias_name required
if not item.get('alias_name'):
row_errors.append('alias_name الزامی است')
# shareholder rule
if (item.get('person_type') == 'سهامدار') or (isinstance(item.get('person_types'), list) and 'سهامدار' in item.get('person_types', [])):
sc = item.get('share_count')
try:
sc_val = int(sc) if sc is not None and str(sc).strip() != '' else None
except Exception:
sc_val = None
if sc_val is None or sc_val <= 0:
row_errors.append('برای سهامدار share_count باید > 0 باشد')
else:
item['share_count'] = sc_val
if row_errors:
errors.append({"row": idx, "errors": row_errors})
continue
valid_items.append(item)
inserted = 0
updated = 0
skipped = 0
if not dry_run_bool and valid_items:
# apply import with conflict policy
from adapters.db.models.person import Person
from sqlalchemy import and_
def find_existing(session: Session, data: dict) -> Optional[Person]:
if match_by == 'national_id' and data.get('national_id'):
return session.query(Person).filter(and_(Person.business_id == business_id, Person.national_id == data['national_id'])).first()
if match_by == 'email' and data.get('email'):
return session.query(Person).filter(and_(Person.business_id == business_id, Person.email == data['email'])).first()
if match_by == 'code' and data.get('code'):
try:
code_int = int(data['code'])
return session.query(Person).filter(and_(Person.business_id == business_id, Person.code == code_int)).first()
except Exception:
return None
return None
for data in valid_items:
existing = find_existing(db, data)
match_value = None
try:
match_value = data.get(match_by)
except Exception:
match_value = None
if existing is None:
# create
try:
create_person(db, business_id, PersonCreateRequest(**data))
inserted += 1
except Exception as e:
logger.error(f"Create person failed for data={data}: {str(e)}")
skipped += 1
else:
if conflict_policy == 'insert':
logger.info(f"Skipping existing person (match_by={match_by}, value={match_value}) due to conflict_policy=insert")
skipped += 1
elif conflict_policy in ('update', 'upsert'):
try:
update_person(db, existing.id, business_id, PersonUpdateRequest(**data))
updated += 1
except Exception as e:
logger.error(f"Update person failed for id={existing.id}, data={data}: {str(e)}")
skipped += 1
summary = {
"total": len(data_rows),
"valid": len(valid_items),
"invalid": len(errors),
"inserted": inserted,
"updated": updated,
"skipped": skipped,
"dry_run": dry_run_bool,
}
return success_response(
data={
"summary": summary,
"errors": errors,
},
request=request,
message="نتیجه ایمپورت اشخاص",
)
except Exception as e:
logger.error(f"Import error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"خطا در پردازش فایل: {str(e)}")

View file

@ -0,0 +1,165 @@
# Removed __future__ annotations to fix OpenAPI schema generation
from typing import Dict, Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from app.core.responses import success_response, ApiError, format_datetime_fields
from adapters.api.v1.schemas import QueryInfo
from adapters.api.v1.schema_models.price_list import (
PriceListCreateRequest,
PriceListUpdateRequest,
PriceItemUpsertRequest,
)
from app.services.price_list_service import (
create_price_list,
list_price_lists,
get_price_list,
update_price_list,
delete_price_list,
list_price_items,
upsert_price_item,
delete_price_item,
)
router = APIRouter(prefix="/price-lists", tags=["price-lists"])
@router.post("/business/{business_id}")
@require_business_access("business_id")
def create_price_list_endpoint(
request: Request,
business_id: int,
payload: PriceListCreateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "write"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
result = create_price_list(db, business_id, payload)
return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
@router.post("/business/{business_id}/search")
@require_business_access("business_id")
def search_price_lists_endpoint(
request: Request,
business_id: int,
query: QueryInfo,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
result = list_price_lists(db, business_id, {
"take": query.take,
"skip": query.skip,
"sort_by": query.sort_by,
"sort_desc": query.sort_desc,
"search": query.search,
})
return success_response(data=format_datetime_fields(result, request), request=request)
@router.get("/business/{business_id}/{price_list_id}")
@require_business_access("business_id")
def get_price_list_endpoint(
request: Request,
business_id: int,
price_list_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
item = get_price_list(db, business_id, price_list_id)
if not item:
raise ApiError("NOT_FOUND", "Price list not found", http_status=404)
return success_response(data=format_datetime_fields({"item": item}, request), request=request)
@router.put("/business/{business_id}/{price_list_id}")
@require_business_access("business_id")
def update_price_list_endpoint(
request: Request,
business_id: int,
price_list_id: int,
payload: PriceListUpdateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "write"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
result = update_price_list(db, business_id, price_list_id, payload)
if not result:
raise ApiError("NOT_FOUND", "Price list not found", http_status=404)
return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
@router.delete("/business/{business_id}/{price_list_id}")
@require_business_access("business_id")
def delete_price_list_endpoint(
request: Request,
business_id: int,
price_list_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "delete"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
ok = delete_price_list(db, business_id, price_list_id)
return success_response({"deleted": ok}, request)
@router.post("/business/{business_id}/{price_list_id}/items")
@require_business_access("business_id")
def upsert_price_item_endpoint(
request: Request,
business_id: int,
price_list_id: int,
payload: PriceItemUpsertRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "write"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
result = upsert_price_item(db, business_id, price_list_id, payload)
return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
@router.get("/business/{business_id}/{price_list_id}/items")
@require_business_access("business_id")
def list_price_items_endpoint(
request: Request,
business_id: int,
price_list_id: int,
product_id: int | None = None,
currency_id: int | None = None,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
result = list_price_items(db, business_id, price_list_id, product_id=product_id, currency_id=currency_id)
return success_response(data=format_datetime_fields(result, request), request=request)
@router.delete("/business/{business_id}/items/{item_id}")
@require_business_access("business_id")
def delete_price_item_endpoint(
request: Request,
business_id: int,
item_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "delete"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
ok = delete_price_item(db, business_id, item_id)
return success_response({"deleted": ok}, request)

View file

@ -0,0 +1,124 @@
from typing import Any, Dict
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from app.core.responses import success_response, ApiError, format_datetime_fields
from adapters.api.v1.schemas import QueryInfo
from adapters.api.v1.schema_models.product_attribute import (
ProductAttributeCreateRequest,
ProductAttributeUpdateRequest,
)
from app.services.product_attribute_service import (
create_attribute,
list_attributes,
get_attribute,
update_attribute,
delete_attribute,
)
router = APIRouter(prefix="/product-attributes", tags=["product-attributes"])
@router.post("/business/{business_id}")
@require_business_access("business_id")
def create_product_attribute(
request: Request,
business_id: int,
payload: ProductAttributeCreateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("product_attributes", "add"):
raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.add", http_status=403)
result = create_attribute(db, business_id, payload)
return success_response(
data=format_datetime_fields(result["data"], request),
request=request,
message=result.get("message"),
)
@router.post("/business/{business_id}/search")
@require_business_access("business_id")
def search_product_attributes(
request: Request,
business_id: int,
query: QueryInfo,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("product_attributes"):
raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.view", http_status=403)
result = list_attributes(db, business_id, {
"take": query.take,
"skip": query.skip,
"sort_by": query.sort_by,
"sort_desc": query.sort_desc,
"search": query.search,
"filters": query.filters,
})
# Format all datetime fields in items/pagination
formatted = format_datetime_fields(result, request)
return success_response(data=formatted, request=request)
@router.get("/business/{business_id}/{attribute_id}")
@require_business_access("business_id")
def get_product_attribute(
request: Request,
business_id: int,
attribute_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("product_attributes"):
raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.view", http_status=403)
item = get_attribute(db, attribute_id, business_id)
if not item:
raise ApiError("NOT_FOUND", "Attribute not found", http_status=404)
return success_response(data=format_datetime_fields({"item": item}, request), request=request)
@router.put("/business/{business_id}/{attribute_id}")
@require_business_access("business_id")
def update_product_attribute(
request: Request,
business_id: int,
attribute_id: int,
payload: ProductAttributeUpdateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("product_attributes", "edit"):
raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.edit", http_status=403)
result = update_attribute(db, attribute_id, business_id, payload)
if not result:
raise ApiError("NOT_FOUND", "Attribute not found", http_status=404)
return success_response(
data=format_datetime_fields(result["data"], request),
request=request,
message=result.get("message"),
)
@router.delete("/business/{business_id}/{attribute_id}")
@require_business_access("business_id")
def delete_product_attribute(
request: Request,
business_id: int,
attribute_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("product_attributes", "delete"):
raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.delete", http_status=403)
ok = delete_attribute(db, attribute_id, business_id)
return success_response({"deleted": ok}, request)

View file

@ -0,0 +1,509 @@
# Removed __future__ annotations to fix OpenAPI schema generation
from typing import Dict, Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from app.core.responses import success_response, ApiError, format_datetime_fields
from adapters.api.v1.schemas import QueryInfo
from adapters.api.v1.schema_models.product import (
ProductCreateRequest,
ProductUpdateRequest,
)
from app.services.product_service import (
create_product,
list_products,
get_product,
update_product,
delete_product,
)
from adapters.db.models.business import Business
from app.core.i18n import negotiate_locale
router = APIRouter(prefix="/products", tags=["products"])
@router.post("/business/{business_id}")
@require_business_access("business_id")
def create_product_endpoint(
request: Request,
business_id: int,
payload: ProductCreateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "write"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
result = create_product(db, business_id, payload)
return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
@router.post("/business/{business_id}/search")
@require_business_access("business_id")
def search_products_endpoint(
request: Request,
business_id: int,
query_info: QueryInfo,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
result = list_products(db, business_id, {
"take": query_info.take,
"skip": query_info.skip,
"sort_by": query_info.sort_by,
"sort_desc": query_info.sort_desc,
"search": query_info.search,
"filters": query_info.filters,
})
return success_response(data=format_datetime_fields(result, request), request=request)
@router.get("/business/{business_id}/{product_id}")
@require_business_access("business_id")
def get_product_endpoint(
request: Request,
business_id: int,
product_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
item = get_product(db, product_id, business_id)
if not item:
raise ApiError("NOT_FOUND", "Product not found", http_status=404)
return success_response(data=format_datetime_fields({"item": item}, request), request=request)
@router.put("/business/{business_id}/{product_id}")
@require_business_access("business_id")
def update_product_endpoint(
request: Request,
business_id: int,
product_id: int,
payload: ProductUpdateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "write"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
result = update_product(db, product_id, business_id, payload)
if not result:
raise ApiError("NOT_FOUND", "Product not found", http_status=404)
return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
@router.delete("/business/{business_id}/{product_id}")
@require_business_access("business_id")
def delete_product_endpoint(
request: Request,
business_id: int,
product_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "delete"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
ok = delete_product(db, product_id, business_id)
return success_response({"deleted": ok}, request)
@router.post("/business/{business_id}/export/excel",
summary="خروجی Excel لیست محصولات",
description="خروجی Excel لیست محصولات با قابلیت فیلتر، انتخاب ستون‌ها و ترتیب آن‌ها",
)
@require_business_access("business_id")
async def export_products_excel(
request: Request,
business_id: int,
body: dict,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
import io
import re
import datetime
from fastapi.responses import Response
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
query_dict = {
"take": int(body.get("take", 1000)),
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by"),
"sort_desc": bool(body.get("sort_desc", False)),
"search": body.get("search"),
"search_fields": body.get("search_fields"),
"filters": body.get("filters"),
}
result = list_products(db, business_id, query_dict)
items = result.get("items", []) if isinstance(result, dict) else result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
# Apply selected indices filter if requested
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None and isinstance(items, list):
indices = None
if isinstance(selected_indices, str):
try:
import json as _json
indices = _json.loads(selected_indices)
except Exception:
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
export_columns = body.get("export_columns")
if export_columns and isinstance(export_columns, list):
headers = [col.get("label") or col.get("key") for col in export_columns]
keys = [col.get("key") for col in export_columns]
else:
default_cols = [
("code", "کد"),
("name", "نام"),
("item_type", "نوع"),
("category_id", "دسته"),
("base_sales_price", "قیمت فروش"),
("base_purchase_price", "قیمت خرید"),
("main_unit_id", "واحد اصلی"),
("secondary_unit_id", "واحد فرعی"),
("track_inventory", "کنترل موجودی"),
("created_at_formatted", "ایجاد"),
]
keys = [k for k, _ in default_cols]
headers = [v for _, v in default_cols]
wb = Workbook()
ws = wb.active
ws.title = "Products"
# Locale and RTL/LTR handling for Excel
locale = negotiate_locale(request.headers.get("Accept-Language"))
if locale == 'fa':
try:
ws.sheet_view.rightToLeft = True
except Exception:
pass
# Header style
header_font = Font(bold=True)
header_fill = PatternFill(start_color="DDDDDD", end_color="DDDDDD", fill_type="solid")
thin_border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
ws.append(headers)
for cell in ws[1]:
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center")
cell.border = thin_border
for it in items:
row = []
for k in keys:
row.append(it.get(k))
ws.append(row)
for cell in ws[ws.max_row]:
cell.border = thin_border
# Align data cells based on locale
if locale == 'fa':
cell.alignment = Alignment(horizontal="right")
# Auto width columns
try:
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if cell.value is not None and len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
except Exception:
pass
output = io.BytesIO()
wb.save(output)
data = output.getvalue()
# Build meaningful filename
biz_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
biz_name = b.name or ""
except Exception:
biz_name = ""
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "products"
if biz_name:
base += f"_{slugify(biz_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return Response(
content=data,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(data)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post("/business/{business_id}/export/pdf",
summary="خروجی PDF لیست محصولات",
description="خروجی PDF لیست محصولات با قابلیت فیلتر و انتخاب ستون‌ها",
)
@require_business_access("business_id")
async def export_products_pdf(
request: Request,
business_id: int,
body: dict,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
import json
import datetime
import re
from fastapi.responses import Response
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
query_dict = {
"take": int(body.get("take", 100)),
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by"),
"sort_desc": bool(body.get("sort_desc", False)),
"search": body.get("search"),
"search_fields": body.get("search_fields"),
"filters": body.get("filters"),
}
result = list_products(db, business_id, query_dict)
items = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
# Apply selected indices filter if requested
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
export_columns = body.get("export_columns")
if export_columns and isinstance(export_columns, list):
headers = [col.get("label") or col.get("key") for col in export_columns]
keys = [col.get("key") for col in export_columns]
else:
default_cols = [
("code", "کد"),
("name", "نام"),
("item_type", "نوع"),
("category_id", "دسته"),
("base_sales_price", "قیمت فروش"),
("base_purchase_price", "قیمت خرید"),
("main_unit_id", "واحد اصلی"),
("secondary_unit_id", "واحد فرعی"),
("track_inventory", "کنترل موجودی"),
("created_at_formatted", "ایجاد"),
]
keys = [k for k, _ in default_cols]
headers = [v for _, v in default_cols]
# Locale and direction
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = (locale == 'fa')
html_lang = 'fa' if is_fa else 'en'
html_dir = 'rtl' if is_fa else 'ltr'
# Load business info for header
business_name = ""
try:
biz = db.query(Business).filter(Business.id == business_id).first()
if biz is not None:
business_name = biz.name or ""
except Exception:
business_name = ""
# Escape helper
def escape(s: Any) -> str:
try:
return str(s).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
except Exception:
return str(s)
# Build rows
rows_html = []
for item in items:
tds = []
for key in keys:
value = item.get(key)
if value is None:
value = ""
elif isinstance(value, list):
value = ", ".join(str(v) for v in value)
tds.append(f"<td>{escape(value)}</td>")
rows_html.append(f"<tr>{''.join(tds)}</tr>")
headers_html = ''.join(f"<th>{escape(h)}</th>" for h in headers)
# Format report datetime based on X-Calendar-Type header
calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
try:
from app.core.calendar import CalendarConverter
formatted_now = CalendarConverter.format_datetime(datetime.datetime.now(),
"jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian")
now_str = formatted_now.get('formatted', formatted_now.get('date_time', ''))
except Exception:
now_str = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
title_text = "گزارش فهرست محصولات" if is_fa else "Products List Report"
label_biz = "نام کسب‌وکار" if is_fa else "Business Name"
label_date = "تاریخ گزارش" if is_fa else "Report Date"
footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix"
page_label_left = "صفحه " if is_fa else "Page "
page_label_of = " از " if is_fa else " of "
table_html = f"""
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
<head>
<meta charset='utf-8'>
<style>
@page {{
size: A4 landscape;
margin: 12mm;
@bottom-{ 'left' if is_fa else 'right' } {{
content: "{page_label_left}" counter(page) "{page_label_of}" counter(pages);
font-size: 10px;
color: #666;
}}
}}
body {{
font-family: sans-serif;
font-size: 11px;
color: #222;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 2px solid #444;
padding-bottom: 6px;
}}
.title {{
font-size: 16px;
font-weight: 700;
}}
.meta {{
font-size: 11px;
color: #555;
}}
.table-wrapper {{
width: 100%;
}}
table.report-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}}
thead th {{
background: #f0f3f7;
border: 1px solid #c7cdd6;
padding: 6px 4px;
text-align: center;
font-weight: 700;
white-space: nowrap;
}}
tbody td {{
border: 1px solid #d7dde6;
padding: 5px 4px;
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}}
.footer {{
position: running(footer);
font-size: 10px;
color: #666;
margin-top: 8px;
text-align: {'left' if is_fa else 'right'};
}}
</style>
</head>
<body>
<div class=\"header\">
<div>
<div class=\"title\">{title_text}</div>
<div class=\"meta\">{label_biz}: {escape(business_name)}</div>
</div>
<div class=\"meta\">{label_date}: {escape(now_str)}</div>
</div>
<div class=\"table-wrapper\">
<table class=\"report-table\">
<thead>
<tr>{headers_html}</tr>
</thead>
<tbody>
{''.join(rows_html)}
</tbody>
</table>
</div>
<div class=\"footer\">{footer_text}</div>
</body>
</html>
"""
font_config = FontConfiguration()
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
# Build meaningful filename
biz_name = business_name
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "products"
if biz_name:
base += f"_{slugify(biz_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)

View file

@ -0,0 +1,10 @@
# This file makes the directory a Python package
# Import from file_storage module
from .file_storage import *
# Re-export from parent schemas module
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from schemas import *

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from typing import List, Optional
from pydantic import BaseModel, Field
class AccountTreeNode(BaseModel):
id: int = Field(..., description="ID حساب")
code: str = Field(..., description="کد حساب")
name: str = Field(..., description="نام حساب")
account_type: Optional[str] = Field(default=None, description="نوع حساب")
parent_id: Optional[int] = Field(default=None, description="شناسه والد")
level: Optional[int] = Field(default=None, description="سطح حساب در درخت")
children: List["AccountTreeNode"] = Field(default_factory=list, description="فرزندان")
class Config:
from_attributes = True

View file

@ -0,0 +1,59 @@
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime
class EmailConfigBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="Configuration name")
smtp_host: str = Field(..., min_length=1, max_length=255, description="SMTP host")
smtp_port: int = Field(..., ge=1, le=65535, description="SMTP port")
smtp_username: str = Field(..., min_length=1, max_length=255, description="SMTP username")
smtp_password: str = Field(..., min_length=1, max_length=255, description="SMTP password")
use_tls: bool = Field(default=True, description="Use TLS encryption")
use_ssl: bool = Field(default=False, description="Use SSL encryption")
from_email: EmailStr = Field(..., description="From email address")
from_name: str = Field(..., min_length=1, max_length=100, description="From name")
is_active: bool = Field(default=True, description="Is this configuration active")
is_default: bool = Field(default=False, description="Is this the default configuration")
class EmailConfigCreate(EmailConfigBase):
pass
class EmailConfigUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
smtp_host: Optional[str] = Field(None, min_length=1, max_length=255)
smtp_port: Optional[int] = Field(None, ge=1, le=65535)
smtp_username: Optional[str] = Field(None, min_length=1, max_length=255)
smtp_password: Optional[str] = Field(None, min_length=1, max_length=255)
use_tls: Optional[bool] = None
use_ssl: Optional[bool] = None
from_email: Optional[EmailStr] = None
from_name: Optional[str] = Field(None, min_length=1, max_length=100)
is_active: Optional[bool] = None
is_default: Optional[bool] = None
class EmailConfigResponse(EmailConfigBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class SendEmailRequest(BaseModel):
to: EmailStr = Field(..., description="Recipient email address")
subject: str = Field(..., min_length=1, max_length=255, description="Email subject")
body: str = Field(..., min_length=1, description="Email body (plain text)")
html_body: Optional[str] = Field(None, description="Email body (HTML)")
config_id: Optional[int] = Field(None, description="Specific config ID to use")
class TestConnectionRequest(BaseModel):
config_id: int = Field(..., description="Configuration ID to test")
# These response models are no longer needed as we use SuccessResponse from schemas.py

View file

@ -0,0 +1,80 @@
from typing import Optional, Dict, Any, List
from uuid import UUID
from pydantic import BaseModel, Field
from datetime import datetime
# Request Models
class StorageConfigCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="نام پیکربندی")
storage_type: str = Field(..., description="نوع ذخیره‌سازی")
config_data: Dict[str, Any] = Field(..., description="داده‌های پیکربندی")
is_default: bool = Field(default=False, description="آیا پیش‌فرض است")
is_active: bool = Field(default=True, description="آیا فعال است")
class StorageConfigUpdateRequest(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=100, description="نام پیکربندی")
config_data: Optional[Dict[str, Any]] = Field(default=None, description="داده‌های پیکربندی")
is_active: Optional[bool] = Field(default=None, description="آیا فعال است")
class FileUploadRequest(BaseModel):
module_context: str = Field(..., description="زمینه ماژول")
context_id: Optional[UUID] = Field(default=None, description="شناسه زمینه")
developer_data: Optional[Dict[str, Any]] = Field(default=None, description="داده‌های توسعه‌دهنده")
is_temporary: bool = Field(default=False, description="آیا فایل موقت است")
expires_in_days: int = Field(default=30, ge=1, le=365, description="تعداد روزهای انقضا")
class FileVerificationRequest(BaseModel):
verification_data: Dict[str, Any] = Field(..., description="داده‌های تایید")
# Response Models
class FileInfo(BaseModel):
file_id: str = Field(..., description="شناسه فایل")
original_name: str = Field(..., description="نام اصلی فایل")
file_size: int = Field(..., description="حجم فایل")
mime_type: str = Field(..., description="نوع فایل")
is_temporary: bool = Field(..., description="آیا موقت است")
is_verified: bool = Field(..., description="آیا تایید شده است")
created_at: str = Field(..., description="تاریخ ایجاد")
expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا")
class Config:
from_attributes = True
class FileUploadResponse(BaseModel):
file_id: str = Field(..., description="شناسه فایل")
original_name: str = Field(..., description="نام اصلی فایل")
file_size: int = Field(..., description="حجم فایل")
mime_type: str = Field(..., description="نوع فایل")
is_temporary: bool = Field(..., description="آیا موقت است")
verification_token: Optional[str] = Field(default=None, description="توکن تایید")
expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا")
class StorageConfigResponse(BaseModel):
id: str = Field(..., description="شناسه پیکربندی")
name: str = Field(..., description="نام پیکربندی")
storage_type: str = Field(..., description="نوع ذخیره‌سازی")
is_default: bool = Field(..., description="آیا پیش‌فرض است")
is_active: bool = Field(..., description="آیا فعال است")
created_at: str = Field(..., description="تاریخ ایجاد")
class Config:
from_attributes = True
class FileStatisticsResponse(BaseModel):
total_files: int = Field(..., description="کل فایل‌ها")
total_size: int = Field(..., description="حجم کل")
temporary_files: int = Field(..., description="فایل‌های موقت")
unverified_files: int = Field(..., description="فایل‌های تایید نشده")
class CleanupResponse(BaseModel):
cleaned_files: int = Field(..., description="تعداد فایل‌های پاکسازی شده")
total_unverified: int = Field(..., description="کل فایل‌های تایید نشده")

View file

@ -0,0 +1,242 @@
from typing import List, Optional
from pydantic import BaseModel, Field
from enum import Enum
from datetime import datetime
class PersonType(str, Enum):
"""نوع شخص"""
CUSTOMER = "مشتری"
MARKETER = "بازاریاب"
EMPLOYEE = "کارمند"
SUPPLIER = "تامین‌کننده"
PARTNER = "همکار"
SELLER = "فروشنده"
SHAREHOLDER = "سهامدار"
class PersonBankAccountCreateRequest(BaseModel):
"""درخواست ایجاد حساب بانکی شخص"""
bank_name: str = Field(..., min_length=1, max_length=255, description="نام بانک")
account_number: Optional[str] = Field(default=None, max_length=50, description="شماره حساب")
card_number: Optional[str] = Field(default=None, max_length=20, description="شماره کارت")
sheba_number: Optional[str] = Field(default=None, max_length=30, description="شماره شبا")
class PersonBankAccountUpdateRequest(BaseModel):
"""درخواست ویرایش حساب بانکی شخص"""
bank_name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام بانک")
account_number: Optional[str] = Field(default=None, max_length=50, description="شماره حساب")
card_number: Optional[str] = Field(default=None, max_length=20, description="شماره کارت")
sheba_number: Optional[str] = Field(default=None, max_length=30, description="شماره شبا")
class PersonBankAccountResponse(BaseModel):
"""پاسخ اطلاعات حساب بانکی شخص"""
id: int = Field(..., description="شناسه حساب بانکی")
person_id: int = Field(..., description="شناسه شخص")
bank_name: str = Field(..., description="نام بانک")
account_number: Optional[str] = Field(default=None, description="شماره حساب")
card_number: Optional[str] = Field(default=None, description="شماره کارت")
sheba_number: Optional[str] = Field(default=None, description="شماره شبا")
created_at: str = Field(..., description="تاریخ ایجاد")
updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
class Config:
from_attributes = True
class PersonCreateRequest(BaseModel):
"""درخواست ایجاد شخص جدید"""
# اطلاعات پایه
code: Optional[int] = Field(default=None, ge=1, description="کد یکتا در هر کسب و کار (در صورت عدم ارسال، خودکار تولید می‌شود)")
alias_name: str = Field(..., min_length=1, max_length=255, description="نام مستعار (الزامی)")
first_name: Optional[str] = Field(default=None, max_length=100, description="نام")
last_name: Optional[str] = Field(default=None, max_length=100, description="نام خانوادگی")
person_type: Optional[PersonType] = Field(default=None, description="نوع شخص (سازگاری قدیمی)")
person_types: Optional[List[PersonType]] = Field(default=None, description="انواع شخص (چندانتخابی)")
company_name: Optional[str] = Field(default=None, max_length=255, description="نام شرکت")
payment_id: Optional[str] = Field(default=None, max_length=100, description="شناسه پرداخت")
# اطلاعات اقتصادی
national_id: Optional[str] = Field(default=None, max_length=20, description="شناسه ملی")
registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت")
economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی")
# اطلاعات تماس
country: Optional[str] = Field(default=None, max_length=100, description="کشور")
province: Optional[str] = Field(default=None, max_length=100, description="استان")
city: Optional[str] = Field(default=None, max_length=100, description="شهرستان")
address: Optional[str] = Field(default=None, description="آدرس")
postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
phone: Optional[str] = Field(default=None, max_length=20, description="تلفن")
mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل")
fax: Optional[str] = Field(default=None, max_length=20, description="فکس")
email: Optional[str] = Field(default=None, max_length=255, description="پست الکترونیکی")
website: Optional[str] = Field(default=None, max_length=255, description="وب‌سایت")
# حساب‌های بانکی
bank_accounts: Optional[List[PersonBankAccountCreateRequest]] = Field(default=[], description="حساب‌های بانکی")
# سهام
share_count: Optional[int] = Field(default=None, ge=1, description="تعداد سهام (برای سهامدار، اجباری و حداقل 1)")
# پورسانت (برای بازاریاب/فروشنده)
commission_sale_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از فروش")
commission_sales_return_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از برگشت از فروش")
commission_sales_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ فروش مبنا")
commission_sales_return_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ برگشت از فروش مبنا")
commission_exclude_discounts: Optional[bool] = Field(default=False, description="عدم محاسبه تخفیف")
commission_exclude_additions_deductions: Optional[bool] = Field(default=False, description="عدم محاسبه اضافات و کسورات")
commission_post_in_invoice_document: Optional[bool] = Field(default=False, description="ثبت پورسانت در سند فاکتور")
@classmethod
def __get_validators__(cls):
yield from super().__get_validators__()
@staticmethod
def _has_shareholder(person_type: Optional[PersonType], person_types: Optional[List[PersonType]]) -> bool:
if person_type == PersonType.SHAREHOLDER:
return True
if person_types:
return PersonType.SHAREHOLDER in person_types
return False
@classmethod
def validate(cls, value): # type: ignore[override]
obj = super().validate(value)
# اعتبارسنجی شرطی سهامدار
if cls._has_shareholder(getattr(obj, 'person_type', None), getattr(obj, 'person_types', None)):
sc = getattr(obj, 'share_count', None)
if sc is None or (isinstance(sc, int) and sc <= 0):
raise ValueError("برای سهامدار، مقدار تعداد سهام الزامی و باید بزرگتر از صفر باشد")
return obj
class PersonUpdateRequest(BaseModel):
"""درخواست ویرایش شخص"""
# اطلاعات پایه
code: Optional[int] = Field(default=None, ge=1, description="کد یکتا در هر کسب و کار")
alias_name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام مستعار")
first_name: Optional[str] = Field(default=None, max_length=100, description="نام")
last_name: Optional[str] = Field(default=None, max_length=100, description="نام خانوادگی")
person_type: Optional[PersonType] = Field(default=None, description="نوع شخص (سازگاری قدیمی)")
person_types: Optional[List[PersonType]] = Field(default=None, description="انواع شخص (چندانتخابی)")
company_name: Optional[str] = Field(default=None, max_length=255, description="نام شرکت")
payment_id: Optional[str] = Field(default=None, max_length=100, description="شناسه پرداخت")
# اطلاعات اقتصادی
national_id: Optional[str] = Field(default=None, max_length=20, description="شناسه ملی")
registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت")
economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی")
# اطلاعات تماس
country: Optional[str] = Field(default=None, max_length=100, description="کشور")
province: Optional[str] = Field(default=None, max_length=100, description="استان")
city: Optional[str] = Field(default=None, max_length=100, description="شهرستان")
address: Optional[str] = Field(default=None, description="آدرس")
postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
phone: Optional[str] = Field(default=None, max_length=20, description="تلفن")
mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل")
fax: Optional[str] = Field(default=None, max_length=20, description="فکس")
email: Optional[str] = Field(default=None, max_length=255, description="پست الکترونیکی")
website: Optional[str] = Field(default=None, max_length=255, description="وب‌سایت")
# سهام
share_count: Optional[int] = Field(default=None, ge=1, description="تعداد سهام (برای سهامدار)")
# پورسانت
commission_sale_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از فروش")
commission_sales_return_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از برگشت از فروش")
commission_sales_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ فروش مبنا")
commission_sales_return_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ برگشت از فروش مبنا")
commission_exclude_discounts: Optional[bool] = Field(default=None, description="عدم محاسبه تخفیف")
commission_exclude_additions_deductions: Optional[bool] = Field(default=None, description="عدم محاسبه اضافات و کسورات")
commission_post_in_invoice_document: Optional[bool] = Field(default=None, description="ثبت پورسانت در سند فاکتور")
@classmethod
def __get_validators__(cls):
yield from super().__get_validators__()
@staticmethod
def _has_shareholder(person_type: Optional[PersonType], person_types: Optional[List[PersonType]]) -> bool:
if person_type == PersonType.SHAREHOLDER:
return True
if person_types:
return PersonType.SHAREHOLDER in person_types
return False
@classmethod
def validate(cls, value): # type: ignore[override]
obj = super().validate(value)
# اگر ورودی‌ها مشخصاً به سهامدار اشاره دارند، share_count باید معتبر باشد
if cls._has_shareholder(getattr(obj, 'person_type', None), getattr(obj, 'person_types', None)):
sc = getattr(obj, 'share_count', None)
if sc is None or (isinstance(sc, int) and sc <= 0):
raise ValueError("برای سهامدار، مقدار تعداد سهام الزامی و باید بزرگتر از صفر باشد")
return obj
class PersonResponse(BaseModel):
"""پاسخ اطلاعات شخص"""
id: int = Field(..., description="شناسه شخص")
business_id: int = Field(..., description="شناسه کسب و کار")
# اطلاعات پایه
code: Optional[int] = Field(default=None, description="کد یکتا")
alias_name: str = Field(..., description="نام مستعار")
first_name: Optional[str] = Field(default=None, description="نام")
last_name: Optional[str] = Field(default=None, description="نام خانوادگی")
person_type: str = Field(..., description="نوع شخص")
person_types: List[str] = Field(default_factory=list, description="انواع شخص")
company_name: Optional[str] = Field(default=None, description="نام شرکت")
payment_id: Optional[str] = Field(default=None, description="شناسه پرداخت")
# اطلاعات اقتصادی
national_id: Optional[str] = Field(default=None, description="شناسه ملی")
registration_number: Optional[str] = Field(default=None, description="شماره ثبت")
economic_id: Optional[str] = Field(default=None, description="شناسه اقتصادی")
# اطلاعات تماس
country: Optional[str] = Field(default=None, description="کشور")
province: Optional[str] = Field(default=None, description="استان")
city: Optional[str] = Field(default=None, description="شهرستان")
address: Optional[str] = Field(default=None, description="آدرس")
postal_code: Optional[str] = Field(default=None, description="کد پستی")
phone: Optional[str] = Field(default=None, description="تلفن")
mobile: Optional[str] = Field(default=None, description="موبایل")
fax: Optional[str] = Field(default=None, description="فکس")
email: Optional[str] = Field(default=None, description="پست الکترونیکی")
website: Optional[str] = Field(default=None, description="وب‌سایت")
# زمان‌بندی
created_at: str = Field(..., description="تاریخ ایجاد")
updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
# حساب‌های بانکی
bank_accounts: List[PersonBankAccountResponse] = Field(default=[], description="حساب‌های بانکی")
# سهام
share_count: Optional[int] = Field(default=None, description="تعداد سهام")
# پورسانت
commission_sale_percent: Optional[float] = Field(default=None, description="درصد پورسانت از فروش")
commission_sales_return_percent: Optional[float] = Field(default=None, description="درصد پورسانت از برگشت از فروش")
commission_sales_amount: Optional[float] = Field(default=None, description="مبلغ فروش مبنا")
commission_sales_return_amount: Optional[float] = Field(default=None, description="مبلغ برگشت از فروش مبنا")
commission_exclude_discounts: Optional[bool] = Field(default=False, description="عدم محاسبه تخفیف")
commission_exclude_additions_deductions: Optional[bool] = Field(default=False, description="عدم محاسبه اضافات و کسورات")
commission_post_in_invoice_document: Optional[bool] = Field(default=False, description="ثبت پورسانت در سند فاکتور")
class Config:
from_attributes = True
class PersonListResponse(BaseModel):
"""پاسخ لیست اشخاص"""
items: List[PersonResponse] = Field(..., description="لیست اشخاص")
pagination: dict = Field(..., description="اطلاعات صفحه‌بندی")
query_info: dict = Field(..., description="اطلاعات جستجو و فیلتر")
class PersonSummaryResponse(BaseModel):
"""پاسخ خلاصه اشخاص"""
total_persons: int = Field(..., description="تعداد کل اشخاص")
by_type: dict = Field(..., description="تعداد بر اساس نوع")
active_persons: int = Field(..., description="تعداد اشخاص فعال")
inactive_persons: int = Field(..., description="تعداد اشخاص غیرفعال")

View file

@ -0,0 +1,54 @@
from __future__ import annotations
from typing import Optional, List
from decimal import Decimal
from pydantic import BaseModel, Field
class PriceListCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
is_active: bool = True
class PriceListUpdateRequest(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
is_active: Optional[bool] = None
class PriceItemUpsertRequest(BaseModel):
product_id: int
unit_id: Optional[int] = None
currency_id: int
tier_name: Optional[str] = Field(default=None, min_length=1, max_length=64)
min_qty: Decimal = Field(default=0)
price: Decimal
class PriceListResponse(BaseModel):
id: int
business_id: int
name: str
is_active: bool
created_at: str
updated_at: str
class Config:
from_attributes = True
class PriceItemResponse(BaseModel):
id: int
price_list_id: int
product_id: int
unit_id: Optional[int] = None
currency_id: int
tier_name: str
min_qty: Decimal
price: Decimal
created_at: str
updated_at: str
class Config:
from_attributes = True

View file

@ -0,0 +1,110 @@
from __future__ import annotations
from typing import Optional, List
from decimal import Decimal
from pydantic import BaseModel, Field
from enum import Enum
class ProductItemType(str, Enum):
PRODUCT = "کالا"
SERVICE = "خدمت"
class ProductCreateRequest(BaseModel):
item_type: ProductItemType = Field(default=ProductItemType.PRODUCT)
code: Optional[str] = Field(default=None, max_length=64)
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = Field(default=None, max_length=2000)
category_id: Optional[int] = None
main_unit_id: Optional[int] = None
secondary_unit_id: Optional[int] = None
unit_conversion_factor: Optional[Decimal] = None
base_sales_price: Optional[Decimal] = None
base_sales_note: Optional[str] = None
base_purchase_price: Optional[Decimal] = None
base_purchase_note: Optional[str] = None
track_inventory: bool = Field(default=False)
reorder_point: Optional[int] = None
min_order_qty: Optional[int] = None
lead_time_days: Optional[int] = None
is_sales_taxable: bool = Field(default=False)
is_purchase_taxable: bool = Field(default=False)
sales_tax_rate: Optional[Decimal] = None
purchase_tax_rate: Optional[Decimal] = None
tax_type_id: Optional[int] = None
tax_code: Optional[str] = Field(default=None, max_length=100)
tax_unit_id: Optional[int] = None
attribute_ids: Optional[List[int]] = Field(default=None, description="ویژگی‌های انتخابی برای لینک شدن")
class ProductUpdateRequest(BaseModel):
item_type: Optional[ProductItemType] = None
code: Optional[str] = Field(default=None, max_length=64)
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = Field(default=None, max_length=2000)
category_id: Optional[int] = None
main_unit_id: Optional[int] = None
secondary_unit_id: Optional[int] = None
unit_conversion_factor: Optional[Decimal] = None
base_sales_price: Optional[Decimal] = None
base_sales_note: Optional[str] = None
base_purchase_price: Optional[Decimal] = None
base_purchase_note: Optional[str] = None
track_inventory: Optional[bool] = None
reorder_point: Optional[int] = None
min_order_qty: Optional[int] = None
lead_time_days: Optional[int] = None
is_sales_taxable: Optional[bool] = None
is_purchase_taxable: Optional[bool] = None
sales_tax_rate: Optional[Decimal] = None
purchase_tax_rate: Optional[Decimal] = None
tax_type_id: Optional[int] = None
tax_code: Optional[str] = Field(default=None, max_length=100)
tax_unit_id: Optional[int] = None
attribute_ids: Optional[List[int]] = None
class ProductResponse(BaseModel):
id: int
business_id: int
item_type: str
code: str
name: str
description: Optional[str] = None
category_id: Optional[int] = None
main_unit_id: Optional[int] = None
secondary_unit_id: Optional[int] = None
unit_conversion_factor: Optional[Decimal] = None
base_sales_price: Optional[Decimal] = None
base_sales_note: Optional[str] = None
base_purchase_price: Optional[Decimal] = None
base_purchase_note: Optional[str] = None
track_inventory: bool
reorder_point: Optional[int] = None
min_order_qty: Optional[int] = None
lead_time_days: Optional[int] = None
is_sales_taxable: bool
is_purchase_taxable: bool
sales_tax_rate: Optional[Decimal] = None
purchase_tax_rate: Optional[Decimal] = None
tax_type_id: Optional[int] = None
tax_code: Optional[str] = None
tax_unit_id: Optional[int] = None
created_at: str
updated_at: str
class Config:
from_attributes = True

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from typing import Optional, List
from pydantic import BaseModel, Field
class ProductAttributeCreateRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=255, description="عنوان ویژگی")
description: Optional[str] = Field(default=None, description="توضیحات ویژگی")
class ProductAttributeUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255, description="عنوان ویژگی")
description: Optional[str] = Field(default=None, description="توضیحات ویژگی")
class ProductAttributeResponse(BaseModel):
id: int
business_id: int
title: str
description: Optional[str] = None
created_at: str
updated_at: str
class ProductAttributeListResponse(BaseModel):
items: list[ProductAttributeResponse]
pagination: dict

View file

@ -0,0 +1,339 @@
from typing import Any, List, Optional, Union, Generic, TypeVar
from pydantic import BaseModel, EmailStr, Field
from enum import Enum
from datetime import datetime, date
T = TypeVar('T')
class FilterItem(BaseModel):
property: str = Field(..., description="نام فیلد مورد نظر برای اعمال فیلتر")
operator: str = Field(..., description="نوع عملگر: =, >, >=, <, <=, !=, *, ?*, *?, in")
value: Any = Field(..., description="مقدار مورد نظر")
class QueryInfo(BaseModel):
sort_by: Optional[str] = Field(default=None, description="نام فیلد مورد نظر برای مرتب سازی")
sort_desc: bool = Field(default=False, description="false = مرتب سازی صعودی، true = مرتب سازی نزولی")
take: int = Field(default=10, ge=1, le=1000, description="حداکثر تعداد رکورد بازگشتی")
skip: int = Field(default=0, ge=0, description="تعداد رکوردی که از ابتدای لیست صرف نظر می شود")
search: Optional[str] = Field(default=None, description="عبارت جستجو")
search_fields: Optional[List[str]] = Field(default=None, description="آرایه ای از فیلدهایی که جستجو در آن انجام می گیرد")
filters: Optional[List[FilterItem]] = Field(default=None, description="آرایه ای از اشیا برای اعمال فیلتر بر روی لیست")
class CaptchaSolve(BaseModel):
captcha_id: str = Field(..., min_length=8)
captcha_code: str = Field(..., min_length=3, max_length=8)
class RegisterRequest(CaptchaSolve):
first_name: Optional[str] = Field(default=None, max_length=100)
last_name: Optional[str] = Field(default=None, max_length=100)
email: Optional[EmailStr] = None
mobile: Optional[str] = Field(default=None, max_length=32)
password: str = Field(..., min_length=8, max_length=128)
device_id: Optional[str] = Field(default=None, max_length=100)
referrer_code: Optional[str] = Field(default=None, min_length=4, max_length=32)
class LoginRequest(CaptchaSolve):
identifier: str = Field(..., min_length=3, max_length=255)
password: str = Field(..., min_length=8, max_length=128)
device_id: Optional[str] = Field(default=None, max_length=100)
class ForgotPasswordRequest(CaptchaSolve):
identifier: str = Field(..., min_length=3, max_length=255)
class ResetPasswordRequest(CaptchaSolve):
token: str = Field(..., min_length=16)
new_password: str = Field(..., min_length=8, max_length=128)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(..., min_length=8, max_length=128)
new_password: str = Field(..., min_length=8, max_length=128)
confirm_password: str = Field(..., min_length=8, max_length=128)
class CreateApiKeyRequest(BaseModel):
name: Optional[str] = Field(default=None, max_length=100)
scopes: Optional[str] = Field(default=None, max_length=500)
expires_at: Optional[str] = None # ISO string; parse server-side if provided
# Response Models
class SuccessResponse(BaseModel):
success: bool = Field(default=True, description="وضعیت موفقیت عملیات")
message: Optional[str] = Field(default=None, description="پیام توضیحی")
data: Optional[Union[dict, list]] = Field(default=None, description="داده‌های بازگشتی")
class ErrorResponse(BaseModel):
success: bool = Field(default=False, description="وضعیت موفقیت عملیات")
message: str = Field(..., description="پیام خطا")
error_code: Optional[str] = Field(default=None, description="کد خطا")
details: Optional[dict] = Field(default=None, description="جزئیات خطا")
class UserResponse(BaseModel):
id: int = Field(..., description="شناسه کاربر")
email: Optional[str] = Field(default=None, description="ایمیل کاربر")
mobile: Optional[str] = Field(default=None, description="شماره موبایل")
first_name: Optional[str] = Field(default=None, description="نام")
last_name: Optional[str] = Field(default=None, description="نام خانوادگی")
is_active: bool = Field(..., description="وضعیت فعال بودن")
referral_code: str = Field(..., description="کد معرفی")
referred_by_user_id: Optional[int] = Field(default=None, description="شناسه کاربر معرف")
app_permissions: Optional[dict] = Field(default=None, description="مجوزهای اپلیکیشن")
created_at: str = Field(..., description="تاریخ ایجاد")
updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
class CaptchaResponse(BaseModel):
captcha_id: str = Field(..., description="شناسه کپچا")
image_base64: str = Field(..., description="تصویر کپچا به صورت base64")
ttl_seconds: int = Field(..., description="زمان انقضا به ثانیه")
class LoginResponse(BaseModel):
api_key: str = Field(..., description="کلید API")
expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا")
user: UserResponse = Field(..., description="اطلاعات کاربر")
class ApiKeyResponse(BaseModel):
id: int = Field(..., description="شناسه کلید")
name: Optional[str] = Field(default=None, description="نام کلید")
scopes: Optional[str] = Field(default=None, description="محدوده دسترسی")
device_id: Optional[str] = Field(default=None, description="شناسه دستگاه")
user_agent: Optional[str] = Field(default=None, description="اطلاعات مرورگر")
ip: Optional[str] = Field(default=None, description="آدرس IP")
expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا")
last_used_at: Optional[str] = Field(default=None, description="آخرین استفاده")
created_at: str = Field(..., description="تاریخ ایجاد")
class ReferralStatsResponse(BaseModel):
total_referrals: int = Field(..., description="تعداد کل معرفی‌ها")
active_referrals: int = Field(..., description="تعداد معرفی‌های فعال")
recent_referrals: int = Field(..., description="تعداد معرفی‌های اخیر")
referral_rate: float = Field(..., description="نرخ معرفی")
class PaginationInfo(BaseModel):
total: int = Field(..., description="تعداد کل رکوردها")
page: int = Field(..., description="شماره صفحه فعلی")
per_page: int = Field(..., description="تعداد رکورد در هر صفحه")
total_pages: int = Field(..., description="تعداد کل صفحات")
has_next: bool = Field(..., description="آیا صفحه بعدی وجود دارد")
has_prev: bool = Field(..., description="آیا صفحه قبلی وجود دارد")
class UsersListResponse(BaseModel):
items: List[UserResponse] = Field(..., description="لیست کاربران")
pagination: PaginationInfo = Field(..., description="اطلاعات صفحه‌بندی")
query_info: dict = Field(..., description="اطلاعات جستجو و فیلتر")
class UsersSummaryResponse(BaseModel):
total_users: int = Field(..., description="تعداد کل کاربران")
active_users: int = Field(..., description="تعداد کاربران فعال")
inactive_users: int = Field(..., description="تعداد کاربران غیرفعال")
active_percentage: float = Field(..., description="درصد کاربران فعال")
# Business Schemas
class BusinessType(str, Enum):
COMPANY = "شرکت"
SHOP = "مغازه"
STORE = "فروشگاه"
UNION = "اتحادیه"
CLUB = "باشگاه"
INSTITUTE = "موسسه"
INDIVIDUAL = "شخصی"
class BusinessField(str, Enum):
MANUFACTURING = "تولیدی"
COMMERCIAL = "بازرگانی"
SERVICE = "خدماتی"
OTHER = "سایر"
class BusinessCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255, description="نام کسب و کار")
business_type: BusinessType = Field(..., description="نوع کسب و کار")
business_field: BusinessField = Field(..., description="زمینه فعالیت")
address: Optional[str] = Field(default=None, max_length=1000, description="آدرس")
phone: Optional[str] = Field(default=None, max_length=20, description="تلفن ثابت")
mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل")
national_id: Optional[str] = Field(default=None, max_length=20, description="کد ملی")
registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت")
economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی")
country: Optional[str] = Field(default=None, max_length=100, description="کشور")
province: Optional[str] = Field(default=None, max_length=100, description="استان")
city: Optional[str] = Field(default=None, max_length=100, description="شهر")
postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
fiscal_years: Optional[List["FiscalYearCreate"]] = Field(default=None, description="آرایه سال‌های مالی برای ایجاد اولیه")
default_currency_id: Optional[int] = Field(default=None, description="شناسه ارز پیشفرض")
currency_ids: Optional[List[int]] = Field(default=None, description="لیست شناسه ارزهای قابل استفاده")
class BusinessUpdateRequest(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام کسب و کار")
business_type: Optional[BusinessType] = Field(default=None, description="نوع کسب و کار")
business_field: Optional[BusinessField] = Field(default=None, description="زمینه فعالیت")
address: Optional[str] = Field(default=None, max_length=1000, description="آدرس")
phone: Optional[str] = Field(default=None, max_length=20, description="تلفن ثابت")
mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل")
national_id: Optional[str] = Field(default=None, max_length=20, description="کد ملی")
registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت")
economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی")
country: Optional[str] = Field(default=None, max_length=100, description="کشور")
province: Optional[str] = Field(default=None, max_length=100, description="استان")
city: Optional[str] = Field(default=None, max_length=100, description="شهر")
postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
class BusinessResponse(BaseModel):
id: int = Field(..., description="شناسه کسب و کار")
name: str = Field(..., description="نام کسب و کار")
business_type: str = Field(..., description="نوع کسب و کار")
business_field: str = Field(..., description="زمینه فعالیت")
owner_id: int = Field(..., description="شناسه مالک")
address: Optional[str] = Field(default=None, description="آدرس")
phone: Optional[str] = Field(default=None, description="تلفن ثابت")
mobile: Optional[str] = Field(default=None, description="موبایل")
national_id: Optional[str] = Field(default=None, description="کد ملی")
registration_number: Optional[str] = Field(default=None, description="شماره ثبت")
economic_id: Optional[str] = Field(default=None, description="شناسه اقتصادی")
country: Optional[str] = Field(default=None, description="کشور")
province: Optional[str] = Field(default=None, description="استان")
city: Optional[str] = Field(default=None, description="شهر")
postal_code: Optional[str] = Field(default=None, description="کد پستی")
created_at: str = Field(..., description="تاریخ ایجاد")
updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
default_currency: Optional[dict] = Field(default=None, description="ارز پیشفرض")
currencies: Optional[List[dict]] = Field(default=None, description="ارزهای فعال کسب‌وکار")
class BusinessListResponse(BaseModel):
items: List[BusinessResponse] = Field(..., description="لیست کسب و کارها")
pagination: PaginationInfo = Field(..., description="اطلاعات صفحه‌بندی")
query_info: dict = Field(..., description="اطلاعات جستجو و فیلتر")
class BusinessSummaryResponse(BaseModel):
total_businesses: int = Field(..., description="تعداد کل کسب و کارها")
by_type: dict = Field(..., description="تعداد بر اساس نوع")
by_field: dict = Field(..., description="تعداد بر اساس زمینه فعالیت")
class PaginatedResponse(BaseModel, Generic[T]):
"""پاسخ صفحه‌بندی شده برای لیست‌ها"""
items: List[T] = Field(..., description="آیتم‌های صفحه")
total: int = Field(..., description="تعداد کل آیتم‌ها")
page: int = Field(..., description="شماره صفحه فعلی")
limit: int = Field(..., description="تعداد آیتم در هر صفحه")
total_pages: int = Field(..., description="تعداد کل صفحات")
@classmethod
def create(cls, items: List[T], total: int, page: int, limit: int) -> 'PaginatedResponse[T]':
"""ایجاد پاسخ صفحه‌بندی شده"""
total_pages = (total + limit - 1) // limit
return cls(
items=items,
total=total,
page=page,
limit=limit,
total_pages=total_pages
)
# Fiscal Year Schemas
class FiscalYearCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=255, description="عنوان سال مالی")
start_date: date = Field(..., description="تاریخ شروع سال مالی")
end_date: date = Field(..., description="تاریخ پایان سال مالی")
is_last: bool = Field(default=True, description="آیا آخرین سال مالی فعال است؟")
# Business User Schemas
class BusinessUserSchema(BaseModel):
id: int
business_id: int
user_id: int
user_name: str
user_email: str
user_phone: Optional[str] = None
role: str
status: str
added_at: datetime
last_active: Optional[datetime] = None
permissions: dict
class Config:
from_attributes = True
class AddUserRequest(BaseModel):
email_or_phone: str
class Config:
json_schema_extra = {
"example": {
"email_or_phone": "user@example.com"
}
}
class AddUserResponse(BaseModel):
success: bool
message: str
user: Optional[BusinessUserSchema] = None
class UpdatePermissionsRequest(BaseModel):
permissions: dict
class Config:
json_schema_extra = {
"example": {
"permissions": {
"sales": {
"read": True,
"write": True,
"delete": False
},
"reports": {
"read": True,
"export": True
},
"settings": {
"manage_users": True
}
}
}
}
class UpdatePermissionsResponse(BaseModel):
success: bool
message: str
class RemoveUserResponse(BaseModel):
success: bool
message: str
class BusinessUsersListResponse(BaseModel):
success: bool
message: str
data: dict
calendar_type: Optional[str] = None

View file

@ -0,0 +1 @@
# Support API endpoints

View file

@ -0,0 +1,29 @@
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.repositories.support.category_repository import CategoryRepository
from adapters.api.v1.support.schemas import CategoryResponse
from adapters.api.v1.schemas import SuccessResponse
from app.core.responses import success_response, format_datetime_fields
router = APIRouter()
@router.get("", response_model=SuccessResponse)
async def get_categories(
request: Request,
db: Session = Depends(get_db)
):
"""دریافت لیست دسته‌بندی‌های فعال"""
category_repo = CategoryRepository(db)
categories = category_repo.get_active_categories()
# Convert to dict and format datetime fields
categories_data = [CategoryResponse.from_orm(category).dict() for category in categories]
formatted_data = format_datetime_fields(categories_data, request)
return success_response(formatted_data, request)

View file

@ -0,0 +1,296 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status, Request, Body
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.repositories.support.ticket_repository import TicketRepository
from adapters.db.repositories.support.message_repository import MessageRepository
from adapters.api.v1.schemas import QueryInfo, PaginatedResponse, SuccessResponse
from adapters.api.v1.support.schemas import (
CreateMessageRequest,
UpdateStatusRequest,
AssignTicketRequest,
TicketResponse,
MessageResponse
)
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_app_permission
from app.core.responses import success_response, format_datetime_fields
router = APIRouter()
@router.post("/tickets/search", response_model=SuccessResponse)
@require_app_permission("support_operator")
async def search_operator_tickets(
request: Request,
query_info: QueryInfo = Body(...),
current_user: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""جستجو در تمام تیکت‌ها برای اپراتور"""
ticket_repo = TicketRepository(db)
# تنظیم فیلدهای قابل جستجو
if not query_info.search_fields:
query_info.search_fields = ["title", "description", "user_email", "user_name"]
tickets, total = ticket_repo.get_operator_tickets(query_info)
# تبدیل به dict
ticket_dicts = []
for ticket in tickets:
ticket_dict = {
"id": ticket.id,
"title": ticket.title,
"description": ticket.description,
"user_id": ticket.user_id,
"category_id": ticket.category_id,
"priority_id": ticket.priority_id,
"status_id": ticket.status_id,
"assigned_operator_id": ticket.assigned_operator_id,
"is_internal": ticket.is_internal,
"closed_at": ticket.closed_at,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at,
"user": {
"id": ticket.user.id,
"first_name": ticket.user.first_name,
"last_name": ticket.user.last_name,
"email": ticket.user.email
} if ticket.user else None,
"assigned_operator": {
"id": ticket.assigned_operator.id,
"first_name": ticket.assigned_operator.first_name,
"last_name": ticket.assigned_operator.last_name,
"email": ticket.assigned_operator.email
} if ticket.assigned_operator else None,
"category": {
"id": ticket.category.id,
"name": ticket.category.name,
"description": ticket.category.description,
"is_active": ticket.category.is_active,
"created_at": ticket.category.created_at,
"updated_at": ticket.category.updated_at
} if ticket.category else None,
"priority": {
"id": ticket.priority.id,
"name": ticket.priority.name,
"description": ticket.priority.description,
"color": ticket.priority.color,
"order": ticket.priority.order,
"created_at": ticket.priority.created_at,
"updated_at": ticket.priority.updated_at
} if ticket.priority else None,
"status": {
"id": ticket.status.id,
"name": ticket.status.name,
"description": ticket.status.description,
"color": ticket.status.color,
"is_final": ticket.status.is_final,
"created_at": ticket.status.created_at,
"updated_at": ticket.status.updated_at
} if ticket.status else None
}
ticket_dicts.append(ticket_dict)
paginated_data = PaginatedResponse.create(
items=ticket_dicts,
total=total,
page=(query_info.skip // query_info.take) + 1,
limit=query_info.take
)
# Format datetime fields based on calendar type
formatted_data = format_datetime_fields(paginated_data.dict(), request)
return success_response(formatted_data, request)
@router.get("/tickets/{ticket_id}", response_model=SuccessResponse)
@require_app_permission("support_operator")
async def get_operator_ticket(
request: Request,
ticket_id: int,
current_user: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""مشاهده تیکت برای اپراتور"""
ticket_repo = TicketRepository(db)
ticket = ticket_repo.get_operator_ticket_with_details(ticket_id)
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="تیکت یافت نشد"
)
# Format datetime fields based on calendar type
ticket_data = TicketResponse.from_orm(ticket).dict()
formatted_data = format_datetime_fields(ticket_data, request)
return success_response(formatted_data, request)
@router.put("/tickets/{ticket_id}/status", response_model=SuccessResponse)
@require_app_permission("support_operator")
async def update_ticket_status(
request: Request,
ticket_id: int,
status_request: UpdateStatusRequest,
current_user: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""تغییر وضعیت تیکت"""
ticket_repo = TicketRepository(db)
ticket = ticket_repo.update_ticket_status(
ticket_id=ticket_id,
status_id=status_request.status_id,
operator_id=status_request.assigned_operator_id or current_user.get_user_id()
)
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="تیکت یافت نشد"
)
# دریافت تیکت با جزئیات
ticket_with_details = ticket_repo.get_operator_ticket_with_details(ticket_id)
# Format datetime fields based on calendar type
ticket_data = TicketResponse.from_orm(ticket_with_details).dict()
formatted_data = format_datetime_fields(ticket_data, request)
return success_response(formatted_data, request)
@router.post("/tickets/{ticket_id}/assign", response_model=SuccessResponse)
@require_app_permission("support_operator")
async def assign_ticket(
request: Request,
ticket_id: int,
assign_request: AssignTicketRequest,
current_user: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""تخصیص تیکت به اپراتور"""
ticket_repo = TicketRepository(db)
ticket = ticket_repo.assign_ticket(ticket_id, assign_request.operator_id)
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="تیکت یافت نشد"
)
# دریافت تیکت با جزئیات
ticket_with_details = ticket_repo.get_operator_ticket_with_details(ticket_id)
# Format datetime fields based on calendar type
ticket_data = TicketResponse.from_orm(ticket_with_details).dict()
formatted_data = format_datetime_fields(ticket_data, request)
return success_response(formatted_data, request)
@router.post("/tickets/{ticket_id}/messages", response_model=SuccessResponse)
@require_app_permission("support_operator")
async def send_operator_message(
request: Request,
ticket_id: int,
message_request: CreateMessageRequest,
current_user: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""ارسال پیام اپراتور به تیکت"""
ticket_repo = TicketRepository(db)
message_repo = MessageRepository(db)
# بررسی وجود تیکت
ticket = ticket_repo.get_operator_ticket_with_details(ticket_id)
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="تیکت یافت نشد"
)
# ایجاد پیام
message = message_repo.create_message(
ticket_id=ticket_id,
sender_id=current_user.get_user_id(),
sender_type="operator",
content=message_request.content,
is_internal=message_request.is_internal
)
# اگر تیکت هنوز به اپراتور تخصیص نشده، آن را تخصیص ده
if not ticket.assigned_operator_id:
ticket_repo.assign_ticket(ticket_id, current_user.get_user_id())
# Format datetime fields based on calendar type
message_data = MessageResponse.from_orm(message).dict()
formatted_data = format_datetime_fields(message_data, request)
return success_response(formatted_data, request)
@router.post("/tickets/{ticket_id}/messages/search", response_model=SuccessResponse)
@require_app_permission("support_operator")
async def search_operator_ticket_messages(
request: Request,
ticket_id: int,
query_info: QueryInfo = Body(...),
current_user: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""جستجو در پیام‌های تیکت برای اپراتور"""
ticket_repo = TicketRepository(db)
message_repo = MessageRepository(db)
# بررسی وجود تیکت
ticket = ticket_repo.get_operator_ticket_with_details(ticket_id)
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="تیکت یافت نشد"
)
# تنظیم فیلدهای قابل جستجو
if not query_info.search_fields:
query_info.search_fields = ["content"]
messages, total = message_repo.get_ticket_messages(ticket_id, query_info)
# تبدیل به dict
message_dicts = []
for message in messages:
message_dict = {
"id": message.id,
"ticket_id": message.ticket_id,
"sender_id": message.sender_id,
"sender_type": message.sender_type,
"content": message.content,
"is_internal": message.is_internal,
"created_at": message.created_at,
"sender": {
"id": message.sender.id,
"first_name": message.sender.first_name,
"last_name": message.sender.last_name,
"email": message.sender.email
} if message.sender else None
}
message_dicts.append(message_dict)
paginated_data = PaginatedResponse.create(
items=message_dicts,
total=total,
page=(query_info.skip // query_info.take) + 1,
limit=query_info.take
)
# Format datetime fields based on calendar type
formatted_data = format_datetime_fields(paginated_data.dict(), request)
return success_response(formatted_data, request)

View file

@ -0,0 +1,29 @@
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.repositories.support.priority_repository import PriorityRepository
from adapters.api.v1.support.schemas import PriorityResponse
from adapters.api.v1.schemas import SuccessResponse
from app.core.responses import success_response, format_datetime_fields
router = APIRouter()
@router.get("", response_model=SuccessResponse)
async def get_priorities(
request: Request,
db: Session = Depends(get_db)
):
"""دریافت لیست اولویت‌ها"""
priority_repo = PriorityRepository(db)
priorities = priority_repo.get_priorities_ordered()
# Convert to dict and format datetime fields
priorities_data = [PriorityResponse.from_orm(priority).dict() for priority in priorities]
formatted_data = format_datetime_fields(priorities_data, request)
return success_response(formatted_data, request)

View file

@ -0,0 +1,134 @@
# Removed __future__ annotations to fix OpenAPI schema generation
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field
from adapters.db.models.support.message import SenderType
from adapters.api.v1.schemas import PaginatedResponse
# Base schemas
class CategoryBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
is_active: bool = True
class PriorityBase(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
description: Optional[str] = None
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
order: int = 0
class StatusBase(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
description: Optional[str] = None
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
is_final: bool = False
class TicketBase(BaseModel):
title: str = Field(..., min_length=1, max_length=255)
description: str = Field(..., min_length=1)
category_id: int
priority_id: int
class MessageBase(BaseModel):
content: str = Field(..., min_length=1)
is_internal: bool = False
# Response schemas
class CategoryResponse(CategoryBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PriorityResponse(PriorityBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class StatusResponse(StatusBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class UserInfo(BaseModel):
id: int
first_name: Optional[str] = None
last_name: Optional[str] = None
email: Optional[str] = None
class Config:
from_attributes = True
class MessageResponse(MessageBase):
id: int
ticket_id: int
sender_id: int
sender_type: SenderType
sender: Optional[UserInfo] = None
created_at: datetime
class Config:
from_attributes = True
class TicketResponse(TicketBase):
id: int
user_id: int
status_id: int
assigned_operator_id: Optional[int] = None
is_internal: bool = False
closed_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
# Related objects
user: Optional[UserInfo] = None
assigned_operator: Optional[UserInfo] = None
category: Optional[CategoryResponse] = None
priority: Optional[PriorityResponse] = None
status: Optional[StatusResponse] = None
messages: Optional[List[MessageResponse]] = None
class Config:
from_attributes = True
# Request schemas
class CreateTicketRequest(TicketBase):
pass
class CreateMessageRequest(MessageBase):
pass
class UpdateStatusRequest(BaseModel):
status_id: int
assigned_operator_id: Optional[int] = None
class AssignTicketRequest(BaseModel):
operator_id: int
# PaginatedResponse is now imported from adapters.api.v1.schemas

View file

@ -0,0 +1,29 @@
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.repositories.support.status_repository import StatusRepository
from adapters.api.v1.support.schemas import StatusResponse
from adapters.api.v1.schemas import SuccessResponse
from app.core.responses import success_response, format_datetime_fields
router = APIRouter()
@router.get("", response_model=SuccessResponse)
async def get_statuses(
request: Request,
db: Session = Depends(get_db)
):
"""دریافت لیست وضعیت‌ها"""
status_repo = StatusRepository(db)
statuses = status_repo.get_all_statuses()
# Convert to dict and format datetime fields
statuses_data = [StatusResponse.from_orm(status).dict() for status in statuses]
formatted_data = format_datetime_fields(statuses_data, request)
return success_response(formatted_data, request)

View file

@ -0,0 +1,256 @@
# Removed __future__ annotations to fix OpenAPI schema generation
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.repositories.support.ticket_repository import TicketRepository
from adapters.db.repositories.support.message_repository import MessageRepository
from adapters.api.v1.schemas import QueryInfo, PaginatedResponse, SuccessResponse
from adapters.api.v1.support.schemas import (
CreateTicketRequest,
CreateMessageRequest,
TicketResponse,
MessageResponse
)
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.responses import success_response, format_datetime_fields
router = APIRouter()
@router.post("/search", response_model=SuccessResponse)
async def search_user_tickets(
request: Request,
query_info: QueryInfo,
current_user: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""جستجو در تیکت‌های کاربر"""
ticket_repo = TicketRepository(db)
# تنظیم فیلدهای قابل جستجو
if not query_info.search_fields:
query_info.search_fields = ["title", "description"]
tickets, total = ticket_repo.get_user_tickets(current_user.get_user_id(), query_info)
# تبدیل به dict
ticket_dicts = []
for ticket in tickets:
ticket_dict = {
"id": ticket.id,
"title": ticket.title,
"description": ticket.description,
"user_id": ticket.user_id,
"category_id": ticket.category_id,
"priority_id": ticket.priority_id,
"status_id": ticket.status_id,
"assigned_operator_id": ticket.assigned_operator_id,
"is_internal": ticket.is_internal,
"closed_at": ticket.closed_at,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at,
"category": {
"id": ticket.category.id,
"name": ticket.category.name,
"description": ticket.category.description,
"is_active": ticket.category.is_active,
"created_at": ticket.category.created_at,
"updated_at": ticket.category.updated_at
} if ticket.category else None,
"priority": {
"id": ticket.priority.id,
"name": ticket.priority.name,
"description": ticket.priority.description,
"color": ticket.priority.color,
"order": ticket.priority.order,
"created_at": ticket.priority.created_at,
"updated_at": ticket.priority.updated_at
} if ticket.priority else None,
"status": {
"id": ticket.status.id,
"name": ticket.status.name,
"description": ticket.status.description,
"color": ticket.status.color,
"is_final": ticket.status.is_final,
"created_at": ticket.status.created_at,
"updated_at": ticket.status.updated_at
} if ticket.status else None
}
ticket_dicts.append(ticket_dict)
paginated_data = PaginatedResponse.create(
items=ticket_dicts,
total=total,
page=(query_info.skip // query_info.take) + 1,
limit=query_info.take
)
# Format datetime fields based on calendar type
formatted_data = format_datetime_fields(paginated_data.dict(), request)
return success_response(formatted_data, request)
@router.post("", response_model=SuccessResponse)
async def create_ticket(
request: Request,
ticket_request: CreateTicketRequest,
current_user: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""ایجاد تیکت جدید"""
ticket_repo = TicketRepository(db)
# ایجاد تیکت
ticket_data = {
"title": ticket_request.title,
"description": ticket_request.description,
"user_id": current_user.get_user_id(),
"category_id": ticket_request.category_id,
"priority_id": ticket_request.priority_id,
"status_id": 1, # وضعیت پیش‌فرض: باز
"is_internal": False
}
ticket = ticket_repo.create(ticket_data)
# ایجاد پیام اولیه
message_repo = MessageRepository(db)
message_repo.create_message(
ticket_id=ticket.id,
sender_id=current_user.get_user_id(),
sender_type="user",
content=ticket_request.description,
is_internal=False
)
# دریافت تیکت با جزئیات
ticket_with_details = ticket_repo.get_ticket_with_details(ticket.id, current_user.get_user_id())
# Format datetime fields based on calendar type
ticket_data = TicketResponse.from_orm(ticket_with_details).dict()
formatted_data = format_datetime_fields(ticket_data, request)
return success_response(formatted_data, request)
@router.get("/{ticket_id}", response_model=SuccessResponse)
async def get_ticket(
request: Request,
ticket_id: int,
current_user: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""مشاهده تیکت"""
ticket_repo = TicketRepository(db)
ticket = ticket_repo.get_ticket_with_details(ticket_id, current_user.get_user_id())
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="تیکت یافت نشد"
)
# Format datetime fields based on calendar type
ticket_data = TicketResponse.from_orm(ticket).dict()
formatted_data = format_datetime_fields(ticket_data, request)
return success_response(formatted_data, request)
@router.post("/{ticket_id}/messages", response_model=SuccessResponse)
async def send_message(
request: Request,
ticket_id: int,
message_request: CreateMessageRequest,
current_user: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""ارسال پیام به تیکت"""
ticket_repo = TicketRepository(db)
message_repo = MessageRepository(db)
# بررسی وجود تیکت
ticket = ticket_repo.get_ticket_with_details(ticket_id, current_user.get_user_id())
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="تیکت یافت نشد"
)
# ایجاد پیام
message = message_repo.create_message(
ticket_id=ticket_id,
sender_id=current_user.get_user_id(),
sender_type="user",
content=message_request.content,
is_internal=message_request.is_internal
)
# Format datetime fields based on calendar type
message_data = MessageResponse.from_orm(message).dict()
formatted_data = format_datetime_fields(message_data, request)
return success_response(formatted_data, request)
@router.post("/{ticket_id}/messages/search", response_model=SuccessResponse)
async def search_ticket_messages(
request: Request,
ticket_id: int,
query_info: QueryInfo,
current_user: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""جستجو در پیام‌های تیکت"""
ticket_repo = TicketRepository(db)
message_repo = MessageRepository(db)
# بررسی وجود تیکت
ticket = ticket_repo.get_ticket_with_details(ticket_id, current_user.get_user_id())
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="تیکت یافت نشد"
)
# تنظیم فیلدهای قابل جستجو
if not query_info.search_fields:
query_info.search_fields = ["content"]
messages, total = message_repo.get_ticket_messages(ticket_id, query_info)
# تبدیل به dict
message_dicts = []
for message in messages:
message_dict = {
"id": message.id,
"ticket_id": message.ticket_id,
"sender_id": message.sender_id,
"sender_type": message.sender_type,
"content": message.content,
"is_internal": message.is_internal,
"created_at": message.created_at,
"sender": {
"id": message.sender.id,
"first_name": message.sender.first_name,
"last_name": message.sender.last_name,
"email": message.sender.email
} if message.sender else None
}
message_dicts.append(message_dict)
paginated_data = PaginatedResponse.create(
items=message_dicts,
total=total,
page=(query_info.skip // query_info.take) + 1,
limit=query_info.take
)
# Format datetime fields based on calendar type
formatted_data = format_datetime_fields(paginated_data.dict(), request)
return success_response(formatted_data, request)

View file

@ -0,0 +1,49 @@
from typing import Dict, Any, List
from fastapi import APIRouter, Depends, Request
from adapters.api.v1.schemas import SuccessResponse
from adapters.db.session import get_db # noqa: F401 (kept for consistency/future use)
from app.core.responses import success_response
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from sqlalchemy.orm import Session # noqa: F401
router = APIRouter(prefix="/tax-types", tags=["tax-types"])
def _static_tax_types() -> List[Dict[str, Any]]:
titles = [
"دارو",
"دخانیات",
"موبایل",
"لوازم خانگی برقی",
"قطعات مصرفی و یدکی وسایل نقلیه",
"فراورده ها و مشتقات نفتی و گازی و پتروشیمیایی",
"طلا اعم از شمش، مسکوکات و مصنوعات زینتی",
"منسوجات و پوشاک",
"اسباب بازی",
"دام زنده، گوشت سفید و قرمز",
"محصولات اساسی کشاورزی",
"سایر کالا ها",
]
return [{"id": idx + 1, "title": t} for idx, t in enumerate(titles)]
@router.get(
"/business/{business_id}",
summary="لیست نوع‌های مالیات",
description="دریافت لیست نوع‌های مالیات (ثابت)",
response_model=SuccessResponse,
)
@require_business_access()
def list_tax_types(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
) -> Dict[str, Any]:
# Currently returns a static list; later can be sourced from DB if needed
items = _static_tax_types()
return success_response(items, request)

View file

@ -0,0 +1,387 @@
from fastapi import APIRouter, Depends, Request, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from decimal import Decimal
from adapters.db.session import get_db
from adapters.db.models.tax_unit import TaxUnit
from adapters.api.v1.schemas import SuccessResponse
from app.core.responses import success_response, format_datetime_fields
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from pydantic import BaseModel, Field
router = APIRouter(prefix="/tax-units", tags=["tax-units"])
alias_router = APIRouter(prefix="/units", tags=["units"])
class TaxUnitCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255, description="نام واحد مالیاتی")
code: str = Field(..., min_length=1, max_length=64, description="کد واحد مالیاتی")
description: Optional[str] = Field(default=None, description="توضیحات")
tax_rate: Optional[Decimal] = Field(default=None, ge=0, le=100, description="نرخ مالیات (درصد)")
is_active: bool = Field(default=True, description="وضعیت فعال/غیرفعال")
class TaxUnitUpdateRequest(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام واحد مالیاتی")
code: Optional[str] = Field(default=None, min_length=1, max_length=64, description="کد واحد مالیاتی")
description: Optional[str] = Field(default=None, description="توضیحات")
tax_rate: Optional[Decimal] = Field(default=None, ge=0, le=100, description="نرخ مالیات (درصد)")
is_active: Optional[bool] = Field(default=None, description="وضعیت فعال/غیرفعال")
class TaxUnitResponse(BaseModel):
id: int
business_id: int
name: str
code: str
description: Optional[str] = None
tax_rate: Optional[Decimal] = None
is_active: bool
created_at: str
updated_at: str
class Config:
from_attributes = True
@router.get("/business/{business_id}",
summary="لیست واحدهای مالیاتی کسب‌وکار",
description="دریافت لیست واحدهای مالیاتی یک کسب‌وکار",
response_model=SuccessResponse,
responses={
200: {
"description": "لیست واحدهای مالیاتی با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "لیست واحدهای مالیاتی دریافت شد",
"data": [
{
"id": 1,
"business_id": 1,
"name": "مالیات بر ارزش افزوده",
"code": "VAT",
"description": "مالیات بر ارزش افزوده 9 درصد",
"tax_rate": 9.0,
"is_active": True,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز به کسب‌وکار"
},
404: {
"description": "کسب‌وکار یافت نشد"
}
}
)
@alias_router.get("/business/{business_id}")
@require_business_access()
def get_tax_units(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""دریافت لیست واحدهای مالیاتی یک کسب‌وکار"""
# Query tax units for the business
tax_units = db.query(TaxUnit).filter(
TaxUnit.business_id == business_id
).order_by(TaxUnit.name).all()
# Convert to response format
tax_unit_dicts = []
for tax_unit in tax_units:
tax_unit_dict = {
"id": tax_unit.id,
"business_id": tax_unit.business_id,
"name": tax_unit.name,
"code": tax_unit.code,
"description": tax_unit.description,
"tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None,
"is_active": tax_unit.is_active,
"created_at": tax_unit.created_at.isoformat(),
"updated_at": tax_unit.updated_at.isoformat()
}
tax_unit_dicts.append(format_datetime_fields(tax_unit_dict, request))
return success_response(tax_unit_dicts, request)
@router.post("/business/{business_id}",
summary="ایجاد واحد مالیاتی جدید",
description="ایجاد یک واحد مالیاتی جدید برای کسب‌وکار",
response_model=SuccessResponse,
responses={
201: {
"description": "واحد مالیاتی با موفقیت ایجاد شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "واحد مالیاتی با موفقیت ایجاد شد",
"data": {
"id": 1,
"business_id": 1,
"name": "مالیات بر ارزش افزوده",
"code": "VAT",
"description": "مالیات بر ارزش افزوده 9 درصد",
"tax_rate": 9.0,
"is_active": True,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها"
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز به کسب‌وکار"
},
404: {
"description": "کسب‌وکار یافت نشد"
}
}
)
@alias_router.post("/business/{business_id}")
@require_business_access()
def create_tax_unit(
request: Request,
business_id: int,
tax_unit_data: TaxUnitCreateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""ایجاد واحد مالیاتی جدید"""
# Check if code already exists for this business
existing_tax_unit = db.query(TaxUnit).filter(
TaxUnit.business_id == business_id,
TaxUnit.code == tax_unit_data.code
).first()
if existing_tax_unit:
raise HTTPException(
status_code=400,
detail="کد واحد مالیاتی قبلاً استفاده شده است"
)
# Create new tax unit
tax_unit = TaxUnit(
business_id=business_id,
name=tax_unit_data.name,
code=tax_unit_data.code,
description=tax_unit_data.description,
tax_rate=tax_unit_data.tax_rate,
is_active=tax_unit_data.is_active
)
db.add(tax_unit)
db.commit()
db.refresh(tax_unit)
# Convert to response format
tax_unit_dict = {
"id": tax_unit.id,
"business_id": tax_unit.business_id,
"name": tax_unit.name,
"code": tax_unit.code,
"description": tax_unit.description,
"tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None,
"is_active": tax_unit.is_active,
"created_at": tax_unit.created_at.isoformat(),
"updated_at": tax_unit.updated_at.isoformat()
}
formatted_response = format_datetime_fields(tax_unit_dict, request)
return success_response(formatted_response, request)
@router.put("/{tax_unit_id}",
summary="به‌روزرسانی واحد مالیاتی",
description="به‌روزرسانی اطلاعات یک واحد مالیاتی",
response_model=SuccessResponse,
responses={
200: {
"description": "واحد مالیاتی با موفقیت به‌روزرسانی شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "واحد مالیاتی با موفقیت به‌روزرسانی شد",
"data": {
"id": 1,
"business_id": 1,
"name": "مالیات بر ارزش افزوده",
"code": "VAT",
"description": "مالیات بر ارزش افزوده 9 درصد",
"tax_rate": 9.0,
"is_active": True,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
}
}
}
},
400: {
"description": "خطا در اعتبارسنجی داده‌ها"
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز به کسب‌وکار"
},
404: {
"description": "واحد مالیاتی یافت نشد"
}
}
)
@alias_router.put("/{tax_unit_id}")
@require_business_access()
def update_tax_unit(
request: Request,
tax_unit_id: int,
tax_unit_data: TaxUnitUpdateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""به‌روزرسانی واحد مالیاتی"""
# Find the tax unit
tax_unit = db.query(TaxUnit).filter(TaxUnit.id == tax_unit_id).first()
if not tax_unit:
raise HTTPException(status_code=404, detail="واحد مالیاتی یافت نشد")
# Check business access
if tax_unit.business_id not in ctx.business_ids:
raise HTTPException(status_code=403, detail="دسترسی غیرمجاز به این کسب‌وکار")
# Check if new code conflicts with existing ones
if tax_unit_data.code and tax_unit_data.code != tax_unit.code:
existing_tax_unit = db.query(TaxUnit).filter(
TaxUnit.business_id == tax_unit.business_id,
TaxUnit.code == tax_unit_data.code,
TaxUnit.id != tax_unit_id
).first()
if existing_tax_unit:
raise HTTPException(
status_code=400,
detail="کد واحد مالیاتی قبلاً استفاده شده است"
)
# Update fields
update_data = tax_unit_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(tax_unit, field, value)
db.commit()
db.refresh(tax_unit)
# Convert to response format
tax_unit_dict = {
"id": tax_unit.id,
"business_id": tax_unit.business_id,
"name": tax_unit.name,
"code": tax_unit.code,
"description": tax_unit.description,
"tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None,
"is_active": tax_unit.is_active,
"created_at": tax_unit.created_at.isoformat(),
"updated_at": tax_unit.updated_at.isoformat()
}
formatted_response = format_datetime_fields(tax_unit_dict, request)
return success_response(formatted_response, request)
@router.delete("/{tax_unit_id}",
summary="حذف واحد مالیاتی",
description="حذف یک واحد مالیاتی",
response_model=SuccessResponse,
responses={
200: {
"description": "واحد مالیاتی با موفقیت حذف شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "واحد مالیاتی با موفقیت حذف شد",
"data": None
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز به کسب‌وکار"
},
404: {
"description": "واحد مالیاتی یافت نشد"
},
409: {
"description": "امکان حذف واحد مالیاتی به دلیل استفاده در محصولات وجود ندارد"
}
}
)
@alias_router.delete("/{tax_unit_id}")
@require_business_access()
def delete_tax_unit(
request: Request,
tax_unit_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""حذف واحد مالیاتی"""
# Find the tax unit
tax_unit = db.query(TaxUnit).filter(TaxUnit.id == tax_unit_id).first()
if not tax_unit:
raise HTTPException(status_code=404, detail="واحد مالیاتی یافت نشد")
# Check business access
if tax_unit.business_id not in ctx.business_ids:
raise HTTPException(status_code=403, detail="دسترسی غیرمجاز به این کسب‌وکار")
# Check if tax unit is used in products
from adapters.db.models.product import Product
products_using_tax_unit = db.query(Product).filter(
Product.tax_unit_id == tax_unit_id
).count()
if products_using_tax_unit > 0:
raise HTTPException(
status_code=409,
detail=f"امکان حذف واحد مالیاتی به دلیل استفاده در {products_using_tax_unit} محصول وجود ندارد"
)
# Delete the tax unit
db.delete(tax_unit)
db.commit()
return success_response(None, request)

View file

@ -0,0 +1,362 @@
# Removed __future__ annotations to fix OpenAPI schema generation
from fastapi import APIRouter, Depends, Request, Query
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.repositories.user_repo import UserRepository
from adapters.api.v1.schemas import QueryInfo, SuccessResponse, UsersListResponse, UsersSummaryResponse, UserResponse
from app.core.responses import success_response, format_datetime_fields
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_user_management
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/search",
summary="لیست کاربران با فیلتر پیشرفته",
description="دریافت لیست کاربران با قابلیت فیلتر، جستجو، مرتب‌سازی و صفحه‌بندی. نیاز به مجوز usermanager در سطح اپلیکیشن دارد.",
response_model=SuccessResponse,
responses={
200: {
"description": "لیست کاربران با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "لیست کاربران دریافت شد",
"data": {
"items": [
{
"id": 1,
"email": "user@example.com",
"mobile": "09123456789",
"first_name": "احمد",
"last_name": "احمدی",
"is_active": True,
"referral_code": "ABC123",
"created_at": "2024-01-01T00:00:00Z"
}
],
"pagination": {
"total": 1,
"page": 1,
"per_page": 10,
"total_pages": 1,
"has_next": False,
"has_prev": False
}
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز - نیاز به مجوز usermanager",
"content": {
"application/json": {
"example": {
"success": False,
"message": "Missing app permission: user_management",
"error_code": "FORBIDDEN"
}
}
}
}
}
)
@require_user_management()
def list_users(
request: Request,
query_info: QueryInfo,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
دریافت لیست کاربران با قابلیت فیلتر، جستجو، مرتبسازی و صفحهبندی
پارامترهای QueryInfo:
- sort_by: فیلد مرتبسازی (مثال: created_at, first_name)
- sort_desc: ترتیب نزولی (true/false)
- take: تعداد رکورد در هر صفحه (پیشفرض: 10)
- skip: تعداد رکورد صرفنظر شده (پیشفرض: 0)
- search: عبارت جستجو
- search_fields: فیلدهای جستجو (مثال: ["first_name", "last_name", "email"])
- filters: آرایه فیلترها با ساختار:
[
{
"property": "is_active",
"operator": "=",
"value": true
},
{
"property": "first_name",
"operator": "*",
"value": "احمد"
}
]
عملگرهای پشتیبانی شده:
- = : برابر
- > : بزرگتر از
- >= : بزرگتر یا مساوی
- < : کوچکتر از
- <= : کوچکتر یا مساوی
- != : نامساوی
- * : شامل (contains)
- ?* : خاتمه یابد (ends with)
- *? : شروع شود (starts with)
- in : در بین مقادیر آرایه
"""
repo = UserRepository(db)
users, total = repo.query_with_filters(query_info)
# تبدیل User objects به dictionary
user_dicts = [repo.to_dict(user) for user in users]
# فرمت کردن تاریخ‌ها
formatted_users = [format_datetime_fields(user_dict, request) for user_dict in user_dicts]
# محاسبه اطلاعات صفحه‌بندی
page = (query_info.skip // query_info.take) + 1
total_pages = (total + query_info.take - 1) // query_info.take
response_data = {
"items": formatted_users,
"pagination": {
"total": total,
"page": page,
"per_page": query_info.take,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1
},
"query_info": {
"sort_by": query_info.sort_by,
"sort_desc": query_info.sort_desc,
"search": query_info.search,
"search_fields": query_info.search_fields,
"filters": [{"property": f.property, "operator": f.operator, "value": f.value} for f in (query_info.filters or [])]
}
}
return success_response(response_data, request)
@router.get("",
summary="لیست ساده کاربران",
description="دریافت لیست ساده کاربران. نیاز به مجوز usermanager در سطح اپلیکیشن دارد.",
response_model=SuccessResponse,
responses={
200: {
"description": "لیست کاربران با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "لیست کاربران دریافت شد",
"data": [
{
"id": 1,
"email": "user@example.com",
"mobile": "09123456789",
"first_name": "احمد",
"last_name": "احمدی",
"is_active": True,
"referral_code": "ABC123",
"created_at": "2024-01-01T00:00:00Z"
}
]
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز - نیاز به مجوز usermanager",
"content": {
"application/json": {
"example": {
"success": False,
"message": "Missing app permission: user_management",
"error_code": "FORBIDDEN"
}
}
}
}
}
)
@require_user_management()
def list_users_simple(
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
limit: int = Query(10, ge=1, le=100, description="تعداد رکورد در هر صفحه"),
offset: int = Query(0, ge=0, description="تعداد رکورد صرف‌نظر شده")
):
"""دریافت لیست ساده کاربران"""
repo = UserRepository(db)
# Create basic query info
query_info = QueryInfo(take=limit, skip=offset)
users, total = repo.query_with_filters(query_info)
# تبدیل User objects به dictionary
user_dicts = [repo.to_dict(user) for user in users]
# فرمت کردن تاریخ‌ها
formatted_users = [format_datetime_fields(user_dict, None) for user_dict in user_dicts]
return success_response(formatted_users, None)
@router.get("/{user_id}",
summary="دریافت اطلاعات یک کاربر",
description="دریافت اطلاعات کامل یک کاربر بر اساس شناسه. نیاز به مجوز usermanager در سطح اپلیکیشن دارد.",
response_model=SuccessResponse,
responses={
200: {
"description": "اطلاعات کاربر با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "اطلاعات کاربر دریافت شد",
"data": {
"id": 1,
"email": "user@example.com",
"mobile": "09123456789",
"first_name": "احمد",
"last_name": "احمدی",
"is_active": True,
"referral_code": "ABC123",
"created_at": "2024-01-01T00:00:00Z"
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز - نیاز به مجوز usermanager",
"content": {
"application/json": {
"example": {
"success": False,
"message": "Missing app permission: user_management",
"error_code": "FORBIDDEN"
}
}
}
},
404: {
"description": "کاربر یافت نشد",
"content": {
"application/json": {
"example": {
"success": False,
"message": "کاربر یافت نشد",
"error_code": "USER_NOT_FOUND"
}
}
}
}
}
)
@require_user_management()
def get_user(
user_id: int,
request: Request,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""دریافت اطلاعات یک کاربر بر اساس ID"""
repo = UserRepository(db)
user = repo.get_by_id(user_id)
if not user:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="کاربر یافت نشد")
user_dict = repo.to_dict(user)
formatted_user = format_datetime_fields(user_dict, request)
return success_response(formatted_user, request)
@router.get("/stats/summary",
summary="آمار کلی کاربران",
description="دریافت آمار کلی کاربران شامل تعداد کل، فعال و غیرفعال. نیاز به مجوز usermanager در سطح اپلیکیشن دارد.",
response_model=SuccessResponse,
responses={
200: {
"description": "آمار کاربران با موفقیت دریافت شد",
"content": {
"application/json": {
"example": {
"success": True,
"message": "آمار کاربران دریافت شد",
"data": {
"total_users": 100,
"active_users": 85,
"inactive_users": 15,
"active_percentage": 85.0
}
}
}
}
},
401: {
"description": "کاربر احراز هویت نشده است"
},
403: {
"description": "دسترسی غیرمجاز - نیاز به مجوز usermanager",
"content": {
"application/json": {
"example": {
"success": False,
"message": "Missing app permission: user_management",
"error_code": "FORBIDDEN"
}
}
}
}
}
)
@require_user_management()
def get_users_summary(
request: Request,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""دریافت آمار کلی کاربران"""
repo = UserRepository(db)
# تعداد کل کاربران
total_users = repo.count_all()
# تعداد کاربران فعال
active_users = repo.query_with_filters(QueryInfo(
filters=[{"property": "is_active", "operator": "=", "value": True}]
))[1]
# تعداد کاربران غیرفعال
inactive_users = total_users - active_users
response_data = {
"total_users": total_users,
"active_users": active_users,
"inactive_users": inactive_users,
"active_percentage": round((active_users / total_users * 100), 2) if total_users > 0 else 0
}
return success_response(response_data, request)

View file

@ -0,0 +1,38 @@
from adapters.db.session import Base # re-export Base for Alembic
# Import models to register with SQLAlchemy metadata
from .user import User # noqa: F401
from .api_key import ApiKey # noqa: F401
from .captcha import Captcha # noqa: F401
from .password_reset import PasswordReset # noqa: F401
from .business import Business # noqa: F401
from .business_permission import BusinessPermission # noqa: F401
from .person import Person, PersonBankAccount # noqa: F401
# Business user models removed - using business_permissions instead
# Import support models
from .support import * # noqa: F401, F403
# Import file storage models
from .file_storage import *
# Import email config models
from .email_config import EmailConfig # noqa: F401, F403
# Accounting / Fiscal models
from .fiscal_year import FiscalYear # noqa: F401
# Currency models
from .currency import Currency, BusinessCurrency # noqa: F401
# Documents
from .document import Document # noqa: F401
from .document_line import DocumentLine # noqa: F401
from .account import Account # noqa: F401
from .category import BusinessCategory # noqa: F401
from .product_attribute import ProductAttribute # noqa: F401
from .product import Product # noqa: F401
from .price_list import PriceList, PriceItem # noqa: F401
from .product_attribute_link import ProductAttributeLink # noqa: F401
from .tax_unit import TaxUnit # noqa: F401

View file

@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class Account(Base):
__tablename__ = "accounts"
__table_args__ = (
UniqueConstraint('business_id', 'code', name='uq_accounts_business_code'),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
business_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=True, index=True)
account_type: Mapped[str] = mapped_column(String(50), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
business = relationship("Business", back_populates="accounts")
parent = relationship("Account", remote_side="Account.id", back_populates="children")
children = relationship("Account", back_populates="parent", cascade="all, delete-orphan")
document_lines = relationship("DocumentLine", back_populates="account")

View file

@ -0,0 +1,28 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
key_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
key_type: Mapped[str] = mapped_column(String(16), nullable=False) # "session" | "personal"
name: Mapped[str | None] = mapped_column(String(100), nullable=True)
scopes: Mapped[str | None] = mapped_column(String(500), nullable=True)
device_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,64 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class BusinessType(str, Enum):
"""نوع کسب و کار"""
COMPANY = "شرکت" # شرکت
SHOP = "مغازه" # مغازه
STORE = "فروشگاه" # فروشگاه
UNION = "اتحادیه" # اتحادیه
CLUB = "باشگاه" # باشگاه
INSTITUTE = "موسسه" # موسسه
INDIVIDUAL = "شخصی" # شخصی
class BusinessField(str, Enum):
"""زمینه فعالیت کسب و کار"""
MANUFACTURING = "تولیدی" # تولیدی
TRADING = "بازرگانی" # بازرگانی
SERVICE = "خدماتی" # خدماتی
OTHER = "سایر" # سایر
class Business(Base):
__tablename__ = "businesses"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
business_type: Mapped[BusinessType] = mapped_column(SQLEnum(BusinessType), nullable=False)
business_field: Mapped[BusinessField] = mapped_column(SQLEnum(BusinessField), nullable=False)
owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
default_currency_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=True, index=True)
# فیلدهای جدید
address: Mapped[str | None] = mapped_column(Text, nullable=True)
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
national_id: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
registration_number: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
economic_id: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
# فیلدهای جغرافیایی
country: Mapped[str | None] = mapped_column(String(100), nullable=True)
province: Mapped[str | None] = mapped_column(String(100), nullable=True)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
postal_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
persons: Mapped[list["Person"]] = relationship("Person", back_populates="business", cascade="all, delete-orphan")
fiscal_years = relationship("FiscalYear", back_populates="business", cascade="all, delete-orphan")
currencies = relationship("Currency", secondary="business_currencies", back_populates="businesses")
default_currency = relationship("Currency", foreign_keys="[Business.default_currency_id]", uselist=False)
documents = relationship("Document", back_populates="business", cascade="all, delete-orphan")
accounts = relationship("Account", back_populates="business", cascade="all, delete-orphan")

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Integer, ForeignKey, JSON, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class BusinessPermission(Base):
__tablename__ = "business_permissions"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
business_permissions: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,20 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class Captcha(Base):
__tablename__ = "captchas"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
code_hash: Mapped[str] = mapped_column(String(128), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
attempts: Mapped[int] = mapped_column(default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, Boolean, ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class BusinessCategory(Base):
"""
دستهبندیهای کالا/خدمت برای هر کسبوکار با ساختار درختی
- عناوین چندزبانه در فیلد JSON `title_translations` نگهداری میشود
- نوع دستهبندی: product | service
"""
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True)
# فیلد type حذف شده است (در مهاجرت بعدی)
title_translations: Mapped[dict] = mapped_column(JSON, nullable=False, default={})
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
parent = relationship("BusinessCategory", remote_side=[id], backref="children")

View file

@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class Currency(Base):
__tablename__ = "currencies"
__table_args__ = (
UniqueConstraint('name', name='uq_currencies_name'),
UniqueConstraint('code', name='uq_currencies_code'),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
symbol: Mapped[str] = mapped_column(String(16), nullable=False)
code: Mapped[str] = mapped_column(String(16), nullable=False) # نام کوتاه
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
businesses = relationship("Business", secondary="business_currencies", back_populates="currencies")
documents = relationship("Document", back_populates="currency")
class BusinessCurrency(Base):
__tablename__ = "business_currencies"
__table_args__ = (
UniqueConstraint('business_id', 'currency_id', name='uq_business_currencies_business_currency'),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="CASCADE"), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,37 @@
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import String, Integer, DateTime, Boolean, ForeignKey, JSON, Date, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class Document(Base):
__tablename__ = "documents"
__table_args__ = (
UniqueConstraint('business_id', 'code', name='uq_documents_business_code'),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
created_by_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True)
registered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
document_date: Mapped[date] = mapped_column(Date, nullable=False)
document_type: Mapped[str] = mapped_column(String(50), nullable=False)
is_proforma: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
extra_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
developer_settings: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
business = relationship("Business", back_populates="documents")
currency = relationship("Currency", back_populates="documents")
created_by = relationship("User", foreign_keys=[created_by_user_id])
lines = relationship("DocumentLine", back_populates="document", cascade="all, delete-orphan")

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Integer, DateTime, ForeignKey, JSON, Text, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class DocumentLine(Base):
__tablename__ = "document_lines"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
document_id: Mapped[int] = mapped_column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=True, index=True)
debit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
credit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
extra_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
developer_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
document = relationship("Document", back_populates="lines")
account = relationship("Account", back_populates="document_lines")

View file

@ -0,0 +1,27 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class EmailConfig(Base):
__tablename__ = "email_configs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
smtp_host: Mapped[str] = mapped_column(String(255), nullable=False)
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False)
smtp_username: Mapped[str] = mapped_column(String(255), nullable=False)
smtp_password: Mapped[str] = mapped_column(String(255), nullable=False) # Should be encrypted
use_tls: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
use_ssl: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
from_email: Mapped[str] = mapped_column(String(255), nullable=False)
from_name: Mapped[str] = mapped_column(String(100), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,71 @@
from sqlalchemy import Column, String, Integer, DateTime, Boolean, Text, ForeignKey, JSON, BigInteger
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from adapters.db.session import Base
class FileStorage(Base):
__tablename__ = "file_storage"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
original_name = Column(String(255), nullable=False)
stored_name = Column(String(255), nullable=False)
file_path = Column(String(500), nullable=False)
file_size = Column(Integer, nullable=False)
mime_type = Column(String(100), nullable=False)
storage_type = Column(String(20), nullable=False) # local, ftp
storage_config_id = Column(String(36), ForeignKey("storage_configs.id"), nullable=True)
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
module_context = Column(String(50), nullable=False) # tickets, accounting, business_logo, etc.
context_id = Column(String(36), nullable=True) # ticket_id, document_id, etc.
developer_data = Column(JSON, nullable=True)
checksum = Column(String(64), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
is_temporary = Column(Boolean, default=False, nullable=False)
is_verified = Column(Boolean, default=False, nullable=False)
verification_token = Column(String(100), nullable=True)
last_verified_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
deleted_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
uploader = relationship("User", foreign_keys=[uploaded_by])
storage_config = relationship("StorageConfig", foreign_keys=[storage_config_id])
class StorageConfig(Base):
__tablename__ = "storage_configs"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(100), nullable=False)
storage_type = Column(String(20), nullable=False) # local, ftp
is_default = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
config_data = Column(JSON, nullable=False)
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
creator = relationship("User", foreign_keys=[created_by])
class FileVerification(Base):
__tablename__ = "file_verifications"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
file_id = Column(String(36), ForeignKey("file_storage.id"), nullable=False)
module_name = Column(String(50), nullable=False)
verification_token = Column(String(100), nullable=False)
verified_at = Column(DateTime(timezone=True), nullable=True)
verified_by = Column(Integer, ForeignKey("users.id"), nullable=True)
verification_data = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
file = relationship("FileStorage", foreign_keys=[file_id])
verifier = relationship("User", foreign_keys=[verified_by])

View file

@ -0,0 +1,26 @@
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import String, Date, DateTime, Integer, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class FiscalYear(Base):
__tablename__ = "fiscal_years"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False)
is_last: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
business = relationship("Business", back_populates="fiscal_years")

View file

@ -0,0 +1,21 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class PasswordReset(Base):
__tablename__ = "password_resets"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,100 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text, UniqueConstraint, Numeric, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class PersonType(str, Enum):
"""نوع شخص"""
CUSTOMER = "مشتری" # مشتری
MARKETER = "بازاریاب" # بازاریاب
EMPLOYEE = "کارمند" # کارمند
SUPPLIER = "تامین‌کننده" # تامین‌کننده
PARTNER = "همکار" # همکار
SELLER = "فروشنده" # فروشنده
SHAREHOLDER = "سهامدار" # سهامدار
class Person(Base):
__tablename__ = "persons"
__table_args__ = (
UniqueConstraint('business_id', 'code', name='uq_persons_business_code'),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
# اطلاعات پایه
code: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="کد یکتا در هر کسب و کار")
alias_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام مستعار (الزامی)")
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام")
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام خانوادگی")
person_type: Mapped[PersonType] = mapped_column(
SQLEnum(PersonType, values_callable=lambda obj: [e.value for e in obj], name="person_type_enum"),
nullable=False,
comment="نوع شخص"
)
person_types: Mapped[str | None] = mapped_column(Text, nullable=True, comment="لیست انواع شخص به صورت JSON")
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت")
payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شناسه پرداخت")
# سهام
share_count: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="تعداد سهام (فقط برای سهامدار)")
# تنظیمات پورسانت برای بازاریاب/فروشنده
commission_sale_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="درصد پورسانت از فروش")
commission_sales_return_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="درصد پورسانت از برگشت از فروش")
commission_sales_amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True, comment="مبلغ فروش مبنا برای پورسانت")
commission_sales_return_amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True, comment="مبلغ برگشت از فروش مبنا برای پورسانت")
commission_exclude_discounts: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="عدم محاسبه تخفیف در پورسانت")
commission_exclude_additions_deductions: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="عدم محاسبه اضافات و کسورات فاکتور در پورسانت")
commission_post_in_invoice_document: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="ثبت پورسانت در سند حسابداری فاکتور")
# اطلاعات اقتصادی
national_id: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True, comment="شناسه ملی")
registration_number: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="شماره ثبت")
economic_id: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="شناسه اقتصادی")
# اطلاعات تماس
country: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="کشور")
province: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="استان")
city: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شهرستان")
address: Mapped[str | None] = mapped_column(Text, nullable=True, comment="آدرس")
postal_code: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="کد پستی")
phone: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="تلفن")
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="موبایل")
fax: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="فکس")
email: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="پست الکترونیکی")
website: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="وب‌سایت")
# زمان‌بندی
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
business: Mapped["Business"] = relationship("Business", back_populates="persons")
bank_accounts: Mapped[list["PersonBankAccount"]] = relationship("PersonBankAccount", back_populates="person", cascade="all, delete-orphan")
class PersonBankAccount(Base):
__tablename__ = "person_bank_accounts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
person_id: Mapped[int] = mapped_column(Integer, ForeignKey("persons.id", ondelete="CASCADE"), nullable=False, index=True)
# اطلاعات حساب بانکی
bank_name: Mapped[str] = mapped_column(String(255), nullable=False, comment="نام بانک")
account_number: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="شماره حساب")
card_number: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="شماره کارت")
sheba_number: Mapped[str | None] = mapped_column(String(30), nullable=True, comment="شماره شبا")
# زمان‌بندی
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
person: Mapped["Person"] = relationship("Person", back_populates="bank_accounts")

View file

@ -0,0 +1,51 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
String,
Integer,
DateTime,
ForeignKey,
UniqueConstraint,
Boolean,
Numeric,
)
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class PriceList(Base):
__tablename__ = "price_lists"
__table_args__ = (
UniqueConstraint("business_id", "name", name="uq_price_lists_business_name"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
class PriceItem(Base):
__tablename__ = "price_items"
__table_args__ = (
UniqueConstraint("price_list_id", "product_id", "unit_id", "tier_name", "min_qty", "currency_id", name="uq_price_items_unique_tier_currency"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
price_list_id: Mapped[int] = mapped_column(Integer, ForeignKey("price_lists.id", ondelete="CASCADE"), nullable=False, index=True)
product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True)
unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
tier_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="نام پله قیمت (تکی/عمده/همکار/...)" )
min_qty: Mapped[Decimal] = mapped_column(Numeric(18, 3), nullable=False, default=0)
price: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,87 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from enum import Enum
from sqlalchemy import (
String,
Integer,
DateTime,
Text,
ForeignKey,
UniqueConstraint,
Boolean,
Numeric,
Enum as SQLEnum,
)
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class ProductItemType(str, Enum):
PRODUCT = "کالا"
SERVICE = "خدمت"
class Product(Base):
"""
موجودیت کالا/خدمت در سطح هر کسبوکار
- کد دستی/اتوماتیک یکتا در هر کسبوکار
- پشتیبانی از مالیات فروش/خرید، کنترل موجودی و واحدها
- اتصال به دستهبندیها و ویژگیها (ویژگیها از طریق جدول لینک)
"""
__tablename__ = "products"
__table_args__ = (
UniqueConstraint("business_id", "code", name="uq_products_business_code"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
item_type: Mapped[ProductItemType] = mapped_column(
SQLEnum(ProductItemType, values_callable=lambda obj: [e.value for e in obj], name="product_item_type_enum"),
nullable=False,
default=ProductItemType.PRODUCT,
comment="نوع آیتم (کالا/خدمت)",
)
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد یکتا در هر کسب‌وکار")
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# دسته‌بندی (اختیاری)
category_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True)
# واحدها
main_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
secondary_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
unit_conversion_factor: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True)
# قیمت‌های پایه (نمایشی)
base_sales_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
base_sales_note: Mapped[str | None] = mapped_column(Text, nullable=True)
base_purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
base_purchase_note: Mapped[str | None] = mapped_column(Text, nullable=True)
# کنترل موجودی
track_inventory: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
reorder_point: Mapped[int | None] = mapped_column(Integer, nullable=True)
min_order_qty: Mapped[int | None] = mapped_column(Integer, nullable=True)
lead_time_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
# مالیات
is_sales_taxable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_purchase_taxable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
sales_tax_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
purchase_tax_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
tax_type_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
tax_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
tax_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, Text, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class ProductAttribute(Base):
"""
ویژگیهای کالا/خدمت در سطح هر کسبوکار
- عنوان و توضیحات ساده (بدون چندزبانه)
- هر عنوان در هر کسبوکار یکتا باشد
"""
__tablename__ = "product_attributes"
__table_args__ = (
UniqueConstraint('business_id', 'title', name='uq_product_attributes_business_title'),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Integer, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class ProductAttributeLink(Base):
"""لینک بین محصول و ویژگی‌ها (چندبه‌چند)"""
__tablename__ = "product_attribute_links"
__table_args__ = (
UniqueConstraint("product_id", "attribute_id", name="uq_product_attribute_links_unique"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True)
attribute_id: Mapped[int] = mapped_column(Integer, ForeignKey("product_attributes.id", ondelete="CASCADE"), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,8 @@
from adapters.db.session import Base # re-export Base for Alembic
# Import support models to register with SQLAlchemy metadata
from .category import Category # noqa: F401
from .priority import Priority # noqa: F401
from .status import Status # noqa: F401
from .ticket import Ticket # noqa: F401
from .message import Message # noqa: F401

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class Category(Base):
"""دسته‌بندی تیکت‌های پشتیبانی"""
__tablename__ = "support_categories"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
tickets = relationship("Ticket", back_populates="category")

View file

@ -0,0 +1,35 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from sqlalchemy import String, DateTime, Integer, ForeignKey, Text, Boolean, Enum as SQLEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class SenderType(str, Enum):
"""نوع فرستنده پیام"""
USER = "user"
OPERATOR = "operator"
SYSTEM = "system"
class Message(Base):
"""پیام‌های تیکت‌های پشتیبانی"""
__tablename__ = "support_messages"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_tickets.id", ondelete="CASCADE"), nullable=False, index=True)
sender_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
sender_type: Mapped[SenderType] = mapped_column(SQLEnum(SenderType), nullable=False, index=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
is_internal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # آیا پیام داخلی است؟
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
ticket = relationship("Ticket", back_populates="messages")
sender = relationship("User")

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class Priority(Base):
"""اولویت تیکت‌های پشتیبانی"""
__tablename__ = "support_priorities"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
color: Mapped[str | None] = mapped_column(String(7), nullable=True) # Hex color code
order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
tickets = relationship("Ticket", back_populates="priority")

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class Status(Base):
"""وضعیت تیکت‌های پشتیبانی"""
__tablename__ = "support_statuses"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
color: Mapped[str | None] = mapped_column(String(7), nullable=True) # Hex color code
is_final: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # آیا وضعیت نهایی است؟
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
tickets = relationship("Ticket", back_populates="status")

View file

@ -0,0 +1,40 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime, Integer, ForeignKey, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class Ticket(Base):
"""تیکت‌های پشتیبانی"""
__tablename__ = "support_tickets"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
description: Mapped[str] = mapped_column(Text, nullable=False)
# Foreign Keys
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
category_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_categories.id", ondelete="RESTRICT"), nullable=False, index=True)
priority_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_priorities.id", ondelete="RESTRICT"), nullable=False, index=True)
status_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_statuses.id", ondelete="RESTRICT"), nullable=False, index=True)
assigned_operator_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
# Additional fields
is_internal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # آیا تیکت داخلی است؟
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", foreign_keys=[user_id], back_populates="tickets")
assigned_operator = relationship("User", foreign_keys=[assigned_operator_id])
category = relationship("Category", back_populates="tickets")
priority = relationship("Priority", back_populates="tickets")
status = relationship("Status", back_populates="tickets")
messages = relationship("Message", back_populates="ticket", cascade="all, delete-orphan")

View file

@ -0,0 +1,24 @@
from datetime import datetime
from sqlalchemy import String, Integer, Boolean, DateTime, Text, Numeric
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class TaxUnit(Base):
"""
موجودیت واحد مالیاتی
- مدیریت واحدهای مالیاتی مختلف برای کسبوکارها
- پشتیبانی از انواع مختلف مالیات (فروش، خرید، ارزش افزوده و...)
"""
__tablename__ = "tax_units"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True, comment="شناسه کسب‌وکار")
name: Mapped[str] = mapped_column(String(255), nullable=False, comment="نام واحد مالیاتی")
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد واحد مالیاتی")
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="توضیحات")
tax_rate: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="نرخ مالیات (درصد)")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="وضعیت فعال/غیرفعال")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,35 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Integer, ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True)
mobile: Mapped[str | None] = mapped_column(String(32), unique=True, index=True, nullable=True)
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Marketing/Referral fields
referral_code: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False)
referred_by_user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
# App permissions
app_permissions: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Support relationships
tickets = relationship("Ticket", foreign_keys="Ticket.user_id", back_populates="user")
# Business relationships - using business_permissions instead
# businesses = relationship("BusinessUser", back_populates="user", cascade="all, delete-orphan")

View file

@ -0,0 +1,27 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from adapters.db.models.api_key import ApiKey
class ApiKeyRepository:
def __init__(self, db: Session) -> None:
self.db = db
def create_session_key(self, *, user_id: int, key_hash: str, device_id: str | None, user_agent: str | None, ip: str | None, expires_at: datetime | None) -> ApiKey:
obj = ApiKey(user_id=user_id, key_hash=key_hash, key_type="session", name=None, scopes=None, device_id=device_id, user_agent=user_agent, ip=ip, expires_at=expires_at)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def get_by_hash(self, key_hash: str) -> Optional[ApiKey]:
stmt = select(ApiKey).where(ApiKey.key_hash == key_hash)
return self.db.execute(stmt).scalars().first()

View file

@ -0,0 +1,64 @@
from __future__ import annotations
from typing import Type, TypeVar, Generic, Any
from sqlalchemy.orm import Session
from sqlalchemy import select, func
from app.services.query_service import QueryService
from adapters.api.v1.schemas import QueryInfo
T = TypeVar('T')
class BaseRepository(Generic[T]):
"""کلاس پایه برای Repository ها با قابلیت فیلتر پیشرفته"""
def __init__(self, db: Session, model_class: Type[T]) -> None:
self.db = db
self.model_class = model_class
def query_with_filters(self, query_info: QueryInfo) -> tuple[list[T], int]:
"""
اجرای کوئری با فیلتر و بازگرداندن نتایج و تعداد کل
Args:
query_info: اطلاعات کوئری شامل فیلترها، مرتبسازی و صفحهبندی
Returns:
tuple: (لیست نتایج, تعداد کل رکوردها)
"""
return QueryService.query_with_filters(self.model_class, self.db, query_info)
def get_by_id(self, id: int) -> T | None:
"""دریافت رکورد بر اساس ID"""
stmt = select(self.model_class).where(self.model_class.id == id)
return self.db.execute(stmt).scalars().first()
def get_all(self, limit: int = 100, offset: int = 0) -> list[T]:
"""دریافت تمام رکوردها با محدودیت"""
stmt = select(self.model_class).offset(offset).limit(limit)
return list(self.db.execute(stmt).scalars().all())
def count_all(self) -> int:
"""شمارش تمام رکوردها"""
stmt = select(func.count()).select_from(self.model_class)
return int(self.db.execute(stmt).scalar() or 0)
def exists(self, **filters) -> bool:
"""بررسی وجود رکورد بر اساس فیلترهای مشخص شده"""
stmt = select(self.model_class)
for field, value in filters.items():
if hasattr(self.model_class, field):
column = getattr(self.model_class, field)
stmt = stmt.where(column == value)
return self.db.execute(stmt).scalars().first() is not None
def delete(self, obj: T) -> None:
"""حذف رکورد از دیتابیس"""
self.db.delete(obj)
self.db.commit()
def update(self, obj: T) -> None:
"""بروزرسانی رکورد در دیتابیس"""
self.db.commit()

View file

@ -0,0 +1,120 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy import select, and_, text
from sqlalchemy.orm import Session
from adapters.db.models.business_permission import BusinessPermission
from adapters.db.repositories.base_repo import BaseRepository
class BusinessPermissionRepository(BaseRepository[BusinessPermission]):
def __init__(self, db: Session) -> None:
super().__init__(db, BusinessPermission)
def get_by_user_and_business(self, user_id: int, business_id: int) -> Optional[BusinessPermission]:
"""دریافت دسترسی‌های کاربر برای کسب و کار خاص"""
stmt = select(BusinessPermission).where(
and_(
BusinessPermission.user_id == user_id,
BusinessPermission.business_id == business_id
)
)
return self.db.execute(stmt).scalars().first()
def create_or_update(self, user_id: int, business_id: int, permissions: dict) -> BusinessPermission:
"""ایجاد یا به‌روزرسانی دسترسی‌های کاربر برای کسب و کار"""
existing = self.get_by_user_and_business(user_id, business_id)
if existing:
# Preserve existing permissions and enforce join=True
existing_permissions = existing.business_permissions or {}
# Always ignore incoming 'join' field from clients
incoming_permissions = dict(permissions or {})
if 'join' in incoming_permissions:
incoming_permissions.pop('join', None)
# Merge and enforce join flag
merged_permissions = dict(existing_permissions)
merged_permissions.update(incoming_permissions)
merged_permissions['join'] = True
existing.business_permissions = merged_permissions
self.db.commit()
self.db.refresh(existing)
return existing
else:
# On creation, ensure join=True exists by default
base_permissions = {'join': True}
incoming_permissions = dict(permissions or {})
if 'join' in incoming_permissions:
incoming_permissions.pop('join', None)
new_permission = BusinessPermission(
user_id=user_id,
business_id=business_id,
business_permissions={**base_permissions, **incoming_permissions}
)
self.db.add(new_permission)
self.db.commit()
self.db.refresh(new_permission)
return new_permission
def delete_by_user_and_business(self, user_id: int, business_id: int) -> bool:
"""حذف دسترسی‌های کاربر برای کسب و کار"""
existing = self.get_by_user_and_business(user_id, business_id)
if existing:
self.db.delete(existing)
self.db.commit()
return True
return False
def get_user_businesses(self, user_id: int) -> list[BusinessPermission]:
"""دریافت تمام کسب و کارهایی که کاربر دسترسی دارد"""
stmt = select(BusinessPermission).where(BusinessPermission.user_id == user_id)
return self.db.execute(stmt).scalars().all()
def get_business_users(self, business_id: int) -> list[BusinessPermission]:
"""دریافت تمام کاربرانی که دسترسی به کسب و کار دارند"""
stmt = select(BusinessPermission).where(BusinessPermission.business_id == business_id)
return self.db.execute(stmt).scalars().all()
def get_user_member_businesses(self, user_id: int) -> list[BusinessPermission]:
"""دریافت تمام کسب و کارهایی که کاربر عضو آن‌ها است (دسترسی join)"""
# ابتدا تمام دسترسی‌های کاربر را دریافت می‌کنیم
all_permissions = self.get_user_businesses(user_id)
# سپس فیلتر می‌کنیم
member_permissions = []
for perm in all_permissions:
# Normalize legacy/non-dict JSON values to dict before access
raw = perm.business_permissions
normalized = {}
if isinstance(raw, dict):
normalized = raw
elif isinstance(raw, list):
# If legacy stored as list, try to coerce to dict if it looks like key-value pairs
try:
# e.g., [["join", true], ["sales", {"read": true}]] or [{"join": true}, ...]
if all(isinstance(item, list) and len(item) == 2 for item in raw):
normalized = {k: v for k, v in raw if isinstance(k, str)}
elif all(isinstance(item, dict) for item in raw):
# Merge list of dicts
merged: dict = {}
for item in raw:
merged.update({k: v for k, v in item.items()})
normalized = merged
except Exception:
normalized = {}
elif raw is None:
normalized = {}
else:
# Unsupported type, skip safely
normalized = {}
if normalized.get('join') == True:
member_permissions.append(perm)
return member_permissions

View file

@ -0,0 +1,145 @@
from __future__ import annotations
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import select, and_
from .base_repo import BaseRepository
from ..models.business import Business, BusinessType, BusinessField
class BusinessRepository(BaseRepository[Business]):
"""Repository برای مدیریت کسب و کارها"""
def __init__(self, db: Session) -> None:
super().__init__(db, Business)
def get_by_owner_id(self, owner_id: int) -> List[Business]:
"""دریافت تمام کسب و کارهای یک مالک"""
stmt = select(Business).where(Business.owner_id == owner_id)
return list(self.db.execute(stmt).scalars().all())
def get_by_business_type(self, business_type: BusinessType) -> List[Business]:
"""دریافت کسب و کارها بر اساس نوع"""
stmt = select(Business).where(Business.business_type == business_type)
return list(self.db.execute(stmt).scalars().all())
def get_by_business_field(self, business_field: BusinessField) -> List[Business]:
"""دریافت کسب و کارها بر اساس زمینه فعالیت"""
stmt = select(Business).where(Business.business_field == business_field)
return list(self.db.execute(stmt).scalars().all())
def get_by_owner_and_type(self, owner_id: int, business_type: BusinessType) -> List[Business]:
"""دریافت کسب و کارهای یک مالک بر اساس نوع"""
stmt = select(Business).where(
and_(
Business.owner_id == owner_id,
Business.business_type == business_type
)
)
return list(self.db.execute(stmt).scalars().all())
def search_by_name(self, name: str) -> List[Business]:
"""جستجوی کسب و کارها بر اساس نام (case-insensitive)"""
stmt = select(Business).where(Business.name.ilike(f"%{name}%"))
return list(self.db.execute(stmt).scalars().all())
def create_business(
self,
name: str,
business_type: BusinessType,
business_field: BusinessField,
owner_id: int,
default_currency_id: int | None = None,
address: str | None = None,
phone: str | None = None,
mobile: str | None = None,
national_id: str | None = None,
registration_number: str | None = None,
economic_id: str | None = None,
country: str | None = None,
province: str | None = None,
city: str | None = None,
postal_code: str | None = None
) -> Business:
"""ایجاد کسب و کار جدید"""
business = Business(
name=name,
business_type=business_type,
business_field=business_field,
owner_id=owner_id,
default_currency_id=default_currency_id,
address=address,
phone=phone,
mobile=mobile,
national_id=national_id,
registration_number=registration_number,
economic_id=economic_id,
country=country,
province=province,
city=city,
postal_code=postal_code
)
self.db.add(business)
self.db.commit()
self.db.refresh(business)
return business
def get_by_national_id(self, national_id: str) -> Business | None:
"""دریافت کسب و کار بر اساس شناسه ملی"""
stmt = select(Business).where(Business.national_id == national_id)
return self.db.execute(stmt).scalars().first()
def get_by_registration_number(self, registration_number: str) -> Business | None:
"""دریافت کسب و کار بر اساس شماره ثبت"""
stmt = select(Business).where(Business.registration_number == registration_number)
return self.db.execute(stmt).scalars().first()
def get_by_economic_id(self, economic_id: str) -> Business | None:
"""دریافت کسب و کار بر اساس شناسه اقتصادی"""
stmt = select(Business).where(Business.economic_id == economic_id)
return self.db.execute(stmt).scalars().first()
def search_by_phone(self, phone: str) -> List[Business]:
"""جستجوی کسب و کارها بر اساس شماره تلفن"""
stmt = select(Business).where(
(Business.phone == phone) | (Business.mobile == phone)
)
return list(self.db.execute(stmt).scalars().all())
def get_by_country(self, country: str) -> List[Business]:
"""دریافت کسب و کارها بر اساس کشور"""
stmt = select(Business).where(Business.country == country)
return list(self.db.execute(stmt).scalars().all())
def get_by_province(self, province: str) -> List[Business]:
"""دریافت کسب و کارها بر اساس استان"""
stmt = select(Business).where(Business.province == province)
return list(self.db.execute(stmt).scalars().all())
def get_by_city(self, city: str) -> List[Business]:
"""دریافت کسب و کارها بر اساس شهرستان"""
stmt = select(Business).where(Business.city == city)
return list(self.db.execute(stmt).scalars().all())
def get_by_postal_code(self, postal_code: str) -> List[Business]:
"""دریافت کسب و کارها بر اساس کد پستی"""
stmt = select(Business).where(Business.postal_code == postal_code)
return list(self.db.execute(stmt).scalars().all())
def get_by_location(self, country: str | None = None, province: str | None = None, city: str | None = None) -> List[Business]:
"""دریافت کسب و کارها بر اساس موقعیت جغرافیایی"""
stmt = select(Business)
conditions = []
if country:
conditions.append(Business.country == country)
if province:
conditions.append(Business.province == province)
if city:
conditions.append(Business.city == city)
if conditions:
stmt = stmt.where(and_(*conditions))
return list(self.db.execute(stmt).scalars().all())

View file

@ -0,0 +1,92 @@
from __future__ import annotations
from typing import List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import select, and_, or_
from .base_repo import BaseRepository
from ..models.category import BusinessCategory
class CategoryRepository(BaseRepository[BusinessCategory]):
def __init__(self, db: Session):
super().__init__(db, BusinessCategory)
def get_tree(self, business_id: int, type_: str | None = None) -> list[Dict[str, Any]]:
stmt = select(BusinessCategory).where(BusinessCategory.business_id == business_id)
# درخت سراسری: نوع نادیده گرفته می‌شود (همه رکوردها)
stmt = stmt.order_by(BusinessCategory.sort_order.asc(), BusinessCategory.id.asc())
rows = list(self.db.execute(stmt).scalars().all())
flat = [
{
"id": r.id,
"parent_id": r.parent_id,
"translations": r.title_translations or {},
# برچسب واحد بر اساس زبان پیش‌فرض: ابتدا fa سپس en
"title": (r.title_translations or {}).get("fa")
or (r.title_translations or {}).get("en")
or "",
}
for r in rows
]
return self._build_tree(flat)
def _build_tree(self, nodes: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
by_id: dict[int, Dict[str, Any]] = {}
roots: list[Dict[str, Any]] = []
for n in nodes:
item = {
"id": n["id"],
"parent_id": n.get("parent_id"),
"title": n.get("title", ""),
"translations": n.get("translations", {}),
"children": [],
}
by_id[item["id"]] = item
for item in list(by_id.values()):
pid = item.get("parent_id")
if pid and pid in by_id:
by_id[pid]["children"].append(item)
else:
roots.append(item)
return roots
def create_category(self, *, business_id: int, parent_id: int | None, translations: dict[str, str]) -> BusinessCategory:
obj = BusinessCategory(
business_id=business_id,
parent_id=parent_id,
title_translations=translations or {},
)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def update_category(self, *, category_id: int, translations: dict[str, str] | None = None) -> BusinessCategory | None:
obj = self.db.get(BusinessCategory, category_id)
if not obj:
return None
if translations:
obj.title_translations = {**(obj.title_translations or {}), **translations}
self.db.commit()
self.db.refresh(obj)
return obj
def move_category(self, *, category_id: int, new_parent_id: int | None) -> BusinessCategory | None:
obj = self.db.get(BusinessCategory, category_id)
if not obj:
return None
obj.parent_id = new_parent_id
self.db.commit()
self.db.refresh(obj)
return obj
def delete_category(self, *, category_id: int) -> bool:
obj = self.db.get(BusinessCategory, category_id)
if not obj:
return False
self.db.delete(obj)
self.db.commit()
return True

View file

@ -0,0 +1,85 @@
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import and_
from adapters.db.models.email_config import EmailConfig
from adapters.db.repositories.base_repo import BaseRepository
class EmailConfigRepository(BaseRepository[EmailConfig]):
def __init__(self, db: Session):
super().__init__(db, EmailConfig)
def get_active_config(self) -> Optional[EmailConfig]:
"""Get the currently active email configuration"""
return self.db.query(self.model_class).filter(self.model_class.is_active == True).first()
def get_default_config(self) -> Optional[EmailConfig]:
"""Get the default email configuration"""
return self.db.query(self.model_class).filter(self.model_class.is_default == True).first()
def set_default_config(self, config_id: int) -> bool:
"""Set a configuration as default (removes default from others)"""
try:
# First check if the config exists
config = self.get_by_id(config_id)
if not config:
return False
# Remove default from all configs
self.db.query(self.model_class).update({self.model_class.is_default: False})
# Set the specified config as default
config.is_default = True
self.db.commit()
return True
except Exception as e:
self.db.rollback()
print(f"Error in set_default_config: {e}") # Debug log
return False
def get_by_name(self, name: str) -> Optional[EmailConfig]:
"""Get email configuration by name"""
return self.db.query(self.model_class).filter(self.model_class.name == name).first()
def get_all_configs(self) -> List[EmailConfig]:
"""Get all email configurations"""
return self.db.query(self.model_class).order_by(self.model_class.created_at.desc()).all()
def set_active_config(self, config_id: int) -> bool:
"""Set a specific configuration as active and deactivate others"""
try:
# Deactivate all configs
self.db.query(self.model_class).update({self.model_class.is_active: False})
# Activate the specified config
config = self.get_by_id(config_id)
if config:
config.is_active = True
self.db.commit()
return True
return False
except Exception:
self.db.rollback()
return False
def test_connection(self, config: EmailConfig) -> bool:
"""Test SMTP connection for a configuration"""
try:
import smtplib
from email.mime.text import MIMEText
# Create SMTP connection
if config.use_ssl:
server = smtplib.SMTP_SSL(config.smtp_host, config.smtp_port)
else:
server = smtplib.SMTP(config.smtp_host, config.smtp_port)
if config.use_tls:
server.starttls()
# Login
server.login(config.smtp_username, config.smtp_password)
server.quit()
return True
except Exception:
return False

View file

@ -0,0 +1,301 @@
from typing import List, Optional, Dict, Any
from uuid import UUID
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, desc, func
from datetime import datetime, timedelta
from adapters.db.models.file_storage import FileStorage, StorageConfig, FileVerification
from adapters.db.repositories.base_repo import BaseRepository
class FileStorageRepository(BaseRepository[FileStorage]):
def __init__(self, db: Session):
super().__init__(db, FileStorage)
async def create_file(
self,
original_name: str,
stored_name: str,
file_path: str,
file_size: int,
mime_type: str,
storage_type: str,
uploaded_by: UUID,
module_context: str,
context_id: Optional[UUID] = None,
developer_data: Optional[Dict] = None,
checksum: Optional[str] = None,
is_temporary: bool = False,
expires_in_days: int = 30,
storage_config_id: Optional[UUID] = None
) -> FileStorage:
expires_at = None
if is_temporary:
expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
file_storage = FileStorage(
original_name=original_name,
stored_name=stored_name,
file_path=file_path,
file_size=file_size,
mime_type=mime_type,
storage_type=storage_type,
storage_config_id=storage_config_id,
uploaded_by=uploaded_by,
module_context=module_context,
context_id=context_id,
developer_data=developer_data,
checksum=checksum,
is_temporary=is_temporary,
expires_at=expires_at
)
self.db.add(file_storage)
self.db.commit()
self.db.refresh(file_storage)
return file_storage
async def get_file_by_id(self, file_id: UUID) -> Optional[FileStorage]:
return self.db.query(FileStorage).filter(
and_(
FileStorage.id == file_id,
FileStorage.deleted_at.is_(None)
)
).first()
async def get_files_by_context(
self,
module_context: str,
context_id: UUID
) -> List[FileStorage]:
return self.db.query(FileStorage).filter(
and_(
FileStorage.module_context == module_context,
FileStorage.context_id == context_id,
FileStorage.deleted_at.is_(None),
FileStorage.is_active == True
)
).order_by(desc(FileStorage.created_at)).all()
async def get_user_files(
self,
user_id: UUID,
limit: int = 50,
offset: int = 0
) -> List[FileStorage]:
return self.db.query(FileStorage).filter(
and_(
FileStorage.uploaded_by == user_id,
FileStorage.deleted_at.is_(None)
)
).order_by(desc(FileStorage.created_at)).offset(offset).limit(limit).all()
async def get_unverified_temporary_files(self) -> List[FileStorage]:
return self.db.query(FileStorage).filter(
and_(
FileStorage.is_temporary == True,
FileStorage.is_verified == False,
FileStorage.deleted_at.is_(None),
FileStorage.is_active == True
)
).all()
async def get_expired_temporary_files(self) -> List[FileStorage]:
return self.db.query(FileStorage).filter(
and_(
FileStorage.is_temporary == True,
FileStorage.expires_at < datetime.utcnow(),
FileStorage.deleted_at.is_(None)
)
).all()
async def verify_file(self, file_id: UUID, verification_data: Dict) -> bool:
file_storage = await self.get_file_by_id(file_id)
if not file_storage:
return False
file_storage.is_verified = True
file_storage.last_verified_at = datetime.utcnow()
file_storage.developer_data = {**(file_storage.developer_data or {}), **verification_data}
self.db.commit()
return True
async def soft_delete_file(self, file_id: UUID) -> bool:
file_storage = await self.get_file_by_id(file_id)
if not file_storage:
return False
file_storage.deleted_at = datetime.utcnow()
file_storage.is_active = False
self.db.commit()
return True
async def restore_file(self, file_id: UUID) -> bool:
file_storage = self.db.query(FileStorage).filter(FileStorage.id == file_id).first()
if not file_storage:
return False
file_storage.deleted_at = None
file_storage.is_active = True
self.db.commit()
return True
async def get_storage_statistics(self) -> Dict[str, Any]:
total_files = self.db.query(FileStorage).filter(
FileStorage.deleted_at.is_(None)
).count()
total_size = self.db.query(func.sum(FileStorage.file_size)).filter(
FileStorage.deleted_at.is_(None)
).scalar() or 0
temporary_files = self.db.query(FileStorage).filter(
and_(
FileStorage.is_temporary == True,
FileStorage.deleted_at.is_(None)
)
).count()
unverified_files = self.db.query(FileStorage).filter(
and_(
FileStorage.is_temporary == True,
FileStorage.is_verified == False,
FileStorage.deleted_at.is_(None)
)
).count()
return {
"total_files": total_files,
"total_size": total_size,
"temporary_files": temporary_files,
"unverified_files": unverified_files
}
class StorageConfigRepository(BaseRepository[StorageConfig]):
def __init__(self, db: Session):
super().__init__(db, StorageConfig)
async def create_config(
self,
name: str,
storage_type: str,
config_data: Dict,
created_by: int,
is_default: bool = False,
is_active: bool = True
) -> StorageConfig:
# اگر این config به عنوان پیش‌فرض تنظیم می‌شود، بقیه را غیرفعال کن
if is_default:
await self.clear_default_configs()
storage_config = StorageConfig(
name=name,
storage_type=storage_type,
config_data=config_data,
created_by=created_by,
is_default=is_default,
is_active=is_active
)
self.db.add(storage_config)
self.db.commit()
self.db.refresh(storage_config)
return storage_config
async def get_default_config(self) -> Optional[StorageConfig]:
return self.db.query(StorageConfig).filter(
and_(
StorageConfig.is_default == True,
StorageConfig.is_active == True
)
).first()
def get_all_configs(self) -> List[StorageConfig]:
return self.db.query(StorageConfig).filter(
StorageConfig.is_active == True
).order_by(desc(StorageConfig.created_at)).all()
async def set_default_config(self, config_id: UUID) -> bool:
# ابتدا همه config ها را غیرپیش‌فرض کن
await self.clear_default_configs()
# config مورد نظر را پیش‌فرض کن
config = self.db.query(StorageConfig).filter(StorageConfig.id == config_id).first()
if not config:
return False
config.is_default = True
self.db.commit()
return True
async def clear_default_configs(self):
self.db.query(StorageConfig).update({"is_default": False})
self.db.commit()
def count_files_by_storage_config(self, config_id: str) -> int:
"""شمارش تعداد فایل‌های مربوط به یک storage config"""
return self.db.query(FileStorage).filter(
FileStorage.storage_config_id == config_id,
FileStorage.is_active == True,
FileStorage.deleted_at.is_(None)
).count()
def delete_config(self, config_id: str) -> bool:
config = self.db.query(StorageConfig).filter(StorageConfig.id == config_id).first()
if not config:
return False
config.is_active = False
self.db.commit()
return True
class FileVerificationRepository(BaseRepository[FileVerification]):
def __init__(self, db: Session):
super().__init__(FileVerification, db)
async def create_verification(
self,
file_id: UUID,
module_name: str,
verification_token: str,
verification_data: Optional[Dict] = None
) -> FileVerification:
verification = FileVerification(
file_id=file_id,
module_name=module_name,
verification_token=verification_token,
verification_data=verification_data
)
self.db.add(verification)
self.db.commit()
self.db.refresh(verification)
return verification
async def verify_file(
self,
file_id: UUID,
verification_token: str,
verified_by: UUID
) -> bool:
verification = self.db.query(FileVerification).filter(
and_(
FileVerification.file_id == file_id,
FileVerification.verification_token == verification_token,
FileVerification.verified_at.is_(None)
)
).first()
if not verification:
return False
verification.verified_at = datetime.utcnow()
verification.verified_by = verified_by
self.db.commit()
return True

View file

@ -0,0 +1,37 @@
from __future__ import annotations
from datetime import date
from sqlalchemy.orm import Session
from .base_repo import BaseRepository
from ..models.fiscal_year import FiscalYear
class FiscalYearRepository(BaseRepository[FiscalYear]):
"""Repository برای مدیریت سال‌های مالی"""
def __init__(self, db: Session) -> None:
super().__init__(db, FiscalYear)
def create_fiscal_year(
self,
*,
business_id: int,
title: str,
start_date: date,
end_date: date,
is_last: bool = True,
) -> FiscalYear:
fiscal_year = FiscalYear(
business_id=business_id,
title=title,
start_date=start_date,
end_date=end_date,
is_last=is_last,
)
self.db.add(fiscal_year)
self.db.commit()
self.db.refresh(fiscal_year)
return fiscal_year

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from adapters.db.models.password_reset import PasswordReset
class PasswordResetRepository:
def __init__(self, db: Session) -> None:
self.db = db
def create(self, *, user_id: int, token_hash: str, expires_at: datetime) -> PasswordReset:
obj = PasswordReset(user_id=user_id, token_hash=token_hash, expires_at=expires_at)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def get_by_hash(self, token_hash: str) -> Optional[PasswordReset]:
stmt = select(PasswordReset).where(PasswordReset.token_hash == token_hash)
return self.db.execute(stmt).scalars().first()
def mark_used(self, pr: PasswordReset) -> None:
pr.used_at = datetime.utcnow()
self.db.add(pr)
self.db.commit()

View file

@ -0,0 +1,158 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
from sqlalchemy import select, func, and_, or_
from .base_repo import BaseRepository
from ..models.price_list import PriceList, PriceItem
class PriceListRepository(BaseRepository[PriceList]):
def __init__(self, db: Session) -> None:
super().__init__(db, PriceList)
def search(self, *, business_id: int, take: int = 20, skip: int = 0, sort_by: str | None = None, sort_desc: bool = True, search: str | None = None) -> dict[str, Any]:
stmt = select(PriceList).where(PriceList.business_id == business_id)
if search:
stmt = stmt.where(PriceList.name.ilike(f"%{search}%"))
total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
if sort_by in {"name", "created_at"}:
col = getattr(PriceList, sort_by)
stmt = stmt.order_by(col.desc() if sort_desc else col.asc())
else:
stmt = stmt.order_by(PriceList.id.desc() if sort_desc else PriceList.id.asc())
rows = list(self.db.execute(stmt.offset(skip).limit(take)).scalars().all())
items = [self._to_dict_list(pl) for pl in rows]
return {
"items": items,
"pagination": {
"total": total,
"page": (skip // take) + 1 if take else 1,
"per_page": take,
"total_pages": (total + take - 1) // take if take else 1,
"has_next": skip + take < total,
"has_prev": skip > 0,
},
}
def create(self, **data: Any) -> PriceList:
obj = PriceList(**data)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def update(self, id: int, **data: Any) -> Optional[PriceList]:
obj = self.db.get(PriceList, id)
if not obj:
return None
for k, v in data.items():
if hasattr(obj, k) and v is not None:
setattr(obj, k, v)
self.db.commit()
self.db.refresh(obj)
return obj
def delete(self, id: int) -> bool:
obj = self.db.get(PriceList, id)
if not obj:
return False
self.db.delete(obj)
self.db.commit()
return True
def _to_dict_list(self, pl: PriceList) -> dict[str, Any]:
return {
"id": pl.id,
"business_id": pl.business_id,
"name": pl.name,
"is_active": pl.is_active,
"created_at": pl.created_at,
"updated_at": pl.updated_at,
}
class PriceItemRepository(BaseRepository[PriceItem]):
def __init__(self, db: Session) -> None:
super().__init__(db, PriceItem)
def list_for_price_list(self, *, price_list_id: int, take: int = 50, skip: int = 0, product_id: int | None = None, currency_id: int | None = None) -> dict[str, Any]:
stmt = select(PriceItem).where(PriceItem.price_list_id == price_list_id)
if product_id is not None:
stmt = stmt.where(PriceItem.product_id == product_id)
if currency_id is not None:
stmt = stmt.where(PriceItem.currency_id == currency_id)
total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
rows = list(self.db.execute(stmt.offset(skip).limit(take)).scalars().all())
items = [self._to_dict(pi) for pi in rows]
return {
"items": items,
"pagination": {
"total": total,
"page": (skip // take) + 1 if take else 1,
"per_page": take,
"total_pages": (total + take - 1) // take if take else 1,
"has_next": skip + take < total,
"has_prev": skip > 0,
},
}
def upsert(self, *, price_list_id: int, product_id: int, unit_id: int | None, currency_id: int, tier_name: str | None, min_qty, price) -> PriceItem:
# Try find existing unique combination
stmt = select(PriceItem).where(
and_(
PriceItem.price_list_id == price_list_id,
PriceItem.product_id == product_id,
PriceItem.unit_id.is_(unit_id) if unit_id is None else PriceItem.unit_id == unit_id,
PriceItem.tier_name == (tier_name or 'پیش‌فرض'),
PriceItem.min_qty == min_qty,
PriceItem.currency_id == currency_id,
)
)
existing = self.db.execute(stmt).scalars().first()
if existing:
existing.price = price
existing.currency_id = currency_id
self.db.commit()
self.db.refresh(existing)
return existing
obj = PriceItem(
price_list_id=price_list_id,
product_id=product_id,
unit_id=unit_id,
currency_id=currency_id,
tier_name=(tier_name or 'پیش‌فرض'),
min_qty=min_qty,
price=price,
)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def delete(self, id: int) -> bool:
obj = self.db.get(PriceItem, id)
if not obj:
return False
self.db.delete(obj)
self.db.commit()
return True
def _to_dict(self, pi: PriceItem) -> dict[str, Any]:
return {
"id": pi.id,
"price_list_id": pi.price_list_id,
"product_id": pi.product_id,
"unit_id": pi.unit_id,
"currency_id": pi.currency_id,
"tier_name": pi.tier_name,
"min_qty": pi.min_qty,
"price": pi.price,
"created_at": pi.created_at,
"updated_at": pi.updated_at,
}

View file

@ -0,0 +1,96 @@
from __future__ import annotations
from typing import Dict, Any, List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import select, and_, func
from .base_repo import BaseRepository
from ..models.product_attribute import ProductAttribute
class ProductAttributeRepository(BaseRepository[ProductAttribute]):
def __init__(self, db: Session):
super().__init__(db, ProductAttribute)
def search(
self,
*,
business_id: int,
take: int = 20,
skip: int = 0,
sort_by: str | None = None,
sort_desc: bool = True,
search: str | None = None,
filters: dict[str, Any] | None = None,
) -> dict[str, Any]:
stmt = select(ProductAttribute).where(ProductAttribute.business_id == business_id)
if search:
stmt = stmt.where(ProductAttribute.title.ilike(f"%{search}%"))
total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
# Sorting
if sort_by == 'title':
order_col = ProductAttribute.title.desc() if sort_desc else ProductAttribute.title.asc()
stmt = stmt.order_by(order_col)
else:
order_col = ProductAttribute.id.desc() if sort_desc else ProductAttribute.id.asc()
stmt = stmt.order_by(order_col)
# Paging
stmt = stmt.offset(skip).limit(take)
rows = list(self.db.execute(stmt).scalars().all())
items: list[dict[str, Any]] = [
{
"id": r.id,
"business_id": r.business_id,
"title": r.title,
"description": r.description,
"created_at": r.created_at,
"updated_at": r.updated_at,
}
for r in rows
]
return {
"items": items,
"pagination": {
"total": total,
"page": (skip // take) + 1 if take else 1,
"per_page": take,
"total_pages": (total + take - 1) // take if take else 1,
"has_next": skip + take < total,
"has_prev": skip > 0,
},
}
def create(self, *, business_id: int, title: str, description: str | None) -> ProductAttribute:
obj = ProductAttribute(business_id=business_id, title=title, description=description)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def update(self, *, attribute_id: int, title: str | None, description: str | None) -> Optional[ProductAttribute]:
obj = self.db.get(ProductAttribute, attribute_id)
if not obj:
return None
if title is not None:
obj.title = title
if description is not None:
obj.description = description
self.db.commit()
self.db.refresh(obj)
return obj
def delete(self, *, attribute_id: int) -> bool:
obj = self.db.get(ProductAttribute, attribute_id)
if not obj:
return False
self.db.delete(obj)
self.db.commit()
return True

View file

@ -0,0 +1,111 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
from sqlalchemy import select, and_, func
from .base_repo import BaseRepository
from ..models.product import Product
class ProductRepository(BaseRepository[Product]):
def __init__(self, db: Session) -> None:
super().__init__(db, Product)
def search(self, *, business_id: int, take: int = 20, skip: int = 0, sort_by: str | None = None, sort_desc: bool = True, search: str | None = None, filters: dict[str, Any] | None = None) -> dict[str, Any]:
stmt = select(Product).where(Product.business_id == business_id)
if search:
like = f"%{search}%"
stmt = stmt.where(
or_(
Product.name.ilike(like),
Product.code.ilike(like),
Product.description.ilike(like),
)
)
total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
# Sorting
if sort_by in {"name", "code", "created_at"}:
col = getattr(Product, sort_by)
stmt = stmt.order_by(col.desc() if sort_desc else col.asc())
else:
stmt = stmt.order_by(Product.id.desc() if sort_desc else Product.id.asc())
stmt = stmt.offset(skip).limit(take)
rows = list(self.db.execute(stmt).scalars().all())
def _to_dict(p: Product) -> dict[str, Any]:
return {
"id": p.id,
"business_id": p.business_id,
"item_type": p.item_type.value if hasattr(p.item_type, 'value') else str(p.item_type),
"code": p.code,
"name": p.name,
"description": p.description,
"category_id": p.category_id,
"main_unit_id": p.main_unit_id,
"secondary_unit_id": p.secondary_unit_id,
"unit_conversion_factor": p.unit_conversion_factor,
"base_sales_price": p.base_sales_price,
"base_sales_note": p.base_sales_note,
"base_purchase_price": p.base_purchase_price,
"base_purchase_note": p.base_purchase_note,
"track_inventory": p.track_inventory,
"reorder_point": p.reorder_point,
"min_order_qty": p.min_order_qty,
"lead_time_days": p.lead_time_days,
"is_sales_taxable": p.is_sales_taxable,
"is_purchase_taxable": p.is_purchase_taxable,
"sales_tax_rate": p.sales_tax_rate,
"purchase_tax_rate": p.purchase_tax_rate,
"tax_type_id": p.tax_type_id,
"tax_code": p.tax_code,
"tax_unit_id": p.tax_unit_id,
"created_at": p.created_at,
"updated_at": p.updated_at,
}
items = [_to_dict(r) for r in rows]
return {
"items": items,
"pagination": {
"total": total,
"page": (skip // take) + 1 if take else 1,
"per_page": take,
"total_pages": (total + take - 1) // take if take else 1,
"has_next": skip + take < total,
"has_prev": skip > 0,
},
}
def create(self, **data: Any) -> Product:
obj = Product(**data)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def update(self, product_id: int, **data: Any) -> Optional[Product]:
obj = self.db.get(Product, product_id)
if not obj:
return None
for k, v in data.items():
if hasattr(obj, k) and v is not None:
setattr(obj, k, v)
self.db.commit()
self.db.refresh(obj)
return obj
def delete(self, product_id: int) -> bool:
obj = self.db.get(Product, product_id)
if not obj:
return False
self.db.delete(obj)
self.db.commit()
return True

View file

@ -0,0 +1 @@
# Support repositories

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from typing import List
from sqlalchemy.orm import Session
from adapters.db.repositories.base_repo import BaseRepository
from adapters.db.models.support.category import Category
class CategoryRepository(BaseRepository[Category]):
def __init__(self, db: Session):
super().__init__(db, Category)
def get_active_categories(self) -> List[Category]:
"""دریافت دسته‌بندی‌های فعال"""
return self.db.query(Category)\
.filter(Category.is_active == True)\
.order_by(Category.name)\
.all()

View file

@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Optional, List
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import select, func, and_, or_
from adapters.db.repositories.base_repo import BaseRepository
from adapters.db.models.support.message import Message, SenderType
from adapters.api.v1.schemas import QueryInfo
class MessageRepository(BaseRepository[Message]):
def __init__(self, db: Session):
super().__init__(db, Message)
def get_ticket_messages(self, ticket_id: int, query_info: QueryInfo) -> tuple[List[Message], int]:
"""دریافت پیام‌های تیکت با فیلتر و صفحه‌بندی"""
query = self.db.query(Message)\
.options(joinedload(Message.sender))\
.filter(Message.ticket_id == ticket_id)
# اعمال جستجو
if query_info.search and query_info.search_fields:
search_conditions = []
for field in query_info.search_fields:
if hasattr(Message, field):
search_conditions.append(getattr(Message, field).ilike(f"%{query_info.search}%"))
if search_conditions:
query = query.filter(or_(*search_conditions))
# شمارش کل
total = query.count()
# اعمال مرتب‌سازی
if query_info.sort_by and hasattr(Message, query_info.sort_by):
sort_column = getattr(Message, query_info.sort_by)
if query_info.sort_desc:
query = query.order_by(sort_column.desc())
else:
query = query.order_by(sort_column.asc())
else:
query = query.order_by(Message.created_at.asc())
# اعمال صفحه‌بندی
query = query.offset(query_info.skip).limit(query_info.take)
return query.all(), total
def create_message(
self,
ticket_id: int,
sender_id: int,
sender_type: SenderType,
content: str,
is_internal: bool = False
) -> Message:
"""ایجاد پیام جدید"""
from datetime import datetime
from adapters.db.models.support.ticket import Ticket
message = Message(
ticket_id=ticket_id,
sender_id=sender_id,
sender_type=sender_type,
content=content,
is_internal=is_internal
)
self.db.add(message)
# Update ticket's updated_at field
ticket = self.db.query(Ticket).filter(Ticket.id == ticket_id).first()
if ticket:
ticket.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(message)
return message

Some files were not shown because too many files have changed in this diff Show more