Files
TimeTracker/app/services/analytics_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

194 lines
7.2 KiB
Python

"""
Service for analytics and insights business logic.
"""
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, case, func
from sqlalchemy.orm import joinedload
from app import db
from app.models import Project, TimeEntry
from app.repositories import ExpenseRepository, InvoiceRepository, ProjectRepository, TimeEntryRepository
class AnalyticsService:
"""Service for analytics operations"""
def __init__(self):
self.time_entry_repo = TimeEntryRepository()
self.project_repo = ProjectRepository()
self.invoice_repo = InvoiceRepository()
self.expense_repo = ExpenseRepository()
def get_dashboard_stats(self, user_id: Optional[int] = None) -> Dict[str, Any]:
"""
Get dashboard statistics.
Returns:
dict with dashboard metrics
"""
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today - timedelta(days=today.weekday())
month_start = today.replace(day=1)
# Today's time
today_seconds = self.time_entry_repo.get_total_duration(
user_id=user_id, start_date=today, end_date=datetime.now()
)
# This week's time
week_seconds = self.time_entry_repo.get_total_duration(
user_id=user_id, start_date=week_start, end_date=datetime.now()
)
# This month's time
month_seconds = self.time_entry_repo.get_total_duration(
user_id=user_id, start_date=month_start, end_date=datetime.now()
)
# Active projects
active_projects = self.project_repo.get_active_projects(user_id=user_id)
# Recent invoices
recent_invoices = self.invoice_repo.get_by_status("sent", include_relations=False)[:5]
# Overdue invoices
overdue_invoices = self.invoice_repo.get_overdue(include_relations=False)
return {
"time_tracking": {
"today_hours": round(today_seconds / 3600, 2),
"week_hours": round(week_seconds / 3600, 2),
"month_hours": round(month_seconds / 3600, 2),
},
"projects": {"active_count": len(active_projects)},
"invoices": {
"recent_count": len(recent_invoices),
"overdue_count": len(overdue_invoices),
"overdue_amount": sum(float(inv.total_amount - (inv.amount_paid or 0)) for inv in overdue_invoices),
},
}
def get_dashboard_top_projects(self, user_id: int, days: int = 30, limit: int = 5) -> List[Dict[str, Any]]:
"""
Get top projects by hours for the dashboard (DB GROUP BY to avoid loading all entries).
Returns list of dicts with keys: project, hours, billable_hours (sorted by hours desc, limited).
"""
period_start = datetime.utcnow().date() - timedelta(days=days)
rows = (
db.session.query(
TimeEntry.project_id,
func.sum(TimeEntry.duration_seconds).label("total_seconds"),
func.sum(
case(
(and_(TimeEntry.billable == True, Project.billable == True), TimeEntry.duration_seconds),
else_=0,
)
).label("billable_seconds"),
)
.join(Project, TimeEntry.project_id == Project.id)
.filter(
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= period_start,
TimeEntry.user_id == user_id,
TimeEntry.project_id.isnot(None),
)
.group_by(TimeEntry.project_id)
.order_by(func.sum(TimeEntry.duration_seconds).desc())
.limit(limit)
.all()
)
project_ids = [r.project_id for r in rows]
projects_by_id = (
{p.id: p for p in Project.query.filter(Project.id.in_(project_ids)).all()} if project_ids else {}
)
result = []
for r in rows:
project = projects_by_id.get(r.project_id)
if not project:
continue
total_seconds = int(r.total_seconds or 0)
billable_seconds = int(r.billable_seconds or 0)
result.append(
{
"project": project,
"hours": round(total_seconds / 3600, 2),
"billable_hours": round(billable_seconds / 3600, 2),
}
)
return result[:limit]
def get_time_by_project_chart(self, user_id: int, days: int = 7, limit: int = 10) -> Dict[str, Any]:
"""
Get time-by-project series for dashboard chart (DB GROUP BY to avoid loading all entries).
Returns dict with keys: series (list of {label, hours}), chart_labels, chart_hours.
"""
period_start = datetime.utcnow().date() - timedelta(days=days)
rows = (
db.session.query(
Project.name,
func.sum(TimeEntry.duration_seconds).label("total_seconds"),
)
.join(TimeEntry, TimeEntry.project_id == Project.id)
.filter(
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= period_start,
TimeEntry.user_id == user_id,
)
.group_by(TimeEntry.project_id, Project.name)
.order_by(func.sum(TimeEntry.duration_seconds).desc())
.limit(limit)
.all()
)
series = [{"label": r.name or "", "hours": round((r.total_seconds or 0) / 3600, 2)} for r in rows]
return {
"series": series,
"chart_labels": [x["label"] for x in series],
"chart_hours": [x["hours"] for x in series],
}
def get_trends(self, user_id: Optional[int] = None, days: int = 30) -> Dict[str, Any]:
"""
Get time tracking trends.
Returns:
dict with daily/hourly trends
"""
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# Get entries
entries = self.time_entry_repo.get_by_date_range(
start_date=start_date, end_date=end_date, user_id=user_id, include_relations=False
)
# Group by date
daily_hours = {}
for entry in entries:
entry_date = entry.start_time.date()
hours = (entry.duration_seconds or 0) / 3600
if entry_date not in daily_hours:
daily_hours[entry_date] = 0.0
daily_hours[entry_date] += hours
# Create trend data
trend_data = []
current_date = start_date.date()
while current_date <= end_date.date():
trend_data.append({"date": current_date.isoformat(), "hours": round(daily_hours.get(current_date, 0), 2)})
current_date += timedelta(days=1)
return {
"period": {
"start_date": start_date.date().isoformat(),
"end_date": end_date.date().isoformat(),
"days": days,
},
"daily_trends": trend_data,
"total_hours": round(sum(daily_hours.values()), 2),
"average_daily_hours": round(sum(daily_hours.values()) / days, 2) if days > 0 else 0,
}