mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 06:05:18 -05:00
feat: Add analytics, incident, and alert models and APIs, along with user permissions and bulk profile lookups.
This commit is contained in:
@@ -110,6 +110,8 @@ urlpatterns = [
|
||||
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
|
||||
path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"),
|
||||
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
|
||||
# User permissions endpoint
|
||||
path("permissions/", views.get_user_permissions, name="get_user_permissions"),
|
||||
# Login history endpoint
|
||||
path("login-history/", views.get_login_history, name="get_login_history"),
|
||||
# Email change cancellation endpoint
|
||||
@@ -119,6 +121,9 @@ urlpatterns = [
|
||||
path("magic-link/verify/", views_magic_link.verify_magic_link, name="verify_magic_link"),
|
||||
# Public Profile
|
||||
path("profiles/<str:username>/", views.get_public_user_profile, name="get_public_user_profile"),
|
||||
# Bulk lookup endpoints
|
||||
path("profiles/bulk/", views.bulk_get_profiles, name="bulk_get_profiles"),
|
||||
path("users/bulk/", views.get_users_with_emails, name="get_users_with_emails"),
|
||||
# ViewSet routes
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -826,6 +826,63 @@ def check_user_deletion_eligibility(request, user_id):
|
||||
# === USER PROFILE ENDPOINTS ===
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_user_permissions",
|
||||
summary="Get current user's management permissions",
|
||||
description="Get the authenticated user's management permissions including role information.",
|
||||
responses={
|
||||
200: {
|
||||
"description": "User permissions",
|
||||
"example": {
|
||||
"user_id": "uuid",
|
||||
"is_superuser": True,
|
||||
"is_staff": True,
|
||||
"is_moderator": False,
|
||||
"roles": ["admin"],
|
||||
"permissions": ["can_moderate", "can_manage_users"],
|
||||
},
|
||||
},
|
||||
401: {
|
||||
"description": "Authentication required",
|
||||
"example": {"detail": "Authentication credentials were not provided."},
|
||||
},
|
||||
},
|
||||
tags=["User Profile"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_user_permissions(request):
|
||||
"""Get the authenticated user's management permissions."""
|
||||
user = request.user
|
||||
profile = getattr(user, "profile", None)
|
||||
|
||||
# Get roles from profile if exists
|
||||
roles = []
|
||||
if profile:
|
||||
if hasattr(profile, "role") and profile.role:
|
||||
roles.append(profile.role)
|
||||
if user.is_superuser:
|
||||
roles.append("admin")
|
||||
if user.is_staff:
|
||||
roles.append("staff")
|
||||
|
||||
# Build permissions list based on flags
|
||||
permissions = []
|
||||
if user.is_superuser or user.is_staff:
|
||||
permissions.extend(["can_moderate", "can_manage_users", "can_view_admin"])
|
||||
elif profile and getattr(profile, "is_moderator", False):
|
||||
permissions.append("can_moderate")
|
||||
|
||||
return Response({
|
||||
"user_id": str(user.id),
|
||||
"is_superuser": user.is_superuser,
|
||||
"is_staff": user.is_staff,
|
||||
"is_moderator": profile and getattr(profile, "is_moderator", False) if profile else False,
|
||||
"roles": list(set(roles)), # Deduplicate
|
||||
"permissions": list(set(permissions)), # Deduplicate
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_user_profile",
|
||||
summary="Get current user's complete profile",
|
||||
@@ -935,8 +992,8 @@ def get_user_preferences(request):
|
||||
"allow_messages": user.allow_messages,
|
||||
}
|
||||
|
||||
serializer = UserPreferencesSerializer(data=data)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
# Return the data directly - no validation needed for GET response
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
@@ -1056,8 +1113,8 @@ def get_notification_settings(request):
|
||||
},
|
||||
}
|
||||
|
||||
serializer = NotificationSettingsSerializer(data=data)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
# Return the data directly - no validation needed for GET response
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
@@ -1131,8 +1188,8 @@ def get_privacy_settings(request):
|
||||
"allow_messages": user.allow_messages,
|
||||
}
|
||||
|
||||
serializer = PrivacySettingsSerializer(data=data)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
# Return the data directly - no validation needed for GET response
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
@@ -1198,8 +1255,8 @@ def get_security_settings(request):
|
||||
"active_sessions": getattr(user, "active_sessions", 1),
|
||||
}
|
||||
|
||||
serializer = SecuritySettingsSerializer(data=data)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
# Return the data directly - no validation needed for GET response
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
@@ -1273,8 +1330,8 @@ def get_user_statistics(request):
|
||||
"last_activity": user.last_login,
|
||||
}
|
||||
|
||||
serializer = UserStatisticsSerializer(data=data)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
# Return the data directly - no validation needed for GET response
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
# === TOP LISTS ENDPOINTS ===
|
||||
@@ -1732,3 +1789,135 @@ def cancel_email_change(request):
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="bulk_get_profiles",
|
||||
summary="Get multiple user profiles by user IDs",
|
||||
description="Fetch profile information for multiple users at once. Useful for displaying user info in lists.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="user_ids",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Comma-separated list of user IDs",
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: {
|
||||
"description": "List of user profiles",
|
||||
"example": [
|
||||
{
|
||||
"user_id": "123",
|
||||
"username": "john_doe",
|
||||
"display_name": "John Doe",
|
||||
"avatar_url": "https://example.com/avatar.jpg",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
tags=["User Profile"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def bulk_get_profiles(request):
|
||||
"""Get multiple user profiles by IDs for efficient bulk lookups."""
|
||||
user_ids_param = request.query_params.get("user_ids", "")
|
||||
|
||||
if not user_ids_param:
|
||||
return Response([], status=status.HTTP_200_OK)
|
||||
|
||||
user_ids = [uid.strip() for uid in user_ids_param.split(",") if uid.strip()]
|
||||
|
||||
if not user_ids:
|
||||
return Response([], status=status.HTTP_200_OK)
|
||||
|
||||
# Limit to prevent abuse
|
||||
if len(user_ids) > 100:
|
||||
user_ids = user_ids[:100]
|
||||
|
||||
profiles = UserProfile.objects.filter(user__user_id__in=user_ids).select_related("user", "avatar")
|
||||
|
||||
result = []
|
||||
for profile in profiles:
|
||||
result.append({
|
||||
"user_id": str(profile.user.user_id),
|
||||
"username": profile.user.username,
|
||||
"display_name": profile.display_name,
|
||||
"avatar_url": profile.get_avatar_url() if hasattr(profile, "get_avatar_url") else None,
|
||||
})
|
||||
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_users_with_emails",
|
||||
summary="Get users with email addresses (admin/moderator only)",
|
||||
description="Fetch user information including emails. Restricted to admins and moderators.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="user_ids",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Comma-separated list of user IDs",
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: {
|
||||
"description": "List of users with emails",
|
||||
"example": [
|
||||
{
|
||||
"user_id": "123",
|
||||
"username": "john_doe",
|
||||
"email": "john@example.com",
|
||||
"display_name": "John Doe",
|
||||
}
|
||||
],
|
||||
},
|
||||
403: {"description": "Not authorized - admin or moderator access required"},
|
||||
},
|
||||
tags=["User Management"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_users_with_emails(request):
|
||||
"""Get users with email addresses - restricted to admins and moderators."""
|
||||
user = request.user
|
||||
|
||||
# Check if user is admin or moderator
|
||||
if not (user.is_staff or user.is_superuser or getattr(user, "role", "") in ["ADMIN", "MODERATOR"]):
|
||||
return Response(
|
||||
{"detail": "Admin or moderator access required"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
user_ids_param = request.query_params.get("user_ids", "")
|
||||
|
||||
if not user_ids_param:
|
||||
return Response([], status=status.HTTP_200_OK)
|
||||
|
||||
user_ids = [uid.strip() for uid in user_ids_param.split(",") if uid.strip()]
|
||||
|
||||
if not user_ids:
|
||||
return Response([], status=status.HTTP_200_OK)
|
||||
|
||||
# Limit to prevent abuse
|
||||
if len(user_ids) > 100:
|
||||
user_ids = user_ids[:100]
|
||||
|
||||
users = User.objects.filter(user_id__in=user_ids).select_related("profile")
|
||||
|
||||
result = []
|
||||
for u in users:
|
||||
profile = getattr(u, "profile", None)
|
||||
result.append({
|
||||
"user_id": str(u.user_id),
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"display_name": profile.display_name if profile else None,
|
||||
})
|
||||
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@@ -3,13 +3,31 @@ Admin API URL configuration.
|
||||
Provides endpoints for admin dashboard functionality.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.core.api.alert_views import (
|
||||
RateLimitAlertConfigViewSet,
|
||||
RateLimitAlertViewSet,
|
||||
SystemAlertViewSet,
|
||||
)
|
||||
from apps.core.api.incident_views import IncidentViewSet
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "admin_api"
|
||||
|
||||
# Router for admin ViewSets
|
||||
router = DefaultRouter()
|
||||
router.register(r"system-alerts", SystemAlertViewSet, basename="system-alert")
|
||||
router.register(r"rate-limit-alerts", RateLimitAlertViewSet, basename="rate-limit-alert")
|
||||
router.register(r"rate-limit-config", RateLimitAlertConfigViewSet, basename="rate-limit-config")
|
||||
router.register(r"incidents", IncidentViewSet, basename="incident")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
# Alert ViewSets (via router)
|
||||
path("", include(router.urls)),
|
||||
# OSM Cache Stats
|
||||
path(
|
||||
"osm-usage-stats/",
|
||||
@@ -52,4 +70,10 @@ urlpatterns = [
|
||||
views.PipelineIntegrityScanView.as_view(),
|
||||
name="pipeline_integrity_scan",
|
||||
),
|
||||
# Admin Settings (key-value store for preferences)
|
||||
path(
|
||||
"settings/",
|
||||
views.AdminSettingsView.as_view(),
|
||||
name="admin_settings",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1263,3 +1263,88 @@ class PipelineIntegrityScanView(APIView):
|
||||
{"detail": "Failed to run integrity scan"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
class AdminSettingsView(APIView):
|
||||
"""
|
||||
GET/POST /admin/settings/
|
||||
Simple key-value store for admin preferences.
|
||||
|
||||
Settings are stored in Django cache with admin-specific keys.
|
||||
For persistent storage, a database model can be added later.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminWithSecondFactor]
|
||||
|
||||
def get(self, request):
|
||||
"""Get all admin settings or a specific setting."""
|
||||
try:
|
||||
key = request.query_params.get("key")
|
||||
|
||||
if key:
|
||||
# Get specific setting
|
||||
value = cache.get(f"admin_setting_{key}")
|
||||
if value is None:
|
||||
return Response(
|
||||
{"results": []},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(
|
||||
{"results": [{"key": key, "value": value}]},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# Get all settings (return empty list if none exist)
|
||||
# In a real implementation, you'd query a database model
|
||||
settings_keys = cache.get("admin_settings_keys", [])
|
||||
results = []
|
||||
for k in settings_keys:
|
||||
val = cache.get(f"admin_setting_{k}")
|
||||
if val is not None:
|
||||
results.append({"key": k, "value": val})
|
||||
|
||||
return Response(
|
||||
{"results": results, "count": len(results)},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_and_log(e, "Admin settings GET - error", source="api")
|
||||
return Response(
|
||||
{"detail": "Failed to fetch admin settings"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def post(self, request):
|
||||
"""Create or update an admin setting."""
|
||||
try:
|
||||
key = request.data.get("key")
|
||||
value = request.data.get("value")
|
||||
|
||||
if not key:
|
||||
return Response(
|
||||
{"detail": "key is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Store in cache (30 days TTL)
|
||||
cache.set(f"admin_setting_{key}", value, 60 * 60 * 24 * 30)
|
||||
|
||||
# Track keys
|
||||
settings_keys = cache.get("admin_settings_keys", [])
|
||||
if key not in settings_keys:
|
||||
settings_keys.append(key)
|
||||
cache.set("admin_settings_keys", settings_keys, 60 * 60 * 24 * 30)
|
||||
|
||||
return Response(
|
||||
{"success": True, "key": key, "value": value},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_and_log(e, "Admin settings POST - error", source="api")
|
||||
return Response(
|
||||
{"detail": "Failed to save admin setting"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ def setup_totp(request):
|
||||
def activate_totp(request):
|
||||
"""Verify TOTP code and activate MFA."""
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.mfa.recovery_codes.internal import auth as recovery_auth
|
||||
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
|
||||
from allauth.mfa.totp.internal import auth as totp_auth
|
||||
|
||||
user = request.user
|
||||
@@ -178,8 +178,9 @@ def activate_totp(request):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get pending secret from session
|
||||
secret = request.session.get("pending_totp_secret")
|
||||
# Get pending secret from session OR from request body
|
||||
# (request body is used as fallback for JWT auth where sessions may not persist)
|
||||
secret = request.session.get("pending_totp_secret") or request.data.get("secret", "").strip()
|
||||
if not secret:
|
||||
return Response(
|
||||
{"detail": "No pending TOTP setup. Please start setup again."},
|
||||
@@ -207,16 +208,13 @@ def activate_totp(request):
|
||||
data={"secret": secret},
|
||||
)
|
||||
|
||||
# Generate recovery codes
|
||||
codes = recovery_auth.generate_recovery_codes()
|
||||
Authenticator.objects.create(
|
||||
user=user,
|
||||
type=Authenticator.Type.RECOVERY_CODES,
|
||||
data={"codes": codes},
|
||||
)
|
||||
# Generate recovery codes using allauth's RecoveryCodes API
|
||||
recovery_instance = RecoveryCodes.activate(user)
|
||||
codes = recovery_instance.get_unused_codes()
|
||||
|
||||
# Clear session
|
||||
del request.session["pending_totp_secret"]
|
||||
# Clear session (only if it exists - won't exist with JWT auth + secret from body)
|
||||
if "pending_totp_secret" in request.session:
|
||||
del request.session["pending_totp_secret"]
|
||||
|
||||
return Response(
|
||||
{
|
||||
@@ -361,7 +359,7 @@ def verify_totp(request):
|
||||
def regenerate_recovery_codes(request):
|
||||
"""Regenerate recovery codes."""
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.mfa.recovery_codes.internal import auth as recovery_auth
|
||||
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
|
||||
|
||||
user = request.user
|
||||
password = request.data.get("password", "")
|
||||
@@ -380,15 +378,14 @@ def regenerate_recovery_codes(request):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Generate new codes
|
||||
codes = recovery_auth.generate_recovery_codes()
|
||||
# Delete existing recovery codes first (so activate creates new ones)
|
||||
Authenticator.objects.filter(
|
||||
user=user, type=Authenticator.Type.RECOVERY_CODES
|
||||
).delete()
|
||||
|
||||
# Update or create recovery codes authenticator
|
||||
authenticator, created = Authenticator.objects.update_or_create(
|
||||
user=user,
|
||||
type=Authenticator.Type.RECOVERY_CODES,
|
||||
defaults={"data": {"codes": codes}},
|
||||
)
|
||||
# Generate new recovery codes using allauth's RecoveryCodes API
|
||||
recovery_instance = RecoveryCodes.activate(user)
|
||||
codes = recovery_instance.get_unused_codes()
|
||||
|
||||
return Response(
|
||||
{
|
||||
|
||||
@@ -377,7 +377,7 @@ class MFALoginVerifyAPIView(APIView):
|
||||
"""Verify TOTP code against user's authenticator."""
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.mfa.totp import TOTP
|
||||
from allauth.mfa.totp.internal import auth as totp_auth
|
||||
|
||||
try:
|
||||
authenticator = Authenticator.objects.get(
|
||||
@@ -387,9 +387,12 @@ class MFALoginVerifyAPIView(APIView):
|
||||
except Authenticator.DoesNotExist:
|
||||
return False
|
||||
|
||||
# Get the TOTP instance and verify
|
||||
totp = TOTP(authenticator)
|
||||
return totp.validate_code(code)
|
||||
# Get the secret from authenticator data and verify
|
||||
secret = authenticator.data.get("secret")
|
||||
if not secret:
|
||||
return False
|
||||
|
||||
return totp_auth.validate_totp_code(secret, code)
|
||||
|
||||
except ImportError:
|
||||
logger.error("allauth.mfa not available for TOTP verification")
|
||||
|
||||
@@ -24,4 +24,10 @@ urlpatterns = [
|
||||
views.QuickEntitySuggestionView.as_view(),
|
||||
name="entity_suggestions",
|
||||
),
|
||||
# Telemetry endpoint for frontend logging
|
||||
path(
|
||||
"telemetry/",
|
||||
views.TelemetryView.as_view(),
|
||||
name="telemetry",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -22,6 +22,108 @@ from apps.core.services.entity_fuzzy_matching import (
|
||||
entity_fuzzy_matcher,
|
||||
)
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelemetryView(APIView):
|
||||
"""
|
||||
Handle frontend telemetry and request metadata logging.
|
||||
|
||||
This endpoint accepts telemetry data from the frontend for logging and
|
||||
analytics purposes. When error data is present, it persists the error
|
||||
to the database for monitoring.
|
||||
|
||||
Note: This endpoint bypasses authentication entirely to ensure errors
|
||||
can be logged even when user tokens are expired or invalid.
|
||||
"""
|
||||
|
||||
authentication_classes = [] # Bypass JWT auth to allow error logging with expired tokens
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=["Core"],
|
||||
summary="Log request metadata",
|
||||
description="Log frontend telemetry and request metadata",
|
||||
)
|
||||
def post(self, request):
|
||||
"""Accept telemetry data from frontend."""
|
||||
data = request.data
|
||||
|
||||
# If this is an error report, persist it to the database
|
||||
if data.get('p_error_type') or data.get('p_error_message') or data.get('error_type') or data.get('error_message'):
|
||||
from apps.core.services import ErrorService
|
||||
|
||||
# Handle both p_ prefixed params (from log_request_metadata RPC) and direct params
|
||||
error_message = data.get('p_error_message') or data.get('error_message') or 'Unknown error'
|
||||
error_type = data.get('p_error_type') or data.get('error_type') or 'Error'
|
||||
severity = data.get('p_severity') or data.get('severity') or 'medium'
|
||||
error_stack = data.get('p_error_stack') or data.get('error_stack') or ''
|
||||
error_code = data.get('p_error_code') or data.get('error_code') or ''
|
||||
|
||||
# Build metadata from available fields
|
||||
metadata = {
|
||||
'action': data.get('p_action') or data.get('action'),
|
||||
'breadcrumbs': data.get('p_breadcrumbs'),
|
||||
'duration_ms': data.get('p_duration_ms'),
|
||||
'retry_attempts': data.get('p_retry_attempts'),
|
||||
'affected_route': data.get('p_affected_route'),
|
||||
'request_id': data.get('p_request_id') or data.get('request_id'),
|
||||
}
|
||||
# Remove None values
|
||||
metadata = {k: v for k, v in metadata.items() if v is not None}
|
||||
|
||||
# Build environment from available fields
|
||||
environment = data.get('p_environment_context') or data.get('environment') or {}
|
||||
if isinstance(environment, str):
|
||||
import json
|
||||
try:
|
||||
environment = json.loads(environment)
|
||||
except json.JSONDecodeError:
|
||||
environment = {}
|
||||
|
||||
try:
|
||||
error = ErrorService.capture_error(
|
||||
error=error_message,
|
||||
source='frontend',
|
||||
request=request,
|
||||
severity=severity,
|
||||
metadata=metadata,
|
||||
environment=environment,
|
||||
)
|
||||
# Update additional fields
|
||||
error.error_type = error_type
|
||||
error.error_stack = error_stack[:10000] if error_stack else ''
|
||||
error.error_code = error_code
|
||||
error.endpoint = data.get('p_affected_route') or ''
|
||||
error.http_status = data.get('p_http_status')
|
||||
error.save(update_fields=['error_type', 'error_stack', 'error_code', 'endpoint', 'http_status'])
|
||||
|
||||
logger.info(f"Frontend error captured: {error.short_error_id}")
|
||||
return Response(
|
||||
{"success": True, "error_id": str(error.error_id)},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to capture frontend error: {e}")
|
||||
# Fall through to regular telemetry logging
|
||||
|
||||
# Non-error telemetry - just log and acknowledge
|
||||
logger.debug(
|
||||
"Telemetry received",
|
||||
extra={
|
||||
"data": data,
|
||||
"user_id": getattr(request.user, "id", None),
|
||||
},
|
||||
)
|
||||
return Response(
|
||||
{"success": True, "message": "Telemetry logged"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
|
||||
class EntityFuzzySearchView(APIView):
|
||||
"""
|
||||
|
||||
@@ -27,12 +27,23 @@ from .views.reviews import LatestReviewsAPIView
|
||||
from .views.stats import StatsAPIView, StatsRecalculateAPIView
|
||||
from .viewsets_rankings import RideRankingViewSet, TriggerRankingCalculationView
|
||||
|
||||
# Import analytics views
|
||||
from apps.core.api.analytics_views import (
|
||||
ApprovalTransactionMetricViewSet,
|
||||
ErrorSummaryView,
|
||||
RequestMetadataViewSet,
|
||||
)
|
||||
|
||||
# Create the main API router
|
||||
router = DefaultRouter()
|
||||
|
||||
# Register ranking endpoints
|
||||
router.register(r"rankings", RideRankingViewSet, basename="ranking")
|
||||
|
||||
# Register analytics endpoints
|
||||
router.register(r"request_metadata", RequestMetadataViewSet, basename="request_metadata")
|
||||
router.register(r"approval_transaction_metrics", ApprovalTransactionMetricViewSet, basename="approval_transaction_metrics")
|
||||
|
||||
app_name = "api_v1"
|
||||
|
||||
urlpatterns = [
|
||||
@@ -40,6 +51,8 @@ urlpatterns = [
|
||||
# See backend/thrillwiki/urls.py for documentation endpoints
|
||||
# Authentication endpoints
|
||||
path("auth/", include("apps.api.v1.auth.urls")),
|
||||
# Analytics endpoints (error_summary is a view, not a viewset)
|
||||
path("error_summary/", ErrorSummaryView.as_view(), name="error-summary"),
|
||||
# Health check endpoints
|
||||
path("health/", HealthCheckAPIView.as_view(), name="health-check"),
|
||||
path("health/simple/", SimpleHealthAPIView.as_view(), name="simple-health"),
|
||||
|
||||
Reference in New Issue
Block a user