mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 04:08:48 -05:00
1836cb3c2d
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.
194 lines
7.2 KiB
Python
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,
|
|
}
|