export.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  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. from backend.app.models.print_log import INCLUDE_OPEN_RUNS, PrintLogEntry
  11. # What one print of an archive has of its own (archive redesign): in the
  12. # export, its run's. Everything else is the file's, the archive's.
  13. _PER_PRINT_FIELDS = frozenset(
  14. {
  15. "status",
  16. "quantity",
  17. "printer_id",
  18. "printer_name",
  19. "plate_id",
  20. "filament_type",
  21. "filament_used_grams",
  22. "cost",
  23. "energy_kwh",
  24. "energy_cost",
  25. "wear_cost",
  26. "failure_reason",
  27. "user_verdict",
  28. "duration_seconds",
  29. "started_at",
  30. "completed_at",
  31. }
  32. )
  33. class ExportService:
  34. """Service for exporting archive data to CSV/Excel formats."""
  35. # Default fields to export
  36. DEFAULT_FIELDS = [
  37. "id",
  38. "run_id",
  39. "print_name",
  40. "filename",
  41. "status",
  42. "quantity",
  43. "printer_id",
  44. "printer_name",
  45. "plate_id",
  46. "project_name",
  47. "filament_type",
  48. "filament_used_grams",
  49. "print_time_seconds",
  50. "layer_height",
  51. "nozzle_diameter",
  52. "bed_temperature",
  53. "nozzle_temperature",
  54. "total_layers",
  55. "cost",
  56. "designer",
  57. "tags",
  58. "notes",
  59. "failure_reason",
  60. "user_verdict",
  61. "duration_seconds",
  62. "started_at",
  63. "completed_at",
  64. "created_at",
  65. ]
  66. # Field labels for headers
  67. FIELD_LABELS = {
  68. "id": "ID",
  69. "run_id": "Print ID",
  70. "printer_name": "Printer",
  71. "plate_id": "Plate",
  72. "user_verdict": "Outcome",
  73. "duration_seconds": "Duration (s)",
  74. "energy_kwh": "Energy (kWh)",
  75. "print_name": "Print Name",
  76. "filename": "Filename",
  77. "status": "Status",
  78. "quantity": "Items Printed",
  79. "printer_id": "Printer ID",
  80. "project_name": "Project",
  81. "filament_type": "Filament Type",
  82. "filament_used_grams": "Filament (g)",
  83. "print_time_seconds": "Print Time (s)",
  84. "layer_height": "Layer Height (mm)",
  85. "nozzle_diameter": "Nozzle (mm)",
  86. "bed_temperature": "Bed Temp (°C)",
  87. "nozzle_temperature": "Nozzle Temp (°C)",
  88. "total_layers": "Total Layers",
  89. "cost": "Cost",
  90. "energy_cost": "Energy Cost",
  91. "wear_cost": "Wear Cost",
  92. "designer": "Designer",
  93. "tags": "Tags",
  94. "notes": "Notes",
  95. "failure_reason": "Failure Reason",
  96. "started_at": "Started At",
  97. "completed_at": "Completed At",
  98. "created_at": "Created At",
  99. }
  100. @classmethod
  101. def unknown_fields(cls, fields: list[str]) -> list[str]:
  102. """The requested columns the export does not have: only its own
  103. columns are exported, never other attributes of an archive (its
  104. one-tap confirm token is a credential)."""
  105. return [f for f in fields if f not in cls.FIELD_LABELS]
  106. def __init__(self, db: AsyncSession):
  107. self.db = db
  108. async def export_archives(
  109. self,
  110. format: str = "csv",
  111. fields: list[str] | None = None,
  112. printer_id: int | None = None,
  113. project_id: int | None = None,
  114. status: str | None = None,
  115. date_from: datetime | None = None,
  116. date_to: datetime | None = None,
  117. search: str | None = None,
  118. visible_to_user_id: int | None = None,
  119. printer_scope: PrinterScope | None = None,
  120. ) -> tuple[bytes, str, str]:
  121. """Export archives to CSV or Excel format, one row per print.
  122. An archive is a file, each print of it a run (archive redesign): a row
  123. has the archive's file columns and one run's own (``_PER_PRINT_FIELDS``).
  124. An archive printed before runs were logged is one row of its own
  125. fields; one never printed, one row with empty print columns.
  126. ``printer_id``, ``status`` and the dates pick prints (a run's start,
  127. an archive without runs by when it was created); ``project_id``,
  128. ``search`` and ``visible_to_user_id`` pick archives. A caller limited
  129. to some printers gets only the prints on theirs.
  130. Returns:
  131. Tuple of (file_bytes, filename, content_type)
  132. """
  133. # Soft-deleted archives (#1343) are excluded: this export is the list
  134. # the user is looking at, saved to a file, and that list hides them —
  135. # an export that silently contains rows the UI says are gone is worse
  136. # than useless for reconciling anything (#2731).
  137. query = (
  138. select(PrintArchive)
  139. .options(selectinload(PrintArchive.project), selectinload(PrintArchive.printer))
  140. .where(PrintArchive.deleted_at.is_(None))
  141. .order_by(PrintArchive.created_at.desc())
  142. )
  143. if project_id:
  144. query = query.where(PrintArchive.project_id == project_id)
  145. if visible_to_user_id is not None:
  146. query = query.where(PrintArchive.created_by_id == visible_to_user_id)
  147. from backend.app.core.row_visibility import archive_scope_clause
  148. if printer_scope is not None and (clause := archive_scope_clause(printer_scope)) is not None:
  149. query = query.where(clause)
  150. if search:
  151. like_pattern = f"%{search}%"
  152. query = query.where(
  153. (PrintArchive.print_name.ilike(like_pattern))
  154. | (PrintArchive.filename.ilike(like_pattern))
  155. | (PrintArchive.tags.ilike(like_pattern))
  156. | (PrintArchive.notes.ilike(like_pattern))
  157. | (PrintArchive.designer.ilike(like_pattern))
  158. )
  159. archives = list((await self.db.execute(query)).scalars().all())
  160. # Every archive's runs, a print running now included; the caller's
  161. # only, when limited. By the archive query itself, not a list of its
  162. # ids: a farm's archives outnumber what one statement may bind.
  163. printed: set[int] = set()
  164. runs: dict[int, list[PrintLogEntry]] = {}
  165. if archives:
  166. archive_ids = query.with_only_columns(PrintArchive.id).order_by(None)
  167. all_runs = (
  168. await self.db.scalars(
  169. select(PrintLogEntry)
  170. .where(PrintLogEntry.archive_id.in_(archive_ids))
  171. .order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc())
  172. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  173. )
  174. ).all()
  175. for run in all_runs:
  176. if run.archive_id is None:
  177. continue
  178. printed.add(run.archive_id)
  179. if printer_scope is None or printer_scope.allows(run.printer_id):
  180. runs.setdefault(run.archive_id, []).append(run)
  181. statuses = {s.strip() for s in status.split(",") if s.strip()} if status else set()
  182. def picked(printer: int | None, row_status: str | None, when: datetime | None) -> bool:
  183. if printer_id and printer != printer_id:
  184. return False
  185. if statuses and row_status not in statuses:
  186. return False
  187. if date_from and (when is None or when < date_from):
  188. return False
  189. return not (date_to and (when is None or when > date_to))
  190. export_fields = fields if fields else self.DEFAULT_FIELDS
  191. rows = []
  192. for archive in archives:
  193. if archive.id in runs:
  194. for run in runs[archive.id]:
  195. if picked(run.printer_id, run.status, run.started_at or run.created_at):
  196. rows.append(self._archive_to_row(archive, export_fields, run))
  197. elif archive.id not in printed:
  198. # Printed before runs were logged, or never: the archive's own.
  199. if picked(archive.printer_id, archive.status, archive.created_at):
  200. rows.append(self._archive_to_row(archive, export_fields))
  201. elif not (printer_id or status or date_from or date_to):
  202. # Printed only on printers outside the caller's scope: the file.
  203. rows.append(self._archive_to_row(archive, export_fields, hide_prints=True))
  204. # Generate headers
  205. headers = [self.FIELD_LABELS.get(f, f) for f in export_fields]
  206. # Generate file
  207. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  208. if format == "xlsx":
  209. file_bytes = self._generate_xlsx(headers, rows, export_fields)
  210. filename = f"archives_export_{timestamp}.xlsx"
  211. content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  212. else:
  213. file_bytes = self._generate_csv(headers, rows)
  214. filename = f"archives_export_{timestamp}.csv"
  215. content_type = "text/csv"
  216. return file_bytes, filename, content_type
  217. async def export_stats(
  218. self,
  219. format: str = "csv",
  220. days: int = 30,
  221. printer_id: int | None = None,
  222. project_id: int | None = None,
  223. created_by_id: int | None = None,
  224. printer_scope: PrinterScope | None = None,
  225. ) -> tuple[bytes, str, str]:
  226. """Export statistics summary to CSV or Excel format.
  227. Args:
  228. format: Export format ('csv' or 'xlsx')
  229. days: Number of days to include in stats
  230. printer_id: Filter by printer
  231. project_id: Filter by project
  232. created_by_id: Filter by user who created the print (-1 for no user)
  233. Returns:
  234. Tuple of (file_bytes, filename, content_type)
  235. """
  236. from backend.app.services.failure_analysis import FailureAnalysisService
  237. # Get failure analysis data (includes stats)
  238. analysis_service = FailureAnalysisService(self.db)
  239. analysis = await analysis_service.analyze_failures(
  240. days=days,
  241. printer_id=printer_id,
  242. project_id=project_id,
  243. created_by_id=created_by_id,
  244. printer_scope=printer_scope,
  245. )
  246. # Build stats rows
  247. rows = [
  248. ["Metric", "Value"],
  249. ["Period (days)", analysis["period_days"]],
  250. ["Total Prints", analysis["total_prints"]],
  251. ["Failed Prints", analysis["failed_prints"]],
  252. ["Failure Rate (%)", analysis["failure_rate"]],
  253. [""],
  254. ["Failures by Reason", ""],
  255. ]
  256. for reason, count in analysis["failures_by_reason"].items():
  257. rows.append([reason, count])
  258. rows.append([""])
  259. rows.append(["Failures by Filament", ""])
  260. for filament, count in analysis["failures_by_filament"].items():
  261. rows.append([filament, count])
  262. rows.append([""])
  263. rows.append(["Failures by Printer", ""])
  264. for printer, count in analysis["failures_by_printer"].items():
  265. rows.append([printer, count])
  266. rows.append([""])
  267. rows.append(["Weekly Trend", ""])
  268. rows.append(["Week", "Total", "Failed", "Rate (%)"])
  269. for week in analysis["trend"]:
  270. rows.append(
  271. [
  272. week["week_start"],
  273. week["total_prints"],
  274. week["failed_prints"],
  275. week["failure_rate"],
  276. ]
  277. )
  278. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  279. if format == "xlsx":
  280. file_bytes = self._generate_xlsx_simple(rows)
  281. filename = f"stats_export_{timestamp}.xlsx"
  282. content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  283. else:
  284. file_bytes = self._generate_csv_simple(rows)
  285. filename = f"stats_export_{timestamp}.csv"
  286. content_type = "text/csv"
  287. return file_bytes, filename, content_type
  288. def _archive_to_row(
  289. self,
  290. archive: PrintArchive,
  291. fields: list[str],
  292. run: PrintLogEntry | None = None,
  293. *,
  294. hide_prints: bool = False,
  295. ) -> list[Any]:
  296. """One row: the archive's file columns, and ``run``'s own (without a
  297. run, the archive's, as it kept its print before runs; empty with
  298. ``hide_prints``)."""
  299. row = []
  300. for field in fields:
  301. if field == "project_name":
  302. value = archive.project.name if archive.project else None
  303. elif field == "run_id":
  304. value = run.id if run is not None else None
  305. elif field in _PER_PRINT_FIELDS and hide_prints:
  306. value = None
  307. elif field in _PER_PRINT_FIELDS and run is not None:
  308. value = getattr(run, field, None)
  309. # Runs logged before they kept these share their archive's.
  310. if field in ("filament_type", "quantity") and value is None:
  311. value = getattr(archive, field)
  312. elif field == "printer_name":
  313. value = archive.printer.name if archive.printer is not None else None
  314. elif field == "duration_seconds":
  315. started, completed = archive.started_at, archive.completed_at
  316. value = (
  317. int((completed - started).total_seconds())
  318. if started and completed and completed > started
  319. else None
  320. )
  321. else:
  322. value = getattr(archive, field, None)
  323. if isinstance(value, datetime):
  324. value = value.isoformat()
  325. row.append(value)
  326. return row
  327. def _generate_csv(self, headers: list[str], rows: list[list]) -> bytes:
  328. """Generate CSV file content."""
  329. output = io.StringIO()
  330. writer = csv.writer(output)
  331. writer.writerow(headers)
  332. writer.writerows(rows)
  333. return output.getvalue().encode("utf-8")
  334. def _generate_csv_simple(self, rows: list[list]) -> bytes:
  335. """Generate CSV file content from simple rows (no separate headers)."""
  336. output = io.StringIO()
  337. writer = csv.writer(output)
  338. writer.writerows(rows)
  339. return output.getvalue().encode("utf-8")
  340. def _generate_xlsx(self, headers: list[str], rows: list[list], fields: list[str]) -> bytes:
  341. """Generate Excel file content."""
  342. try:
  343. from openpyxl import Workbook
  344. from openpyxl.styles import Alignment, Font, PatternFill
  345. from openpyxl.utils import get_column_letter
  346. except ImportError:
  347. raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
  348. wb = Workbook()
  349. ws = wb.active
  350. ws.title = "Archives"
  351. # Header style
  352. header_font = Font(bold=True, color="FFFFFF")
  353. header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
  354. header_alignment = Alignment(horizontal="center")
  355. # Write headers
  356. for col, header in enumerate(headers, 1):
  357. cell = ws.cell(row=1, column=col, value=header)
  358. cell.font = header_font
  359. cell.fill = header_fill
  360. cell.alignment = header_alignment
  361. # Write data
  362. for row_idx, row in enumerate(rows, 2):
  363. for col_idx, value in enumerate(row, 1):
  364. ws.cell(row=row_idx, column=col_idx, value=value)
  365. # Auto-adjust column widths
  366. for col_idx, _field in enumerate(fields, 1):
  367. column_letter = get_column_letter(col_idx)
  368. max_length = len(headers[col_idx - 1])
  369. for row in rows:
  370. cell_value = row[col_idx - 1]
  371. if cell_value is not None:
  372. max_length = max(max_length, len(str(cell_value)))
  373. ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
  374. # Freeze header row
  375. ws.freeze_panes = "A2"
  376. output = io.BytesIO()
  377. wb.save(output)
  378. return output.getvalue()
  379. def _generate_xlsx_simple(self, rows: list[list]) -> bytes:
  380. """Generate Excel file content from simple rows."""
  381. try:
  382. from openpyxl import Workbook
  383. from openpyxl.styles import Font
  384. except ImportError:
  385. raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
  386. wb = Workbook()
  387. ws = wb.active
  388. ws.title = "Statistics"
  389. bold_font = Font(bold=True)
  390. for row_idx, row in enumerate(rows, 1):
  391. for col_idx, value in enumerate(row, 1):
  392. cell = ws.cell(row=row_idx, column=col_idx, value=value)
  393. # Bold section headers
  394. if col_idx == 1 and value and isinstance(value, str) and value.endswith(":"):
  395. cell.font = bold_font
  396. output = io.BytesIO()
  397. wb.save(output)
  398. return output.getvalue()