Files
TimeTracker/app/services/export_service.py
T
Dries Peeters 1836cb3c2d chore(typing): resolve mypy errors and harden type checking
Drives ``mypy app/`` from 567 errors in 208 files to 0 errors across the
376 source files checked by ``./scripts/run-ci-local.sh code-quality``.

Configuration & dependencies
- pyproject.toml: enable implicit_optional (Flask-style ``x: str = None``
  defaults), silence truthy-function/truthy-bool (legitimate import-guard
  checks like ``KanbanColumn``), and disable warn_return_any (SQLAlchemy
  1.x ``Query`` API returns Any pervasively). Add module overrides for
  ``app.models.*``, repositories, base CRUD service, and known
  ``joinedload`` / ``Query.paginate`` callers where mypy cannot model the
  Flask-SQLAlchemy runtime API without a plugin.
- requirements-test.txt: pin ``types-requests``, ``types-bleach``,
  ``types-Markdown``, ``types-python-dateutil`` so mypy stops complaining
  about missing stubs.

Latent bugs fixed while driving mypy to zero
- app/utils/logger.py, app/utils/datetime_utils.py: drop imports of
  symbols that don't exist (``get_performance_metrics``,
  ``from_app_timezone``, ``to_app_timezone``) — these would have raised
  at import time on first use.
- app/services/currency_service.py: ``from typing import Decimal`` was a
  bug (typing has no Decimal); switch to ``decimal.Decimal`` and rename
  the ``D`` alias.
- app/utils/env_validation.py, app/utils/role_migration.py: ``Dict[str,
  any]`` → ``Dict[str, Any]`` (built-in ``any`` is not a type).
- app/utils/email.py: introduce ``send_template_email`` and update the
  three callers (``client_approval_service``,
  ``client_notification_service``, ``workflow_engine``) that were
  passing ``to=``/``template=``/etc. to ``send_email`` whose signature
  doesn't accept them — calls would have raised TypeError at runtime.
- app/services/permission_service.py: rewrite ``grant_permission`` /
  ``revoke_permission`` to use the actual ``Role`` ↔ ``Permission``
  many-to-many relationship; the old code referenced non-existent
  ``Permission.role_id`` / ``Permission.granted`` columns.
- app/services/gps_tracking_service.py: pass the required ``title`` and
  ``expense_date`` fields when creating mileage ``Expense`` rows.
- app/services/workflow_engine.py: ``_perform_action`` now forwards the
  ``rule`` argument to ``_action_log_time``, and ``_action_webhook``
  short-circuits when ``url`` is missing.
- app/services/time_tracking_service.py: validate ``start_time`` /
  ``end_time`` before comparing them.
- app/services/export_service.py: build CSV in a ``StringIO`` then wrap
  the bytes in ``BytesIO`` — ``csv.writer`` requires text I/O.
- app/integrations/peppol_smp.py: avoid attribute access on ``None`` in
  the SMP ``href`` fallback.
- app/integrations/{github,gitlab,slack}.py: coerce query-string params
  to strings so ``requests.get(params=...)`` matches the typed signature
  (and is what the HTTP layer expects anyway).
- app/integrations/{xero,quickbooks}.py: guard ``get_access_token()``
  returning ``None`` before calling private ``_api_request`` helpers.

Annotation-only changes
- Add ``Dict[str, Any]`` / ``list`` / ``Optional[...]`` annotations to
  service dict-literals that mypy could not infer from heterogeneous
  values (``ai_suggestion_service``, ``ai_categorization_service``,
  ``custom_report_service``, ``unpaid_hours_service``,
  ``integration_service``, ``invoice_service``, ``backup_service``,
  ``inventory_report_service``, ``analytics_service``, etc.).
- ``app/utils/event_bus.py``: ``emit_event`` accepts ``str |
  WebhookEvent`` and normalizes to ``str`` so all call-sites type-check.
- ``app/utils/api_responses.py``: introduce ``ApiResponse`` alias for
  ``Response | tuple[Response, int] | tuple[str, int]``.
- ``app/utils/budget_forecasting.py``: forecasting helpers return
  ``Optional[Dict]`` (they already returned ``None`` when the project
  was missing).
- ``app/utils/pdf_generator_reportlab.py``: ``_normalize_color`` is
  ``Optional[str]``.
- ``app/utils/pdfa3.py``: remove invalid ``force_version=None`` retry
  call.
- Narrow ``type: ignore`` markers on optional-dependency fallbacks
  (``redis``, ``bleach``, ``markdown``, ``babel``,
  ``powerpoint_export``) and on the documented ``requests.Session``
  / ``RotatingFileHandler`` typeshed limitations.
2026-05-13 10:32:06 +02:00

198 lines
6.8 KiB
Python

