From eae9248dc5b8f67191bf451c6d4c4a1eda1c3108 Mon Sep 17 00:00:00 2001 From: shahzeydyfah Date: Thu, 5 Feb 2026 23:02:08 +0330 Subject: [PATCH] feat(root):add create export --- app/urls/__init__.py | 3 +- app/urls/download.py | 48 +++++++++++++++++ core/utils/table_engine/types.py | 1 + core/utils/te/__init__.py | 3 ++ core/utils/te/engines/Mongo.py | 91 +++++++++++++++++++++++++++----- core/utils/te/utils/Models.py | 2 +- 6 files changed, 132 insertions(+), 16 deletions(-) create mode 100644 app/urls/download.py diff --git a/app/urls/__init__.py b/app/urls/__init__.py index 31b031b..925baf7 100644 --- a/app/urls/__init__.py +++ b/app/urls/__init__.py @@ -18,4 +18,5 @@ from .financial_status import * from .financial_adjust import * from .delivery import * from .report import * -from .excel import * \ No newline at end of file +from .excel import * +from .download import * \ No newline at end of file diff --git a/app/urls/download.py b/app/urls/download.py new file mode 100644 index 0000000..1c8b37c --- /dev/null +++ b/app/urls/download.py @@ -0,0 +1,48 @@ +""" +دانلود فایل اکسل تولیدشده از خروجی جدول (Table Engine). +فایل‌ها در پوشه temp پروژه ذخیره می‌شوند و از طریق این endpoint قابل دانلود هستند. +""" +import os +import re + +from fastapi import APIRouter, HTTPException +from starlette.responses import FileResponse + +from config import conf +from core.app import app +from core.utils.router import generate_router + +router = generate_router("download", auth_dependencies=False, version="v2") +_tag_base = ["download"] +_pre = conf.server.prefix_url.replace("v1", "v2") + + +def _excel_temp_dir(): + return os.path.join(os.getcwd(), "temp") + + +def _safe_excel_filename(name: str) -> bool: + """فقط نام فایل اکسلی با فرمت uuid.xlsx (۳۲ کاراکتر hex + .xlsx) مجاز است.""" + return bool(re.match(r"^[a-f0-9]{32}\.xlsx$", name)) + + +@router.get("/excel/{filename}", tags=_tag_base) +async def download_excel(filename: str): + """ + دانلود فایل اکسل با نام بازگردانده‌شده از API لیست (فیلد path). + مثال: بعد از درخواست خروجی اکسل، در پاسخ path مثلاً «abc123...xlsx» می‌آید؛ + برای دانلود: GET /v2/download/excel/abc123....xlsx + """ + if not _safe_excel_filename(filename): + raise HTTPException(status_code=400, detail="نام فایل نامعتبر است.") + file_path = os.path.join(_excel_temp_dir(), filename) + if not os.path.isfile(file_path): + raise HTTPException(status_code=404, detail="فایل یافت نشد یا منقضی شده است.") + return FileResponse( + file_path, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + filename=filename, + ) + + +app.include_router(router) diff --git a/core/utils/table_engine/types.py b/core/utils/table_engine/types.py index ab4ce5f..f2cd1a1 100644 --- a/core/utils/table_engine/types.py +++ b/core/utils/table_engine/types.py @@ -10,6 +10,7 @@ class TableEngineInputType(GenericModel, Generic[T]): o: Optional[dict] = {} p: Optional[dict] = {} s: Optional[dict] = {} + e: Optional[dict] = {} class TableEngineExport(GenericModel, Generic[T]): diff --git a/core/utils/te/__init__.py b/core/utils/te/__init__.py index c1e81c4..df197ff 100644 --- a/core/utils/te/__init__.py +++ b/core/utils/te/__init__.py @@ -62,6 +62,9 @@ class NewTableEngine: if self.return_query: query = await self.exe() return query + if self.input.e: + query = await self.exe() + return query rows, count = await self.exe() self.input.extra_filter = None del self.engine diff --git a/core/utils/te/engines/Mongo.py b/core/utils/te/engines/Mongo.py index 9bc1463..4ca7709 100644 --- a/core/utils/te/engines/Mongo.py +++ b/core/utils/te/engines/Mongo.py @@ -2,6 +2,13 @@ import datetime import uuid from typing import Union, List from uuid import UUID +import os +import xlsxwriter +import tempfile +import string +import copy +import jdatetime + # import dateutil.parser from celery import shared_task @@ -34,12 +41,10 @@ class MongoEngine(AbstractEngineClass): async def exe(self): _f = self.filters = self.set_filters() - print("_f ====>", _f) if self.unselect: tmp = self.query_class.find(_f, self.unselect) else: tmp = self.query_class.find(_f) - sort_by = self.set_sort() if isinstance(sort_by, dict): tmp = tmp.sort(**sort_by) @@ -49,7 +54,11 @@ class MongoEngine(AbstractEngineClass): self.rows = tmp.limit( self.input.get_limit()).skip(self.input.skip()) if self.input.e and self.input.e.ot: - self.input.e.d = self.generate_output(self.query_class, _f) + copy_list = copy.deepcopy(tmp) + res = await copy_list.to_list(None) + self.input.e.d = self.generate_output(res) + return {'query': _f, 'sort': self.set_sort(), 'path': self.input.e.d} + if self.return_query: return {'query':_f, 'sort':self.set_sort(), 'limit':self.input.get_limit(), 'page':self.input.skip()} return [self.model(**document) async for document in self.rows], await self.count() @@ -249,15 +258,11 @@ class MongoEngine(AbstractEngineClass): def set_joins(self): pass - def generate_output(self, query, _f): - print(_f.__dict__) - return '' + def generate_output(self, _f): + print(self.input.e.ot) if self.input.e.ot == OutPutType.EXEL: file_name = str(uuid.uuid4().hex) + '.xlsx' - return _f.dict() - MongoEngine.output_generator(str(query).replace('false', '0'), params, - str(self.query_class.bind.url), file_name=file_name, cols=self.input.e.c) - return file_name + return self.output_generator(_f, file_name=file_name, cols=self.input.e.c) elif self.input.e.ot == OutPutType.JSON: pass elif self.input.e.ot == OutPutType.PDF: @@ -265,7 +270,65 @@ class MongoEngine(AbstractEngineClass): else: raise Exception("Output type not supported.") - @staticmethod - @shared_task(name='output_generator_task_mongo') - def output_generator(query, params, url, file_name, cols): - pass + def _excel_temp_dir(self): + """پوشه temp پروژه برای ذخیره فایل‌های اکسل (همان مسیری که برای دانلود سرو می‌شود).""" + temp_dir = os.path.join(os.getcwd(), 'temp') + os.makedirs(temp_dir, exist_ok=True) + return temp_dir + + def output_generator(self,query, file_name, cols): + datas = query + file_dir = os.path.join(self._excel_temp_dir(), file_name) + head_cols = list(str(string.ascii_uppercase)) + headers = list(cols.keys()) + + workbook = xlsxwriter.Workbook(file_dir) + worksheet = workbook.add_worksheet() + + # نوشتن هدرها + bold_format = workbook.add_format({'bold': True}) + + # نوشتن هدرها + for col_index, header in enumerate(headers): + worksheet.write(0, col_index, cols[header], bold_format) + + # نوشتن داده‌ها + for row_index, item in enumerate(datas, start=1): + for col_index, col in enumerate(headers): + val = self.get_nested_value(item, col) + worksheet.write(row_index, col_index, val) + workbook.close() + return file_name + + def get_nested_value(self, data, path, default='-'): + """ + دریافت مقدار از دیکشنری تو در تو با مسیر dot notation + مثال: path = "design.title" + """ + if not isinstance(path, str): + return default + + keys = path.split('.') + val = data + + for key in keys: + if isinstance(val, dict): + val = val.get(key, default) + else: + return default + + if val is None: + return default + + # تبدیل datetime و UUID به رشته برای اکسل + if isinstance(val, datetime.datetime): + return self.create_date(val) + if isinstance(val, uuid.UUID): + return str(val) + + return val + + def create_date(self, date): + shamsi_dt = jdatetime.datetime.fromgregorian(datetime=date) + return shamsi_dt.strftime("%Y/%m/%d - %H:%M:%S") + diff --git a/core/utils/te/utils/Models.py b/core/utils/te/utils/Models.py index 49f24f1..b83433a 100644 --- a/core/utils/te/utils/Models.py +++ b/core/utils/te/utils/Models.py @@ -35,7 +35,7 @@ class Cr(str, Enum): class OutPutType(str, Enum): JSON = 'json' - EXEL = 'exel' + EXEL = 'excel' PDF = 'pdf'