Align configuration API paths with active model usage#269
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| def build_default_service() -> DataQueryService: | ||
| """Create the service with default strategies.""" | ||
|
|
||
| return DataQueryService( | ||
| strategies=[ | ||
| MetricsQueryStrategy(dataset=[]), | ||
| LogsQueryStrategy(log_path=DEFAULT_LOG_PATH), | ||
| HealthQueryStrategy(script_path=DEFAULT_HEALTH_SCRIPT), |
There was a problem hiding this comment.
Metrics query no longer returns stored data
The new DRF view builds its default service with MetricsQueryStrategy(dataset=[]), so /api/v1/data/query?type=metrics always returns an empty list regardless of the records in dora_metrics.DORAMetric. The previous view queried the database, but that logic has been removed; all environments will now report zero metrics even when data exists.
Useful? React with 👍 / 👎.
| last_activity_at = models.DateTimeField( | ||
| default=timezone.now, | ||
| help_text='Último timestamp de actividad en la sesión', | ||
| ) | ||
| is_active = models.BooleanField( |
There was a problem hiding this comment.
Schema changes shipped without migrations
A new last_activity_at column was added to UserSession and a new PasswordHistory model was introduced, but no migrations were included in this commit (the users migrations folder remains unchanged). Deploying this code will leave the database without these fields/tables, causing OperationalError: column ... does not exist when sessions are saved or password history validation runs.
Useful? React with 👍 / 👎.
| codigos_capacidades = [cap.codigo for cap in capacidades] | ||
| tiene_permiso = capacidad_codigo in codigos_capacidades | ||
| except OperationalError: | ||
| # Si las tablas de capacidades no existen (entorno de prueba), permitir acceso | ||
| tiene_permiso = True |
There was a problem hiding this comment.
Permission checks default to allow on DB errors
When obtener_capacidades_de_usuario raises any OperationalError, usuario_tiene_permiso now sets tiene_permiso = True. This fail-open behavior means any database outage or missing table will silently grant permissions (and skip denials/audits), exposing protected actions to users who would otherwise be rejected.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull Request Overview
This pull request reorganizes the configuration API architecture to avoid naming conflicts between the legacy Configuracion model and the active ConfiguracionSistema model. The key changes include scoping legacy configuration endpoints under /api/v1/configuration/, adding regression tests to ensure proper model separation, and introducing new security features including session inactivity handling, password history enforcement, and safer permission auditing.
- Refactored legacy configuration endpoints to use
/api/v1/configuration/prefix to prevent URL clashing - Added comprehensive data centralization layer with REST API for metrics, logs, and health checks
- Implemented session security middleware, password history tracking, and permission auditing with graceful fallbacks
Reviewed Changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 24 comments.
Show a summary per file
| File | Description |
|---|---|
| api/callcentersite/callcentersite/urls.py | Updated configuration app URL prefix to /api/v1/configuration/ and added data centralization routes |
| api/callcentersite/callcentersite/apps/configuration/urls.py | Simplified URL patterns by removing redundant /configuracion/ prefix since it's now handled in main urls.py |
| api/callcentersite/tests/configuracion/test_api_rest_configuracion.py | Added regression test ensuring /api/v1/configuracion/ uses ConfiguracionSistema instead of legacy Configuracion model |
| api/callcentersite/callcentersite/apps/users/models.py | Added last_activity_at field to UserSession and new PasswordHistory model for password reuse prevention |
| api/callcentersite/callcentersite/apps/users/models_permisos_granular.py | Extended AuditoriaPermiso with new fields for richer audit trail (recurso_tipo, recurso_id, razon, detalles) |
| api/callcentersite/callcentersite/apps/users/service_helpers.py | Added defensive OperationalError handling for permission checks in test/migration environments |
| api/callcentersite/callcentersite/apps/users/services_permisos_granular.py | Enhanced permission verification with fallback behavior when audit tables are unavailable |
| api/callcentersite/callcentersite/apps/authentication/services.py | Implemented logout method, password history validation, session management helpers, and bcrypt-based password hashing |
| api/callcentersite/callcentersite/apps/authentication/validators.py | Added password complexity validation with character requirements and username exclusion |
| api/callcentersite/callcentersite/apps/authentication/jobs.py | Created scheduled job for closing inactive sessions with audit logging and user notifications |
| api/callcentersite/callcentersite/apps/notifications/models.py | Added QuerySet normalization layer for backward compatibility with legacy test code using user instead of recipient |
| api/callcentersite/data_centralization/views.py | Refactored from function-based to class-based APIView with DRF, added authentication and input validation |
| api/callcentersite/data_centralization/urls.py | Updated to use class-based view and renamed endpoint for consistency |
| api/callcentersite/data_centralization/services.py | Extracted business logic into strategy pattern with separate classes for metrics, logs, and health queries |
| api/callcentersite/data_centralization/tests/test_views.py | Created comprehensive test suite for data query API with authentication, validation, and integration tests |
| api/callcentersite/data_centralization/tests.py | Removed placeholder test file in favor of proper test directory structure |
| api/callcentersite/callcentersite/settings/base.py | Registered data_centralization app in INSTALLED_APPS |
| api/callcentersite/tests/settings/test_middleware_and_databases.py | Added configuration validation tests for session security middleware and database engines |
Comments suppressed due to low confidence (2)
api/callcentersite/callcentersite/apps/authentication/services.py:109
- Variable user is not used.
user = None
api/callcentersite/callcentersite/apps/authentication/services.py:427
- 'except' clause does nothing but pass and there is no explanatory comment.
except AttributeError:
| @staticmethod | ||
| def _coerce_datetime(value: Any) -> datetime | None: | ||
| if isinstance(value, datetime): | ||
| return value | ||
| if isinstance(value, str): | ||
| try: | ||
| normalized = value.replace("Z", "+00:00") | ||
| return datetime.fromisoformat(normalized) | ||
| except ValueError: | ||
| return None | ||
| return None |
There was a problem hiding this comment.
The _coerce_datetime method is duplicated in both MetricsQueryStrategy (lines 119-129) and LogsQueryStrategy (lines 189-199) with identical implementation. This code duplication violates DRY principles and increases maintenance burden.
Consider extracting this method to a shared utility function at the module level that both strategies can use.
|
|
||
|
|
||
| def close_inactive_sessions() -> dict: | ||
| """Cierra sesiones inactivas y registra auditoría y notificaciones.""" | ||
|
|
||
| threshold = timezone.now() - timedelta(minutes=INACTIVITY_TIMEOUT_MINUTES) | ||
| inactive_sessions = ( | ||
| UserSession.objects.filter(is_active=True, last_activity_at__lte=threshold) | ||
| .select_related('user') | ||
| ) | ||
|
|
||
| closed_sessions = 0 | ||
|
|
||
| for session in inactive_sessions: | ||
| session.close(reason='INACTIVITY_TIMEOUT') | ||
| closed_sessions += 1 | ||
|
|
||
| AuditLog.objects.create( | ||
| user=session.user, | ||
| event_type='SESSION_TIMEOUT', | ||
| result='SUCCESS', | ||
| ip_address=session.ip_address, | ||
| user_agent=session.user_agent, | ||
| details={ | ||
| 'reason': 'INACTIVITY_TIMEOUT', | ||
| 'session_key': session.session_key, | ||
| 'closed_at': session.logged_out_at.isoformat() if session.logged_out_at else None, | ||
| }, | ||
| ) | ||
|
|
||
| InternalMessage.objects.create( | ||
| recipient=session.user, | ||
| sender=None, | ||
| subject='Sesión cerrada por inactividad', | ||
| body='Tu sesión fue cerrada después de 30 minutos de inactividad.', | ||
| message_type='system', | ||
| priority='medium', | ||
| created_by_system=True, | ||
| metadata={'session_key': session.session_key}, | ||
| ) | ||
|
|
There was a problem hiding this comment.
[nitpick] The close_inactive_sessions job creates an audit log and internal message for every closed session within the same transaction loop. If there are many inactive sessions (e.g., after a deployment or during off-hours), this could create a large number of database writes in a single operation, potentially causing performance issues or database lock contention.
Consider:
- Batching the audit log and message creation
- Using
bulk_createfor audit logs and messages - Adding a limit to the number of sessions processed per run
- Processing the sessions in smaller chunks
| def close_inactive_sessions() -> dict: | |
| """Cierra sesiones inactivas y registra auditoría y notificaciones.""" | |
| threshold = timezone.now() - timedelta(minutes=INACTIVITY_TIMEOUT_MINUTES) | |
| inactive_sessions = ( | |
| UserSession.objects.filter(is_active=True, last_activity_at__lte=threshold) | |
| .select_related('user') | |
| ) | |
| closed_sessions = 0 | |
| for session in inactive_sessions: | |
| session.close(reason='INACTIVITY_TIMEOUT') | |
| closed_sessions += 1 | |
| AuditLog.objects.create( | |
| user=session.user, | |
| event_type='SESSION_TIMEOUT', | |
| result='SUCCESS', | |
| ip_address=session.ip_address, | |
| user_agent=session.user_agent, | |
| details={ | |
| 'reason': 'INACTIVITY_TIMEOUT', | |
| 'session_key': session.session_key, | |
| 'closed_at': session.logged_out_at.isoformat() if session.logged_out_at else None, | |
| }, | |
| ) | |
| InternalMessage.objects.create( | |
| recipient=session.user, | |
| sender=None, | |
| subject='Sesión cerrada por inactividad', | |
| body='Tu sesión fue cerrada después de 30 minutos de inactividad.', | |
| message_type='system', | |
| priority='medium', | |
| created_by_system=True, | |
| metadata={'session_key': session.session_key}, | |
| ) | |
| BATCH_SIZE = 100 # Limita la cantidad de sesiones procesadas por ejecución | |
| def close_inactive_sessions() -> dict: | |
| """Cierra sesiones inactivas y registra auditoría y notificaciones.""" | |
| threshold = timezone.now() - timedelta(minutes=INACTIVITY_TIMEOUT_MINUTES) | |
| inactive_sessions = ( | |
| UserSession.objects.filter(is_active=True, last_activity_at__lte=threshold) | |
| .select_related('user')[:BATCH_SIZE] | |
| ) | |
| closed_sessions = 0 | |
| audit_logs = [] | |
| internal_messages = [] | |
| for session in inactive_sessions: | |
| session.close(reason='INACTIVITY_TIMEOUT') | |
| closed_sessions += 1 | |
| audit_logs.append( | |
| AuditLog( | |
| user=session.user, | |
| event_type='SESSION_TIMEOUT', | |
| result='SUCCESS', | |
| ip_address=session.ip_address, | |
| user_agent=session.user_agent, | |
| details={ | |
| 'reason': 'INACTIVITY_TIMEOUT', | |
| 'session_key': session.session_key, | |
| 'closed_at': session.logged_out_at.isoformat() if session.logged_out_at else None, | |
| }, | |
| ) | |
| ) | |
| internal_messages.append( | |
| InternalMessage( | |
| recipient=session.user, | |
| sender=None, | |
| subject='Sesión cerrada por inactividad', | |
| body='Tu sesión fue cerrada después de 30 minutos de inactividad.', | |
| message_type='system', | |
| priority='medium', | |
| created_by_system=True, | |
| metadata={'session_key': session.session_key}, | |
| ) | |
| ) | |
| if audit_logs: | |
| AuditLog.objects.bulk_create(audit_logs) | |
| if internal_messages: | |
| InternalMessage.objects.bulk_create(internal_messages) |
| def _puede_auditar() -> bool: | ||
| try: | ||
| return AuditoriaPermiso._meta.db_table in connection.introspection.table_names() | ||
| except Exception: | ||
| return False | ||
|
|
||
| if not tiene_permiso: | ||
| # Auditar intento denegado | ||
| if _puede_auditar(): | ||
| try: | ||
| AuditoriaPermiso.objects.create( | ||
| usuario_id=usuario_id, | ||
| capacidad_codigo=capacidad_codigo, | ||
| recurso_tipo=recurso_tipo, | ||
| recurso_id=recurso_id, | ||
| accion=accion, | ||
| resultado='denegado', | ||
| razon=f'Usuario no tiene permiso {capacidad_codigo}', | ||
| ) | ||
| except OperationalError: | ||
| pass | ||
|
|
||
| # Lanzar excepción | ||
| mensaje = mensaje_error or f'No tiene permiso para {accion} {recurso_tipo}s' | ||
| raise PermissionDenied(mensaje) | ||
|
|
||
| # Auditar acceso permitido | ||
| AuditoriaPermiso.objects.create( | ||
| usuario_id=usuario_id, | ||
| capacidad_codigo=capacidad_codigo, | ||
| recurso_tipo=recurso_tipo, | ||
| recurso_id=recurso_id, | ||
| accion=accion, | ||
| resultado='permitido', | ||
| ) | ||
| if _puede_auditar(): | ||
| try: | ||
| AuditoriaPermiso.objects.create( | ||
| usuario_id=usuario_id, | ||
| capacidad_codigo=capacidad_codigo, | ||
| recurso_tipo=recurso_tipo, | ||
| recurso_id=recurso_id, | ||
| accion=accion, | ||
| resultado='permitido', | ||
| ) | ||
| except OperationalError: | ||
| pass |
There was a problem hiding this comment.
The _puede_auditar() helper is called twice for every permission check (once at line 81 and again at line 100), and each call performs a database introspection query with connection.introspection.table_names(). This is inefficient and could become a performance bottleneck with high request volumes.
Consider caching the result of this check at the module level or checking table existence once at startup rather than on every permission verification.
| try: | ||
| tiene_permiso = UserManagementService.usuario_tiene_permiso( | ||
| usuario_id=usuario_id, | ||
| capacidad_codigo=capacidad_codigo, | ||
| recurso_tipo=recurso_tipo, | ||
| recurso_id=recurso_id, | ||
| accion=accion, | ||
| resultado='denegado', | ||
| razon=f'Usuario no tiene permiso {capacidad_codigo}', | ||
| auditar=False, | ||
| ) | ||
| except OperationalError: | ||
| tiene_permiso = True |
There was a problem hiding this comment.
The fallback behavior of allowing all permissions when OperationalError occurs (line 70-71) could be a security risk. If the permissions system fails due to a database issue, this code grants unrestricted access rather than failing securely.
Consider either:
- Raising an exception to force the system to fail closed
- Providing a configurable fallback policy
- At minimum, logging this security-critical event for monitoring
| DEFAULT_LOG_PATH = Path("/var/log/iact/app.json.log") | ||
| DEFAULT_HEALTH_SCRIPT = Path("/home/user/IACT---project/scripts/health_check.sh") |
There was a problem hiding this comment.
The hardcoded path /home/user/IACT---project/scripts/health_check.sh is environment-specific and will fail in production, Docker containers, or different development setups. This makes the health check feature non-portable.
Consider:
- Using an environment variable or Django setting to configure this path
- Making the path relative to the project root
- Providing a configuration mechanism to override default paths
| from django.contrib.auth import get_user_model | ||
| from django.contrib.auth.hashers import check_password | ||
| from django.core.exceptions import PermissionDenied, ObjectDoesNotExist | ||
| from django.core.exceptions import PermissionDenied, ObjectDoesNotExist, ValidationError |
There was a problem hiding this comment.
Import of 'ObjectDoesNotExist' is not used.
| from django.core.exceptions import PermissionDenied, ObjectDoesNotExist, ValidationError | |
| from django.core.exceptions import PermissionDenied, ValidationError |
| resultado='denegado', | ||
| razon=f'Usuario no tiene permiso {capacidad_codigo}', | ||
| ) | ||
| except OperationalError: |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
| except OperationalError: | |
| except OperationalError: | |
| # Audit logging is best-effort; ignore DB errors to avoid blocking permission enforcement. |
| accion=accion, | ||
| resultado='permitido', | ||
| ) | ||
| except OperationalError: |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
| resultado='permitido', | ||
| detalles=detalles or '', | ||
| ) | ||
| except OperationalError: |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
| except OperationalError: | |
| except OperationalError: | |
| # Intentionally ignore DB errors during audit logging to avoid interrupting main user flow. | |
| # This can occur if migrations are pending or the audit table is missing. |
| ip_address=ip_address, | ||
| user_agent=user_agent, | ||
| ) | ||
| except OperationalError: |
There was a problem hiding this comment.
'except' clause does nothing but pass and there is no explanatory comment.
Summary
/api/v1/configuration/and normalize their URL patterns to avoid clashing with the ConfiguracionSistema API/api/v1/configuracion/serves data fromConfiguracionSistemainstead of legacyConfiguracionTesting
pytest tests/configuracion/test_api_rest_configuracion.py -qpytest tests/integration/test_configuracion_backup.py -q(fails:UserManager.create_usernow requiresusernamein fixtures)Codex Task