"""
Service for data export operations.
"""
import csv
from datetime import date, datetime
from io import BytesIO, StringIO
from typing import Any, Dict, List, Optional
from app.models import Expense, Invoice, Project, TimeEntry
from app.repositories import ExpenseRepository, InvoiceRepository, ProjectRepository, TimeEntryRepository
class ExportService:
"""Service for export operations"""
def __init__(self):
self.time_entry_repo = TimeEntryRepository()
self.project_repo = ProjectRepository()
self.invoice_repo = InvoiceRepository()
self.expense_repo = ExpenseRepository()
def export_time_entries_csv(
self,
user_id: Optional[int] = None,
project_id: Optional[int] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
) -> BytesIO:
"""
Export time entries to CSV.
Returns:
BytesIO object with CSV data
"""
# Get entries
if start_date and end_date:
entries = self.time_entry_repo.get_by_date_range(
start_date=start_date, end_date=end_date, user_id=user_id, project_id=project_id, include_relations=True
)
elif project_id:
entries = self.time_entry_repo.get_by_project(project_id=project_id, include_relations=True)
elif user_id:
entries = self.time_entry_repo.get_by_user(user_id=user_id, include_relations=True)
else:
entries = []
# Create CSV in a text buffer then return bytes for binary download.
text_buffer = StringIO()
writer = csv.writer(text_buffer)
# Write header
writer.writerow(
[
"Date",
"User",
"Project",
"Task",
"Start Time",
"End Time",
"Duration (hours)",
"Notes",
"Tags",
"Billable",
"Source",
]
)
# Write rows
for entry in entries:
duration_hours = (entry.duration_seconds or 0) / 3600
writer.writerow(
[
entry.start_time.date().isoformat() if entry.start_time else "",
entry.user.username if entry.user else "",
entry.project.name if entry.project else "",
entry.task.name if entry.task else "",
entry.start_time.isoformat() if entry.start_time else "",
entry.end_time.isoformat() if entry.end_time else "",
f"{duration_hours:.2f}",
entry.notes or "",
entry.tags or "",
"Yes" if entry.billable else "No",
entry.source or "",
]
)
output = BytesIO(text_buffer.getvalue().encode("utf-8"))
return output
def export_projects_csv(self, status: Optional[str] = None, client_id: Optional[int] = None) -> BytesIO:
"""
Export projects to CSV.
Returns:
BytesIO object with CSV data
"""
# Get projects
if status == "active":
projects = self.project_repo.get_active_projects(client_id=client_id, include_relations=True)
else:
projects = (
self.project_repo.get_all()
if not client_id
else self.project_repo.get_by_client(client_id, status=status, include_relations=True)
)
# Create CSV in a text buffer then return bytes for binary download.
text_buffer = StringIO()
writer = csv.writer(text_buffer)
# Write header
writer.writerow(
["Name", "Client", "Status", "Billable", "Hourly Rate", "Budget", "Estimated Hours", "Created", "Updated"]
)
# Write rows
for project in projects:
writer.writerow(
[
project.name,
# Project.client is a string property; relationship is Project.client_obj
(
(project.client_obj.name if getattr(project, "client_obj", None) else project.client)
if project
else ""
),
project.status,
"Yes" if project.billable else "No",
str(project.hourly_rate) if project.hourly_rate else "",
str(project.budget_amount) if project.budget_amount else "",
str(project.estimated_hours) if project.estimated_hours else "",
project.created_at.isoformat() if project.created_at else "",
project.updated_at.isoformat() if project.updated_at else "",
]
)
output = BytesIO(text_buffer.getvalue().encode("utf-8"))
return output
def export_invoices_csv(self, status: Optional[str] = None, client_id: Optional[int] = None) -> BytesIO:
"""
Export invoices to CSV.
Returns:
BytesIO object with CSV data
"""
# Get invoices
if status:
invoices = self.invoice_repo.get_by_status(status, include_relations=True)
elif client_id:
invoices = self.invoice_repo.get_by_client(client_id, include_relations=True)
else:
invoices = self.invoice_repo.get_all()
# Create CSV in a text buffer then return bytes for binary download.
text_buffer = StringIO()
writer = csv.writer(text_buffer)
# Write header
writer.writerow(
[
"Invoice Number",
"Client",
"Project",
"Issue Date",
"Due Date",
"Status",
"Subtotal",
"Tax",
"Total",
"Amount Paid",
"Outstanding",
]
)
# Write rows
for invoice in invoices:
outstanding = invoice.total_amount - (invoice.amount_paid or 0)
writer.writerow(
[
invoice.invoice_number,
invoice.client_name,
invoice.project.name if invoice.project else "",
invoice.issue_date.isoformat() if invoice.issue_date else "",
invoice.due_date.isoformat() if invoice.due_date else "",
invoice.status,
str(invoice.subtotal),
str(invoice.tax_amount),
str(invoice.total_amount),
str(invoice.amount_paid or 0),
str(outstanding),
]
)
output = BytesIO(text_buffer.getvalue().encode("utf-8"))
return output