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

130 lines
3.4 KiB
Python

"""Utility for migrating users from legacy role field to new role system"""
from typing import Any, Dict, Optional
from app import db
from app.models import Role, User
def migrate_user_roles(dry_run: bool = False) -> Dict[str, Any]:
"""
Migrate users from legacy role field to new role system.
Args:
dry_run: If True, don't make changes, just report what would be done
Returns:
Dictionary with migration statistics
"""
stats: Dict[str, Any] = {
"total_users": 0,
"users_with_roles": 0,
"users_needing_migration": 0,
"migrated": 0,
"failed": 0,
"errors": [],
}
# Get all users
users = User.query.all()
stats["total_users"] = len(users)
# Map of legacy role names to new role names
role_mapping = {
"admin": "admin",
"user": "user",
"manager": "manager",
"viewer": "viewer",
}
for user in users:
# Skip if user already has roles assigned
if user.roles:
stats["users_with_roles"] += 1
continue
# Skip if user has no legacy role or role is not in mapping
if not user.role or user.role not in role_mapping:
continue
stats["users_needing_migration"] += 1
# Get the target role
target_role_name = role_mapping[user.role]
target_role = Role.query.filter_by(name=target_role_name).first()
if not target_role:
error_msg = f"User {user.username}: Role '{target_role_name}' not found in database"
stats["errors"].append(error_msg)
stats["failed"] += 1
continue
if not dry_run:
try:
# Assign the role
user.roles.append(target_role)
db.session.commit()
stats["migrated"] += 1
except Exception as e:
db.session.rollback()
error_msg = f"User {user.username}: Failed to assign role - {str(e)}"
stats["errors"].append(error_msg)
stats["failed"] += 1
else:
# Dry run - just count what would be migrated
stats["migrated"] += 1
return stats
def migrate_single_user(user_id: int, role_name: Optional[str] = None) -> bool:
"""
Migrate a single user to the new role system.
Args:
user_id: ID of the user to migrate
role_name: Optional role name to assign. If None, uses legacy role field.
Returns:
True if successful, False otherwise
"""
user = User.query.get(user_id)
if not user:
return False
# If user already has roles, skip
if user.roles:
return True
# Determine target role
target_role_name: Optional[str]
if role_name:
target_role_name = role_name
elif user.role:
role_mapping = {
"admin": "admin",
"user": "user",
"manager": "manager",
"viewer": "viewer",
}
target_role_name = role_mapping.get(user.role)
else:
# Default to "user" role
target_role_name = "user"
if not target_role_name:
return False
# Get the role
target_role = Role.query.filter_by(name=target_role_name).first()
if not target_role:
return False
try:
user.roles.append(target_role)
db.session.commit()
return True
except Exception:
db.session.rollback()
return False