export.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. import csv
  2. import io
  3. from datetime import datetime
  4. from typing import Any
  5. from sqlalchemy import select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from sqlalchemy.orm import selectinload
  8. from backend.app.core.printer_scope import PrinterScope
  9. from backend.app.models.archive import PrintArchive
  10. class ExportService:
  11. """Service for exporting archive data to CSV/Excel formats."""
  12. # Default fields to export
  13. DEFAULT_FIELDS = [
  14. "id",
  15. "print_name",
  16. "filename",
  17. "status",
  18. "quantity",
  19. "printer_id",
  20. "project_name",
  21. "filament_type",
  22. "filament_used_grams",
  23. "print_time_seconds",
  24. "layer_height",
  25. "nozzle_diameter",
  26. "bed_temperature",
  27. "nozzle_temperature",
  28. "total_layers",
  29. "cost",
  30. "designer",
  31. "tags",
  32. "notes",
  33. "failure_reason",
  34. "started_at",
  35. "completed_at",
  36. "created_at",
  37. ]
  38. # Field labels for headers
  39. FIELD_LABELS = {
  40. "id": "ID",
  41. "print_name": "Print Name",
  42. "filename": "Filename",
  43. "status": "Status",
  44. "quantity": "Items Printed",
  45. "printer_id": "Printer ID",
  46. "project_name": "Project",
  47. "filament_type": "Filament Type",
  48. "filament_used_grams": "Filament (g)",
  49. "print_time_seconds": "Print Time (s)",
  50. "layer_height": "Layer Height (mm)",
  51. "nozzle_diameter": "Nozzle (mm)",
  52. "bed_temperature": "Bed Temp (°C)",
  53. "nozzle_temperature": "Nozzle Temp (°C)",
  54. "total_layers": "Total Layers",
  55. "cost": "Cost",
  56. "energy_cost": "Energy Cost",
  57. "wear_cost": "Wear Cost",
  58. "designer": "Designer",
  59. "tags": "Tags",
  60. "notes": "Notes",
  61. "failure_reason": "Failure Reason",
  62. "started_at": "Started At",
  63. "completed_at": "Completed At",
  64. "created_at": "Created At",
  65. }
  66. def __init__(self, db: AsyncSession):
  67. self.db = db
  68. async def export_archives(
  69. self,
  70. format: str = "csv",
  71. fields: list[str] | None = None,
  72. printer_id: int | None = None,
  73. project_id: int | None = None,
  74. status: str | None = None,
  75. date_from: datetime | None = None,
  76. date_to: datetime | None = None,
  77. search: str | None = None,
  78. visible_to_user_id: int | None = None,
  79. printer_scope: PrinterScope | None = None,
  80. ) -> tuple[bytes, str, str]:
  81. """Export archives to CSV or Excel format.
  82. Args:
  83. format: Export format ('csv' or 'xlsx')
  84. fields: List of fields to include (None = all default fields)
  85. printer_id: Filter by printer
  86. project_id: Filter by project
  87. status: Filter by status
  88. date_from: Filter by start date
  89. date_to: Filter by end date
  90. search: Search filter
  91. visible_to_user_id: Scope rows to those owned by this user (used
  92. when the caller has ARCHIVES_READ_OWN but not _ALL).
  93. Returns:
  94. Tuple of (file_bytes, filename, content_type)
  95. """
  96. # Build query. Soft-deleted archives (#1343) are excluded: this export
  97. # is the list the user is looking at, saved to a file, and that list
  98. # hides them — an export that silently contains rows the UI says are
  99. # gone is worse than useless for reconciling anything (#2731).
  100. query = (
  101. select(PrintArchive)
  102. .options(selectinload(PrintArchive.project))
  103. .where(PrintArchive.deleted_at.is_(None))
  104. .order_by(PrintArchive.created_at.desc())
  105. )
  106. # Apply filters
  107. if printer_id:
  108. query = query.where(PrintArchive.printer_id == printer_id)
  109. if project_id:
  110. query = query.where(PrintArchive.project_id == project_id)
  111. if status:
  112. query = query.where(PrintArchive.status == status)
  113. if date_from:
  114. query = query.where(PrintArchive.created_at >= date_from)
  115. if date_to:
  116. query = query.where(PrintArchive.created_at <= date_to)
  117. if visible_to_user_id is not None:
  118. query = query.where(PrintArchive.created_by_id == visible_to_user_id)
  119. if printer_scope is not None and (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
  120. query = query.where(clause)
  121. if search:
  122. like_pattern = f"%{search}%"
  123. query = query.where(
  124. (PrintArchive.print_name.ilike(like_pattern))
  125. | (PrintArchive.filename.ilike(like_pattern))
  126. | (PrintArchive.tags.ilike(like_pattern))
  127. | (PrintArchive.notes.ilike(like_pattern))
  128. | (PrintArchive.designer.ilike(like_pattern))
  129. )
  130. # Execute query
  131. result = await self.db.execute(query)
  132. archives = list(result.scalars().all())
  133. # Determine fields to export
  134. export_fields = fields if fields else self.DEFAULT_FIELDS
  135. # Convert to rows
  136. rows = []
  137. for archive in archives:
  138. row = self._archive_to_row(archive, export_fields)
  139. rows.append(row)
  140. # Generate headers
  141. headers = [self.FIELD_LABELS.get(f, f) for f in export_fields]
  142. # Generate file
  143. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  144. if format == "xlsx":
  145. file_bytes = self._generate_xlsx(headers, rows, export_fields)
  146. filename = f"archives_export_{timestamp}.xlsx"
  147. content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  148. else:
  149. file_bytes = self._generate_csv(headers, rows)
  150. filename = f"archives_export_{timestamp}.csv"
  151. content_type = "text/csv"
  152. return file_bytes, filename, content_type
  153. async def export_stats(
  154. self,
  155. format: str = "csv",
  156. days: int = 30,
  157. printer_id: int | None = None,
  158. project_id: int | None = None,
  159. created_by_id: int | None = None,
  160. printer_scope: PrinterScope | None = None,
  161. ) -> tuple[bytes, str, str]:
  162. """Export statistics summary to CSV or Excel format.
  163. Args:
  164. format: Export format ('csv' or 'xlsx')
  165. days: Number of days to include in stats
  166. printer_id: Filter by printer
  167. project_id: Filter by project
  168. created_by_id: Filter by user who created the print (-1 for no user)
  169. Returns:
  170. Tuple of (file_bytes, filename, content_type)
  171. """
  172. from backend.app.services.failure_analysis import FailureAnalysisService
  173. # Get failure analysis data (includes stats)
  174. analysis_service = FailureAnalysisService(self.db)
  175. analysis = await analysis_service.analyze_failures(
  176. days=days,
  177. printer_id=printer_id,
  178. project_id=project_id,
  179. created_by_id=created_by_id,
  180. printer_scope=printer_scope,
  181. )
  182. # Build stats rows
  183. rows = [
  184. ["Metric", "Value"],
  185. ["Period (days)", analysis["period_days"]],
  186. ["Total Prints", analysis["total_prints"]],
  187. ["Failed Prints", analysis["failed_prints"]],
  188. ["Failure Rate (%)", analysis["failure_rate"]],
  189. [""],
  190. ["Failures by Reason", ""],
  191. ]
  192. for reason, count in analysis["failures_by_reason"].items():
  193. rows.append([reason, count])
  194. rows.append([""])
  195. rows.append(["Failures by Filament", ""])
  196. for filament, count in analysis["failures_by_filament"].items():
  197. rows.append([filament, count])
  198. rows.append([""])
  199. rows.append(["Failures by Printer", ""])
  200. for printer, count in analysis["failures_by_printer"].items():
  201. rows.append([printer, count])
  202. rows.append([""])
  203. rows.append(["Weekly Trend", ""])
  204. rows.append(["Week", "Total", "Failed", "Rate (%)"])
  205. for week in analysis["trend"]:
  206. rows.append(
  207. [
  208. week["week_start"],
  209. week["total_prints"],
  210. week["failed_prints"],
  211. week["failure_rate"],
  212. ]
  213. )
  214. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  215. if format == "xlsx":
  216. file_bytes = self._generate_xlsx_simple(rows)
  217. filename = f"stats_export_{timestamp}.xlsx"
  218. content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  219. else:
  220. file_bytes = self._generate_csv_simple(rows)
  221. filename = f"stats_export_{timestamp}.csv"
  222. content_type = "text/csv"
  223. return file_bytes, filename, content_type
  224. def _archive_to_row(self, archive: PrintArchive, fields: list[str]) -> list[Any]:
  225. """Convert an archive to a row of values."""
  226. row = []
  227. for field in fields:
  228. if field == "project_name":
  229. value = archive.project.name if archive.project else None
  230. elif field in ("started_at", "completed_at", "created_at"):
  231. value = getattr(archive, field)
  232. if value:
  233. value = value.isoformat()
  234. else:
  235. value = getattr(archive, field, None)
  236. row.append(value)
  237. return row
  238. def _generate_csv(self, headers: list[str], rows: list[list]) -> bytes:
  239. """Generate CSV file content."""
  240. output = io.StringIO()
  241. writer = csv.writer(output)
  242. writer.writerow(headers)
  243. writer.writerows(rows)
  244. return output.getvalue().encode("utf-8")
  245. def _generate_csv_simple(self, rows: list[list]) -> bytes:
  246. """Generate CSV file content from simple rows (no separate headers)."""
  247. output = io.StringIO()
  248. writer = csv.writer(output)
  249. writer.writerows(rows)
  250. return output.getvalue().encode("utf-8")
  251. def _generate_xlsx(self, headers: list[str], rows: list[list], fields: list[str]) -> bytes:
  252. """Generate Excel file content."""
  253. try:
  254. from openpyxl import Workbook
  255. from openpyxl.styles import Alignment, Font, PatternFill
  256. from openpyxl.utils import get_column_letter
  257. except ImportError:
  258. raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
  259. wb = Workbook()
  260. ws = wb.active
  261. ws.title = "Archives"
  262. # Header style
  263. header_font = Font(bold=True, color="FFFFFF")
  264. header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
  265. header_alignment = Alignment(horizontal="center")
  266. # Write headers
  267. for col, header in enumerate(headers, 1):
  268. cell = ws.cell(row=1, column=col, value=header)
  269. cell.font = header_font
  270. cell.fill = header_fill
  271. cell.alignment = header_alignment
  272. # Write data
  273. for row_idx, row in enumerate(rows, 2):
  274. for col_idx, value in enumerate(row, 1):
  275. ws.cell(row=row_idx, column=col_idx, value=value)
  276. # Auto-adjust column widths
  277. for col_idx, _field in enumerate(fields, 1):
  278. column_letter = get_column_letter(col_idx)
  279. max_length = len(headers[col_idx - 1])
  280. for row in rows:
  281. cell_value = row[col_idx - 1]
  282. if cell_value is not None:
  283. max_length = max(max_length, len(str(cell_value)))
  284. ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
  285. # Freeze header row
  286. ws.freeze_panes = "A2"
  287. output = io.BytesIO()
  288. wb.save(output)
  289. return output.getvalue()
  290. def _generate_xlsx_simple(self, rows: list[list]) -> bytes:
  291. """Generate Excel file content from simple rows."""
  292. try:
  293. from openpyxl import Workbook
  294. from openpyxl.styles import Font
  295. except ImportError:
  296. raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
  297. wb = Workbook()
  298. ws = wb.active
  299. ws.title = "Statistics"
  300. bold_font = Font(bold=True)
  301. for row_idx, row in enumerate(rows, 1):
  302. for col_idx, value in enumerate(row, 1):
  303. cell = ws.cell(row=row_idx, column=col_idx, value=value)
  304. # Bold section headers
  305. if col_idx == 1 and value and isinstance(value, str) and value.endswith(":"):
  306. cell.font = bold_font
  307. output = io.BytesIO()
  308. wb.save(output)
  309. return output.getvalue()