Files
TimeTracker/app/utils/datetime_utils.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

322 lines
7.9 KiB
Python

"""
Enhanced date and time utilities.
"""
from datetime import date, datetime, timedelta
from typing import Optional, Tuple
from dateutil.relativedelta import relativedelta
def parse_date(date_str: str, format: Optional[str] = None) -> Optional[date]:
"""
Parse a date string to a date object.
Args:
date_str: Date string
format: Optional format string (defaults to ISO format)
Returns:
date object or None if parsing fails
"""
if not date_str:
return None
try:
if format:
return datetime.strptime(date_str, format).date()
else:
# Try ISO format first
try:
return datetime.fromisoformat(date_str).date()
except ValueError:
# Try common formats
for fmt in ["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%Y/%m/%d"]:
try:
return datetime.strptime(date_str, fmt).date()
except ValueError:
continue
return None
except Exception:
return None
def parse_datetime(datetime_str: str, format: Optional[str] = None) -> Optional[datetime]:
"""
Parse a datetime string to a datetime object.
Args:
datetime_str: Datetime string
format: Optional format string (defaults to ISO format)
Returns:
datetime object or None if parsing fails
"""
if not datetime_str:
return None
try:
if format:
return datetime.strptime(datetime_str, format)
else:
# Try ISO format first
try:
return datetime.fromisoformat(datetime_str.replace("Z", "+00:00"))
except ValueError:
# Try common formats
for fmt in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%d/%m/%Y %H:%M:%S", "%m/%d/%Y %H:%M:%S"]:
try:
return datetime.strptime(datetime_str, fmt)
except ValueError:
continue
return None
except Exception:
return None
def format_date(d: date, format: str = "%Y-%m-%d") -> str:
"""
Format a date object to a string.
Args:
d: date object
format: Format string
Returns:
Formatted date string
"""
if not d:
return ""
return d.strftime(format)
def format_datetime(dt: datetime, format: str = "%Y-%m-%d %H:%M:%S") -> str:
"""
Format a datetime object to a string.
Args:
dt: datetime object
format: Format string
Returns:
Formatted datetime string
"""
if not dt:
return ""
return dt.strftime(format)
def get_date_range(
period: str = "month", start_date: Optional[date] = None, end_date: Optional[date] = None
) -> Tuple[date, date]:
"""
Get a date range for common periods.
Args:
period: Period type ('today', 'week', 'month', 'quarter', 'year', 'custom')
start_date: Custom start date (for 'custom' period)
end_date: Custom end date (for 'custom' period)
Returns:
tuple of (start_date, end_date)
"""
today = date.today()
if period == "today":
return today, today
elif period == "week":
# Start of week (Monday)
start = today - timedelta(days=today.weekday())
return start, today
elif period == "month":
start = today.replace(day=1)
return start, today
elif period == "quarter":
quarter = (today.month - 1) // 3
start = date(today.year, quarter * 3 + 1, 1)
return start, today
elif period == "year":
start = date(today.year, 1, 1)
return start, today
elif period == "custom":
if start_date and end_date:
return start_date, end_date
return today, today
else:
return today, today
def get_previous_period(period: str = "month", reference_date: Optional[date] = None) -> Tuple[date, date]:
"""
Get the previous period date range.
Args:
period: Period type ('week', 'month', 'quarter', 'year')
reference_date: Reference date (defaults to today)
Returns:
tuple of (start_date, end_date)
"""
ref = reference_date or date.today()
if period == "week":
start = ref - timedelta(days=ref.weekday() + 7)
end = start + timedelta(days=6)
return start, end
elif period == "month":
first_day = ref.replace(day=1)
start = first_day - relativedelta(months=1)
end = first_day - timedelta(days=1)
return start, end
elif period == "quarter":
quarter = (ref.month - 1) // 3
start = date(ref.year, quarter * 3 + 1, 1)
if quarter == 0:
start = date(ref.year - 1, 10, 1)
end = date(ref.year - 1, 12, 31)
else:
end = date(ref.year, quarter * 3, 1) - timedelta(days=1)
return start, end
elif period == "year":
start = date(ref.year - 1, 1, 1)
end = date(ref.year - 1, 12, 31)
return start, end
else:
return ref, ref
def calculate_duration(start: datetime, end: datetime) -> timedelta:
"""
Calculate duration between two datetimes.
Args:
start: Start datetime
end: End datetime
Returns:
timedelta object
"""
if not start or not end:
return timedelta(0)
return end - start
def format_duration(seconds: float, format: str = "hours") -> str:
"""
Format duration in seconds to a human-readable string.
Args:
seconds: Duration in seconds
format: Format type ('hours', 'detailed', 'short')
Returns:
Formatted duration string
"""
if format == "hours":
hours = seconds / 3600
return f"{hours:.2f}h"
elif format == "detailed":
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
parts = []
if hours > 0:
parts.append(f"{hours}h")
if minutes > 0:
parts.append(f"{minutes}m")
if secs > 0 or not parts:
parts.append(f"{secs}s")
return " ".join(parts)
elif format == "short":
hours_short = seconds / 3600
if hours_short < 1:
minutes_short = seconds / 60
return f"{int(minutes_short)}m"
return f"{hours_short:.1f}h"
else:
return f"{seconds}s"
def is_business_day(d: date) -> bool:
"""
Check if a date is a business day (Monday-Friday).
Args:
d: date object
Returns:
True if business day, False otherwise
"""
return d.weekday() < 5 # Monday = 0, Friday = 4
def add_business_days(start_date: date, days: int) -> date:
"""
Add business days to a date.
Args:
start_date: Start date
days: Number of business days to add
Returns:
Result date
"""
current = start_date
added = 0
while added < days:
current += timedelta(days=1)
if is_business_day(current):
added += 1
return current
def get_week_start_end(d: date) -> Tuple[date, date]:
"""
Get the start (Monday) and end (Sunday) of the week for a date.
Args:
d: date object
Returns:
tuple of (week_start, week_end)
"""
week_start = d - timedelta(days=d.weekday())
week_end = week_start + timedelta(days=6)
return week_start, week_end
def get_month_start_end(d: date) -> Tuple[date, date]:
"""
Get the start and end of the month for a date.
Args:
d: date object
Returns:
tuple of (month_start, month_end)
"""
month_start = d.replace(day=1)
if d.month == 12:
month_end = date(d.year + 1, 1, 1) - timedelta(days=1)
else:
month_end = date(d.year, d.month + 1, 1) - timedelta(days=1)
return month_start, month_end