mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 12:50:11 -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.
322 lines
7.9 KiB
Python
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
|