mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
Deleting a print that belonged to a project left it on the project page as a card with a missing thumbnail, and there was no way to remove it. Deleting a print is a soft delete by default (#1343): the files go from disk, the row stays so global Quick Stats keeps counting its filament, time and cost. Every other consumer filters those rows out. The projects module filtered none of them — the only deleted_at check in the whole file was for LibraryFile — so a deleted print kept its project_id and kept being listed, pointing at a thumbnail that no longer existed. The same broken previews appeared on the overview cards, and in the timeline, where the entry links to an archive that no longer opens. Unassigning was impossible because the only UI that can change a print's project lives on the Archives page, which correctly hides deleted prints: visible on the project, unreachable from anywhere. All eight project-scoped archive queries now filter, counts included. That last part is a deliberate divergence from #1343, where the whole point of the soft delete is that the contribution survives: a project is a piece of work with a definite membership, not a lifetime total, so a project that lists eleven prints must not claim twelve. The reasoning is recorded at the constant so nobody later "fixes" it back. remove_archives_from_project keeps working on hidden rows on purpose — it is the repair path for links written before this. The BOM print_name lookups are left alone; naming a since-deleted print is still correct. Two more consumers had the same gap. The CSV/Excel export handed back rows the interface says are gone — filtered at the base query, since the export is the list you are looking at saved to a file. Per-project failure analysis measured a failure rate against prints deleted from the project, and disagreed with the project's own numbers; only the project-scoped branch filters, global analysis still counts every run including orphans as #1390 established. Finally, the project page needed a manual reload to catch up. staleTime is 60s and the delete mutations invalidated only ['archives'], so a project visited within the minute served its cached copy, print still there. The project-assign mutations had the mirror-image bug: ['projects'] refreshed the overview cards but never ['project', id]. Both now go through one shared helper covering every project-derived key, as bare prefixes so all cached project ids are matched.
350 lines
12 KiB
Python
350 lines
12 KiB
Python
import csv
|
|
import io
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from backend.app.models.archive import PrintArchive
|
|
|
|
|
|
class ExportService:
|
|
"""Service for exporting archive data to CSV/Excel formats."""
|
|
|
|
# Default fields to export
|
|
DEFAULT_FIELDS = [
|
|
"id",
|
|
"print_name",
|
|
"filename",
|
|
"status",
|
|
"quantity",
|
|
"printer_id",
|
|
"project_name",
|
|
"filament_type",
|
|
"filament_used_grams",
|
|
"print_time_seconds",
|
|
"layer_height",
|
|
"nozzle_diameter",
|
|
"bed_temperature",
|
|
"nozzle_temperature",
|
|
"total_layers",
|
|
"cost",
|
|
"designer",
|
|
"tags",
|
|
"notes",
|
|
"failure_reason",
|
|
"started_at",
|
|
"completed_at",
|
|
"created_at",
|
|
]
|
|
|
|
# Field labels for headers
|
|
FIELD_LABELS = {
|
|
"id": "ID",
|
|
"print_name": "Print Name",
|
|
"filename": "Filename",
|
|
"status": "Status",
|
|
"quantity": "Items Printed",
|
|
"printer_id": "Printer ID",
|
|
"project_name": "Project",
|
|
"filament_type": "Filament Type",
|
|
"filament_used_grams": "Filament (g)",
|
|
"print_time_seconds": "Print Time (s)",
|
|
"layer_height": "Layer Height (mm)",
|
|
"nozzle_diameter": "Nozzle (mm)",
|
|
"bed_temperature": "Bed Temp (°C)",
|
|
"nozzle_temperature": "Nozzle Temp (°C)",
|
|
"total_layers": "Total Layers",
|
|
"cost": "Cost",
|
|
"designer": "Designer",
|
|
"tags": "Tags",
|
|
"notes": "Notes",
|
|
"failure_reason": "Failure Reason",
|
|
"started_at": "Started At",
|
|
"completed_at": "Completed At",
|
|
"created_at": "Created At",
|
|
}
|
|
|
|
def __init__(self, db: AsyncSession):
|
|
self.db = db
|
|
|
|
async def export_archives(
|
|
self,
|
|
format: str = "csv",
|
|
fields: list[str] | None = None,
|
|
printer_id: int | None = None,
|
|
project_id: int | None = None,
|
|
status: str | None = None,
|
|
date_from: datetime | None = None,
|
|
date_to: datetime | None = None,
|
|
search: str | None = None,
|
|
visible_to_user_id: int | None = None,
|
|
) -> tuple[bytes, str, str]:
|
|
"""Export archives to CSV or Excel format.
|
|
|
|
Args:
|
|
format: Export format ('csv' or 'xlsx')
|
|
fields: List of fields to include (None = all default fields)
|
|
printer_id: Filter by printer
|
|
project_id: Filter by project
|
|
status: Filter by status
|
|
date_from: Filter by start date
|
|
date_to: Filter by end date
|
|
search: Search filter
|
|
visible_to_user_id: Scope rows to those owned by this user (used
|
|
when the caller has ARCHIVES_READ_OWN but not _ALL).
|
|
|
|
Returns:
|
|
Tuple of (file_bytes, filename, content_type)
|
|
"""
|
|
# Build query. Soft-deleted archives (#1343) are excluded: this export
|
|
# is the list the user is looking at, saved to a file, and that list
|
|
# hides them — an export that silently contains rows the UI says are
|
|
# gone is worse than useless for reconciling anything (#2731).
|
|
query = (
|
|
select(PrintArchive)
|
|
.options(selectinload(PrintArchive.project))
|
|
.where(PrintArchive.deleted_at.is_(None))
|
|
.order_by(PrintArchive.created_at.desc())
|
|
)
|
|
|
|
# Apply filters
|
|
if printer_id:
|
|
query = query.where(PrintArchive.printer_id == printer_id)
|
|
if project_id:
|
|
query = query.where(PrintArchive.project_id == project_id)
|
|
if status:
|
|
query = query.where(PrintArchive.status == status)
|
|
if date_from:
|
|
query = query.where(PrintArchive.created_at >= date_from)
|
|
if date_to:
|
|
query = query.where(PrintArchive.created_at <= date_to)
|
|
if visible_to_user_id is not None:
|
|
query = query.where(PrintArchive.created_by_id == visible_to_user_id)
|
|
if search:
|
|
like_pattern = f"%{search}%"
|
|
query = query.where(
|
|
(PrintArchive.print_name.ilike(like_pattern))
|
|
| (PrintArchive.filename.ilike(like_pattern))
|
|
| (PrintArchive.tags.ilike(like_pattern))
|
|
| (PrintArchive.notes.ilike(like_pattern))
|
|
| (PrintArchive.designer.ilike(like_pattern))
|
|
)
|
|
|
|
# Execute query
|
|
result = await self.db.execute(query)
|
|
archives = list(result.scalars().all())
|
|
|
|
# Determine fields to export
|
|
export_fields = fields if fields else self.DEFAULT_FIELDS
|
|
|
|
# Convert to rows
|
|
rows = []
|
|
for archive in archives:
|
|
row = self._archive_to_row(archive, export_fields)
|
|
rows.append(row)
|
|
|
|
# Generate headers
|
|
headers = [self.FIELD_LABELS.get(f, f) for f in export_fields]
|
|
|
|
# Generate file
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
if format == "xlsx":
|
|
file_bytes = self._generate_xlsx(headers, rows, export_fields)
|
|
filename = f"archives_export_{timestamp}.xlsx"
|
|
content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
else:
|
|
file_bytes = self._generate_csv(headers, rows)
|
|
filename = f"archives_export_{timestamp}.csv"
|
|
content_type = "text/csv"
|
|
|
|
return file_bytes, filename, content_type
|
|
|
|
async def export_stats(
|
|
self,
|
|
format: str = "csv",
|
|
days: int = 30,
|
|
printer_id: int | None = None,
|
|
project_id: int | None = None,
|
|
created_by_id: int | None = None,
|
|
) -> tuple[bytes, str, str]:
|
|
"""Export statistics summary to CSV or Excel format.
|
|
|
|
Args:
|
|
format: Export format ('csv' or 'xlsx')
|
|
days: Number of days to include in stats
|
|
printer_id: Filter by printer
|
|
project_id: Filter by project
|
|
created_by_id: Filter by user who created the print (-1 for no user)
|
|
|
|
Returns:
|
|
Tuple of (file_bytes, filename, content_type)
|
|
"""
|
|
from backend.app.services.failure_analysis import FailureAnalysisService
|
|
|
|
# Get failure analysis data (includes stats)
|
|
analysis_service = FailureAnalysisService(self.db)
|
|
analysis = await analysis_service.analyze_failures(
|
|
days=days,
|
|
printer_id=printer_id,
|
|
project_id=project_id,
|
|
created_by_id=created_by_id,
|
|
)
|
|
|
|
# Build stats rows
|
|
rows = [
|
|
["Metric", "Value"],
|
|
["Period (days)", analysis["period_days"]],
|
|
["Total Prints", analysis["total_prints"]],
|
|
["Failed Prints", analysis["failed_prints"]],
|
|
["Failure Rate (%)", analysis["failure_rate"]],
|
|
[""],
|
|
["Failures by Reason", ""],
|
|
]
|
|
|
|
for reason, count in analysis["failures_by_reason"].items():
|
|
rows.append([reason, count])
|
|
|
|
rows.append([""])
|
|
rows.append(["Failures by Filament", ""])
|
|
|
|
for filament, count in analysis["failures_by_filament"].items():
|
|
rows.append([filament, count])
|
|
|
|
rows.append([""])
|
|
rows.append(["Failures by Printer", ""])
|
|
|
|
for printer, count in analysis["failures_by_printer"].items():
|
|
rows.append([printer, count])
|
|
|
|
rows.append([""])
|
|
rows.append(["Weekly Trend", ""])
|
|
rows.append(["Week", "Total", "Failed", "Rate (%)"])
|
|
|
|
for week in analysis["trend"]:
|
|
rows.append(
|
|
[
|
|
week["week_start"],
|
|
week["total_prints"],
|
|
week["failed_prints"],
|
|
week["failure_rate"],
|
|
]
|
|
)
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
if format == "xlsx":
|
|
file_bytes = self._generate_xlsx_simple(rows)
|
|
filename = f"stats_export_{timestamp}.xlsx"
|
|
content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
else:
|
|
file_bytes = self._generate_csv_simple(rows)
|
|
filename = f"stats_export_{timestamp}.csv"
|
|
content_type = "text/csv"
|
|
|
|
return file_bytes, filename, content_type
|
|
|
|
def _archive_to_row(self, archive: PrintArchive, fields: list[str]) -> list[Any]:
|
|
"""Convert an archive to a row of values."""
|
|
row = []
|
|
for field in fields:
|
|
if field == "project_name":
|
|
value = archive.project.name if archive.project else None
|
|
elif field in ("started_at", "completed_at", "created_at"):
|
|
value = getattr(archive, field)
|
|
if value:
|
|
value = value.isoformat()
|
|
else:
|
|
value = getattr(archive, field, None)
|
|
row.append(value)
|
|
return row
|
|
|
|
def _generate_csv(self, headers: list[str], rows: list[list]) -> bytes:
|
|
"""Generate CSV file content."""
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(headers)
|
|
writer.writerows(rows)
|
|
return output.getvalue().encode("utf-8")
|
|
|
|
def _generate_csv_simple(self, rows: list[list]) -> bytes:
|
|
"""Generate CSV file content from simple rows (no separate headers)."""
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerows(rows)
|
|
return output.getvalue().encode("utf-8")
|
|
|
|
def _generate_xlsx(self, headers: list[str], rows: list[list], fields: list[str]) -> bytes:
|
|
"""Generate Excel file content."""
|
|
try:
|
|
from openpyxl import Workbook
|
|
from openpyxl.styles import Alignment, Font, PatternFill
|
|
from openpyxl.utils import get_column_letter
|
|
except ImportError:
|
|
raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
|
|
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = "Archives"
|
|
|
|
# Header style
|
|
header_font = Font(bold=True, color="FFFFFF")
|
|
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
|
header_alignment = Alignment(horizontal="center")
|
|
|
|
# Write headers
|
|
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
|
|
|
|
# Write data
|
|
for row_idx, row in enumerate(rows, 2):
|
|
for col_idx, value in enumerate(row, 1):
|
|
ws.cell(row=row_idx, column=col_idx, value=value)
|
|
|
|
# Auto-adjust column widths
|
|
for col_idx, _field in enumerate(fields, 1):
|
|
column_letter = get_column_letter(col_idx)
|
|
max_length = len(headers[col_idx - 1])
|
|
for row in rows:
|
|
cell_value = row[col_idx - 1]
|
|
if cell_value is not None:
|
|
max_length = max(max_length, len(str(cell_value)))
|
|
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
|
|
|
|
# Freeze header row
|
|
ws.freeze_panes = "A2"
|
|
|
|
output = io.BytesIO()
|
|
wb.save(output)
|
|
return output.getvalue()
|
|
|
|
def _generate_xlsx_simple(self, rows: list[list]) -> bytes:
|
|
"""Generate Excel file content from simple rows."""
|
|
try:
|
|
from openpyxl import Workbook
|
|
from openpyxl.styles import Font
|
|
except ImportError:
|
|
raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
|
|
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = "Statistics"
|
|
|
|
bold_font = Font(bold=True)
|
|
|
|
for row_idx, row in enumerate(rows, 1):
|
|
for col_idx, value in enumerate(row, 1):
|
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
|
# Bold section headers
|
|
if col_idx == 1 and value and isinstance(value, str) and value.endswith(":"):
|
|
cell.font = bold_font
|
|
|
|
output = io.BytesIO()
|
|
wb.save(output)
|
|
return output.getvalue()
|