fix: harden multi-tenant data isolation across 8 modules
Pre-production security fixes to prevent cross-hospital data leaks: - Standards API: add get_queryset() filtering by department__hospital - Reports service: add user param with hospital filtering to all querysets - RCA views: replace is_superuser with tenant_hospital pattern, add access checks to all 11 mutation views - Notifications views: replace is_superuser patterns with _get_notification_hospital helper across all 5 settings functions - Appreciation API: add tenant_hospital fallback to AppreciationViewSet, AppreciationStatsViewSet, and LeaderboardView - AI Analytics: add tenant_hospital fallback in ExecutiveSummaryGenerator and ActionRecommendationEngine - SourceUserRestrictionMiddleware: remove None from ALLOWED_URL_NAMES - Complaint export: fix nullable patient/due_at/description crashes in CSV and Excel export, fix invalid get_category_display/get_source_display calls E2E test updates: - Update isolation gap tests to actively assert hospital filtering - Fix CSV export test to use API context for download handling - Switch clinical-staff tests to serial mode to prevent race conditions
This commit is contained in:
parent
6b51b0870d
commit
23d439f5a5
1120
apps/analytics/services/ai_analytics.py
Normal file
1120
apps/analytics/services/ai_analytics.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,7 @@ Analytics Console UI views
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.cache import cache
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Avg, Count, F, Q, Value
|
from django.db.models import Avg, Count, F, Q, Value
|
||||||
from django.db.models.functions import Concat
|
from django.db.models.functions import Concat
|
||||||
@ -19,6 +20,13 @@ from apps.physicians.models import PhysicianMonthlyRating
|
|||||||
|
|
||||||
from .models import KPI, KPIValue
|
from .models import KPI, KPIValue
|
||||||
from .services import UnifiedAnalyticsService, ExportService
|
from .services import UnifiedAnalyticsService, ExportService
|
||||||
|
from .services.ai_analytics import (
|
||||||
|
ExecutiveSummaryGenerator,
|
||||||
|
EarlyWarningSystem,
|
||||||
|
ComplaintVolumeForecaster,
|
||||||
|
SLABreachPredictor,
|
||||||
|
ActionRecommendationEngine,
|
||||||
|
)
|
||||||
from apps.core.decorators import block_source_user
|
from apps.core.decorators import block_source_user
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@ -64,10 +72,18 @@ def analytics_dashboard(request):
|
|||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
|
# Build cache key based on user and hospital
|
||||||
|
cache_key = f"analytics_dashboard_{user.id}_{request.GET.get('hospital', 'all')}"
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return render(request, "analytics/dashboard.html", cached)
|
||||||
|
|
||||||
# Get hospital filter
|
# Get hospital filter
|
||||||
hospital_filter = request.GET.get("hospital")
|
hospital_filter = request.GET.get("hospital")
|
||||||
if hospital_filter:
|
if hospital_filter:
|
||||||
hospital = Hospital.objects.filter(id=hospital_filter).first()
|
hospital = Hospital.objects.filter(id=hospital_filter).first()
|
||||||
|
elif user.is_px_admin() and hasattr(request, "tenant_hospital") and request.tenant_hospital:
|
||||||
|
hospital = request.tenant_hospital
|
||||||
elif user.hospital:
|
elif user.hospital:
|
||||||
hospital = user.hospital
|
hospital = user.hospital
|
||||||
else:
|
else:
|
||||||
@ -93,8 +109,17 @@ def analytics_dashboard(request):
|
|||||||
closed_complaints = complaints_queryset.filter(status="closed").count()
|
closed_complaints = complaints_queryset.filter(status="closed").count()
|
||||||
overdue_complaints = complaints_queryset.filter(is_overdue=True).count()
|
overdue_complaints = complaints_queryset.filter(is_overdue=True).count()
|
||||||
|
|
||||||
# Complaint sources
|
# Complaint source types (internal vs external)
|
||||||
complaint_sources = complaints_queryset.values("source").annotate(count=Count("id")).order_by("-count")[:6]
|
internal_complaints = complaints_queryset.filter(complaint_source_type="internal").count()
|
||||||
|
external_complaints = complaints_queryset.filter(complaint_source_type="external").count()
|
||||||
|
|
||||||
|
# Complaint sources (by PXSource name)
|
||||||
|
complaint_sources = (
|
||||||
|
complaints_queryset.filter(source__isnull=False)
|
||||||
|
.values("source__name_en")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")[:6]
|
||||||
|
)
|
||||||
|
|
||||||
# Complaint domains (Level 1)
|
# Complaint domains (Level 1)
|
||||||
top_domains = (
|
top_domains = (
|
||||||
@ -112,7 +137,15 @@ def analytics_dashboard(request):
|
|||||||
.order_by("-count")[:5]
|
.order_by("-count")[:5]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Complaint severity
|
# Complaint severity - build explicit counts for template
|
||||||
|
severity_counts = complaints_queryset.values("severity").annotate(count=Count("id"))
|
||||||
|
severity_map = {item["severity"]: item["count"] for item in severity_counts}
|
||||||
|
critical_complaints = severity_map.get("critical", 0)
|
||||||
|
high_complaints = severity_map.get("high", 0)
|
||||||
|
medium_complaints = severity_map.get("medium", 0)
|
||||||
|
low_complaints = severity_map.get("low", 0)
|
||||||
|
|
||||||
|
# Severity breakdown for JSON
|
||||||
severity_breakdown = complaints_queryset.values("severity").annotate(count=Count("id")).order_by("-count")
|
severity_breakdown = complaints_queryset.values("severity").annotate(count=Count("id")).order_by("-count")
|
||||||
|
|
||||||
# Status breakdown
|
# Status breakdown
|
||||||
@ -125,14 +158,26 @@ def analytics_dashboard(request):
|
|||||||
approved_actions = actions_queryset.filter(status="approved").count()
|
approved_actions = actions_queryset.filter(status="approved").count()
|
||||||
closed_actions = actions_queryset.filter(status="closed").count()
|
closed_actions = actions_queryset.filter(status="closed").count()
|
||||||
overdue_actions = actions_queryset.filter(is_overdue=True).count()
|
overdue_actions = actions_queryset.filter(is_overdue=True).count()
|
||||||
|
pending_actions = actions_queryset.filter(status="pending_approval").count()
|
||||||
|
|
||||||
# Action sources
|
# Action sources
|
||||||
action_sources = actions_queryset.values("source_type").annotate(count=Count("id")).order_by("-count")[:6]
|
action_sources = (
|
||||||
|
actions_queryset.filter(source_type__isnull=False)
|
||||||
|
.values("source_type")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")[:6]
|
||||||
|
)
|
||||||
|
|
||||||
# Action categories
|
# Action categories - build explicit counts
|
||||||
action_categories = (
|
action_categories = (
|
||||||
actions_queryset.exclude(category="").values("category").annotate(count=Count("id")).order_by("-count")[:5]
|
actions_queryset.exclude(category="").values("category").annotate(count=Count("id")).order_by("-count")[:5]
|
||||||
)
|
)
|
||||||
|
action_category_map = {item["category"]: item["count"] for item in action_categories}
|
||||||
|
training_actions = action_category_map.get("training", 0)
|
||||||
|
process_actions = action_category_map.get("process_improvement", 0)
|
||||||
|
policy_actions = action_category_map.get("policy", 0)
|
||||||
|
facility_actions = action_category_map.get("facility", 0)
|
||||||
|
other_actions = action_category_map.get("other", 0)
|
||||||
|
|
||||||
# ============ SURVEYS KPIs ============
|
# ============ SURVEYS KPIs ============
|
||||||
total_surveys = surveys_queryset.count()
|
total_surveys = surveys_queryset.count()
|
||||||
@ -176,15 +221,37 @@ def analytics_dashboard(request):
|
|||||||
.order_by("day")
|
.order_by("day")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Survey score trend
|
# Survey score trend - last 6 months for chart
|
||||||
survey_score_trend = (
|
six_months_ago = timezone.now() - timedelta(days=180)
|
||||||
surveys_queryset.filter(completed_at__gte=thirty_days_ago)
|
survey_score_trend_6m = (
|
||||||
.annotate(day=TruncDate("completed_at"))
|
surveys_queryset.filter(completed_at__gte=six_months_ago)
|
||||||
.values("day")
|
.annotate(month=TruncMonth("completed_at"))
|
||||||
|
.values("month")
|
||||||
.annotate(avg_score=Avg("total_score"))
|
.annotate(avg_score=Avg("total_score"))
|
||||||
.order_by("day")
|
.order_by("month")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Build survey trend array for last 6 months (pad with zeros if missing)
|
||||||
|
from calendar import month_name
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
survey_trend_values = []
|
||||||
|
survey_trend_labels = []
|
||||||
|
for i in range(5, -1, -1):
|
||||||
|
target_month = now.month - i
|
||||||
|
target_year = now.year
|
||||||
|
while target_month <= 0:
|
||||||
|
target_month += 12
|
||||||
|
target_year -= 1
|
||||||
|
survey_trend_labels.append(month_name[target_month][:3])
|
||||||
|
# Find matching data point
|
||||||
|
found = None
|
||||||
|
for item in survey_score_trend_6m:
|
||||||
|
if item["month"].month == target_month and item["month"].year == target_year:
|
||||||
|
found = round(item["avg_score"], 2) if item["avg_score"] else 0
|
||||||
|
break
|
||||||
|
survey_trend_values.append(found if found is not None else 0)
|
||||||
|
|
||||||
# ============ DEPARTMENT RANKINGS ============
|
# ============ DEPARTMENT RANKINGS ============
|
||||||
department_rankings = (
|
department_rankings = (
|
||||||
Department.objects.filter(status="active")
|
Department.objects.filter(status="active")
|
||||||
@ -200,6 +267,39 @@ def analytics_dashboard(request):
|
|||||||
.order_by("-avg_score")[:7]
|
.order_by("-avg_score")[:7]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Build department_stats list with resolution rate calculation
|
||||||
|
department_stats = []
|
||||||
|
for dept in department_rankings:
|
||||||
|
dept_complaints = (
|
||||||
|
complaints_queryset.filter(department=dept).count()
|
||||||
|
if hospital
|
||||||
|
else Complaint.objects.filter(department=dept).count()
|
||||||
|
)
|
||||||
|
dept_actions = (
|
||||||
|
actions_queryset.filter(department=dept).count()
|
||||||
|
if hospital
|
||||||
|
else PXAction.objects.filter(department=dept).count()
|
||||||
|
)
|
||||||
|
dept_resolved = (
|
||||||
|
complaints_queryset.filter(department=dept, status__in=["resolved", "closed"]).count()
|
||||||
|
if hospital
|
||||||
|
else Complaint.objects.filter(department=dept, status__in=["resolved", "closed"]).count()
|
||||||
|
)
|
||||||
|
resolution_rate = round((dept_resolved / dept_complaints * 100), 1) if dept_complaints > 0 else 0
|
||||||
|
|
||||||
|
department_stats.append(
|
||||||
|
{
|
||||||
|
"name_en": dept.name_en if hasattr(dept, "name_en") else str(dept),
|
||||||
|
"name_ar": dept.name_ar
|
||||||
|
if hasattr(dept, "name_ar")
|
||||||
|
else (dept.name_en if hasattr(dept, "name_en") else str(dept)),
|
||||||
|
"complaints": dept_complaints,
|
||||||
|
"actions": dept_actions,
|
||||||
|
"survey_avg": round(dept.avg_score, 2) if dept.avg_score else 0,
|
||||||
|
"resolution_rate": resolution_rate,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# ============ TIME-BASED CALCULATIONS ============
|
# ============ TIME-BASED CALCULATIONS ============
|
||||||
# Average resolution time (complaints)
|
# Average resolution time (complaints)
|
||||||
resolved_with_time = complaints_queryset.filter(
|
resolved_with_time = complaints_queryset.filter(
|
||||||
@ -256,6 +356,12 @@ def analytics_dashboard(request):
|
|||||||
"resolved_complaints": resolved_complaints,
|
"resolved_complaints": resolved_complaints,
|
||||||
"closed_complaints": closed_complaints,
|
"closed_complaints": closed_complaints,
|
||||||
"overdue_complaints": overdue_complaints,
|
"overdue_complaints": overdue_complaints,
|
||||||
|
"internal_complaints": internal_complaints,
|
||||||
|
"external_complaints": external_complaints,
|
||||||
|
"critical_complaints": critical_complaints,
|
||||||
|
"high_complaints": high_complaints,
|
||||||
|
"medium_complaints": medium_complaints,
|
||||||
|
"low_complaints": low_complaints,
|
||||||
"avg_resolution_hours": round(avg_resolution_hours, 1),
|
"avg_resolution_hours": round(avg_resolution_hours, 1),
|
||||||
"sla_compliance": round(sla_compliance, 1),
|
"sla_compliance": round(sla_compliance, 1),
|
||||||
"total_actions": total_actions,
|
"total_actions": total_actions,
|
||||||
@ -263,7 +369,13 @@ def analytics_dashboard(request):
|
|||||||
"in_progress_actions": in_progress_actions,
|
"in_progress_actions": in_progress_actions,
|
||||||
"approved_actions": approved_actions,
|
"approved_actions": approved_actions,
|
||||||
"closed_actions": closed_actions,
|
"closed_actions": closed_actions,
|
||||||
|
"pending_actions": pending_actions,
|
||||||
"overdue_actions": overdue_actions,
|
"overdue_actions": overdue_actions,
|
||||||
|
"training_actions": training_actions,
|
||||||
|
"process_actions": process_actions,
|
||||||
|
"policy_actions": policy_actions,
|
||||||
|
"facility_actions": facility_actions,
|
||||||
|
"other_actions": other_actions,
|
||||||
"avg_action_days": round(avg_action_days, 1),
|
"avg_action_days": round(avg_action_days, 1),
|
||||||
"total_surveys": total_surveys,
|
"total_surveys": total_surveys,
|
||||||
"avg_survey_score": round(avg_survey_score, 2),
|
"avg_survey_score": round(avg_survey_score, 2),
|
||||||
@ -274,8 +386,41 @@ def analytics_dashboard(request):
|
|||||||
"compliments": compliments,
|
"compliments": compliments,
|
||||||
"suggestions": suggestions,
|
"suggestions": suggestions,
|
||||||
"avg_rating": round(avg_rating, 2),
|
"avg_rating": round(avg_rating, 2),
|
||||||
|
"survey_trend_1": survey_trend_values[0] if len(survey_trend_values) > 0 else 0,
|
||||||
|
"survey_trend_2": survey_trend_values[1] if len(survey_trend_values) > 1 else 0,
|
||||||
|
"survey_trend_3": survey_trend_values[2] if len(survey_trend_values) > 2 else 0,
|
||||||
|
"survey_trend_4": survey_trend_values[3] if len(survey_trend_values) > 3 else 0,
|
||||||
|
"survey_trend_5": survey_trend_values[4] if len(survey_trend_values) > 4 else 0,
|
||||||
|
"survey_trend_6": survey_trend_values[5] if len(survey_trend_values) > 5 else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============ AI-POWERED ANALYTICS ============
|
||||||
|
hospital_id = str(hospital.id) if hospital else None
|
||||||
|
|
||||||
|
# Trigger async Celery tasks to refresh cache in background
|
||||||
|
from .tasks import (
|
||||||
|
generate_executive_summary_task,
|
||||||
|
generate_action_recommendations_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
generate_executive_summary_task.delay(user_id=str(user.id), hospital_id=hospital_id, period="30d")
|
||||||
|
generate_action_recommendations_task.delay(user_id=str(user.id), hospital_id=hospital_id)
|
||||||
|
|
||||||
|
# 1. Executive Summary — read from cache (populated by Celery or fallback)
|
||||||
|
exec_summary = ExecutiveSummaryGenerator.generate(user, hospital_id=hospital_id, period="30d")
|
||||||
|
|
||||||
|
# 2. Early Warning System
|
||||||
|
early_warnings = EarlyWarningSystem.detect(user, hospital_id=hospital_id, limit=5)
|
||||||
|
|
||||||
|
# 3. Complaint Volume Forecast
|
||||||
|
complaint_forecast = ComplaintVolumeForecaster.forecast(user, hospital_id=hospital_id, forecast_days=30)
|
||||||
|
|
||||||
|
# 4. SLA Breach Predictions
|
||||||
|
sla_breach_predictions = SLABreachPredictor.predict(user, hospital_id=hospital_id, limit=10)
|
||||||
|
|
||||||
|
# 5. Action Recommendations — read from cache
|
||||||
|
action_recommendations = ActionRecommendationEngine.generate_recommendations(user, hospital_id=hospital_id, limit=5)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"kpis": kpis,
|
"kpis": kpis,
|
||||||
"selected_hospital": hospital,
|
"selected_hospital": hospital,
|
||||||
@ -288,15 +433,77 @@ def analytics_dashboard(request):
|
|||||||
"action_sources": serialize_queryset_values(action_sources),
|
"action_sources": serialize_queryset_values(action_sources),
|
||||||
"action_categories": serialize_queryset_values(action_categories),
|
"action_categories": serialize_queryset_values(action_categories),
|
||||||
"survey_types": serialize_queryset_values(survey_types),
|
"survey_types": serialize_queryset_values(survey_types),
|
||||||
"survey_score_trend": serialize_queryset_values(survey_score_trend),
|
"survey_score_trend": serialize_queryset_values(survey_score_trend_6m),
|
||||||
"sentiment_breakdown": serialize_queryset_values(sentiment_breakdown),
|
"sentiment_breakdown": serialize_queryset_values(sentiment_breakdown),
|
||||||
"feedback_categories": serialize_queryset_values(feedback_categories),
|
"feedback_categories": serialize_queryset_values(feedback_categories),
|
||||||
"department_rankings": department_rankings,
|
"department_rankings": department_rankings,
|
||||||
|
"department_stats": department_stats,
|
||||||
|
"survey_trend_labels": json.dumps(survey_trend_labels),
|
||||||
|
# AI-powered features
|
||||||
|
"exec_summary": exec_summary,
|
||||||
|
"early_warnings": early_warnings,
|
||||||
|
"complaint_forecast": complaint_forecast,
|
||||||
|
"sla_breach_predictions": sla_breach_predictions,
|
||||||
|
"action_recommendations": action_recommendations,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Clear old cache (the new data isn't in the old cache entries)
|
||||||
|
cache.delete(cache_key)
|
||||||
|
|
||||||
return render(request, "analytics/dashboard.html", context)
|
return render(request, "analytics/dashboard.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@block_source_user
|
||||||
|
@login_required
|
||||||
|
def refresh_ai_analytics(request):
|
||||||
|
"""
|
||||||
|
API endpoint: Trigger async AI analytics refresh and return status.
|
||||||
|
POST to trigger, GET to check if cache is fresh.
|
||||||
|
"""
|
||||||
|
if request.method == "POST":
|
||||||
|
from .tasks import (
|
||||||
|
generate_executive_summary_task,
|
||||||
|
generate_action_recommendations_task,
|
||||||
|
precompute_dashboard_cache_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
hospital_id = request.POST.get("hospital") or request.GET.get("hospital")
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Trigger async tasks
|
||||||
|
generate_executive_summary_task.delay(
|
||||||
|
user_id=str(user.id), hospital_id=hospital_id, period="30d", force_refresh=True
|
||||||
|
)
|
||||||
|
generate_action_recommendations_task.delay(user_id=str(user.id), hospital_id=hospital_id)
|
||||||
|
|
||||||
|
# Also clear caches so next page load triggers fresh computation
|
||||||
|
cache.delete(f"exec_summary_{user.id}_{hospital_id}_30d")
|
||||||
|
cache.delete(f"action_recommendations_{user.id}_{hospital_id}_5")
|
||||||
|
|
||||||
|
return JsonResponse(
|
||||||
|
{"status": "triggered", "message": "AI analytics refresh queued. Results will be available in ~30 seconds."}
|
||||||
|
)
|
||||||
|
|
||||||
|
# GET — check cache freshness
|
||||||
|
hospital_id = request.GET.get("hospital") or (
|
||||||
|
str(request.tenant_hospital.id) if hasattr(request, "tenant_hospital") and request.tenant_hospital else None
|
||||||
|
)
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
summary_cached = cache.get(f"exec_summary_{user.id}_{hospital_id}_30d")
|
||||||
|
recommendations_cached = cache.get(f"action_recommendations_{user.id}_{hospital_id}_5")
|
||||||
|
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"cached": {
|
||||||
|
"executive_summary": summary_cached is not None,
|
||||||
|
"action_recommendations": recommendations_cached is not None,
|
||||||
|
},
|
||||||
|
"risk_level": summary_cached.get("risk_level", "unknown") if summary_cached else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@block_source_user
|
@block_source_user
|
||||||
@login_required
|
@login_required
|
||||||
def kpi_list(request):
|
def kpi_list(request):
|
||||||
@ -384,10 +591,46 @@ def command_center(request):
|
|||||||
custom_end=custom_end,
|
custom_end=custom_end,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Initial AI data for server-side render
|
||||||
|
from .services.ai_analytics import (
|
||||||
|
ExecutiveSummaryGenerator,
|
||||||
|
EarlyWarningSystem,
|
||||||
|
ComplaintVolumeForecaster,
|
||||||
|
SLABreachPredictor,
|
||||||
|
ActionRecommendationEngine,
|
||||||
|
)
|
||||||
|
|
||||||
|
hospital_id = filters["hospital"] if filters["hospital"] else None
|
||||||
|
department_id = filters["department"] if filters["department"] else None
|
||||||
|
|
||||||
|
if not hospital_id and user.is_px_admin():
|
||||||
|
tenant = getattr(request, "tenant_hospital", None)
|
||||||
|
if tenant:
|
||||||
|
hospital_id = str(tenant.id)
|
||||||
|
|
||||||
|
# Trigger async refresh
|
||||||
|
from .tasks import generate_executive_summary_task, generate_action_recommendations_task
|
||||||
|
|
||||||
|
generate_executive_summary_task.delay(
|
||||||
|
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id, period=filters["date_range"]
|
||||||
|
)
|
||||||
|
generate_action_recommendations_task.delay(
|
||||||
|
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id
|
||||||
|
)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"filters": filters,
|
"filters": filters,
|
||||||
"departments": departments,
|
"departments": departments,
|
||||||
"kpis": kpis,
|
"kpis": kpis,
|
||||||
|
"exec_summary": ExecutiveSummaryGenerator.generate(
|
||||||
|
user, hospital_id=hospital_id, department_id=department_id, period=filters["date_range"]
|
||||||
|
),
|
||||||
|
"early_warnings": EarlyWarningSystem.detect(user, hospital_id=hospital_id, limit=5),
|
||||||
|
"complaint_forecast": ComplaintVolumeForecaster.forecast(user, hospital_id=hospital_id, forecast_days=30),
|
||||||
|
"sla_breach_predictions": SLABreachPredictor.predict(user, hospital_id=hospital_id, limit=10),
|
||||||
|
"action_recommendations": ActionRecommendationEngine.generate_recommendations(
|
||||||
|
user, hospital_id=hospital_id, department_id=department_id, limit=5
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, "analytics/command_center.html", context)
|
return render(request, "analytics/command_center.html", context)
|
||||||
@ -431,6 +674,11 @@ def command_center_api(request):
|
|||||||
# Handle department_id (UUID string)
|
# Handle department_id (UUID string)
|
||||||
department_id = department_id if department_id else None
|
department_id = department_id if department_id else None
|
||||||
|
|
||||||
|
if not hospital_id and user.is_px_admin():
|
||||||
|
tenant = getattr(request, "tenant_hospital", None)
|
||||||
|
if tenant:
|
||||||
|
hospital_id = str(tenant.id)
|
||||||
|
|
||||||
# Get KPIs
|
# Get KPIs
|
||||||
kpis = UnifiedAnalyticsService.get_all_kpis(
|
kpis = UnifiedAnalyticsService.get_all_kpis(
|
||||||
user=user,
|
user=user,
|
||||||
@ -557,7 +805,45 @@ def command_center_api(request):
|
|||||||
for p in physician_data
|
for p in physician_data
|
||||||
]
|
]
|
||||||
|
|
||||||
return JsonResponse({"kpis": kpis, "charts": charts, "tables": tables})
|
# ============ AI-POWERED ANALYTICS ============
|
||||||
|
from .services.ai_analytics import (
|
||||||
|
ExecutiveSummaryGenerator,
|
||||||
|
EarlyWarningSystem,
|
||||||
|
ComplaintVolumeForecaster,
|
||||||
|
SLABreachPredictor,
|
||||||
|
ActionRecommendationEngine,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigger async Celery tasks for background refresh
|
||||||
|
from .tasks import (
|
||||||
|
generate_executive_summary_task,
|
||||||
|
generate_action_recommendations_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
generate_executive_summary_task.delay(
|
||||||
|
user_id=str(user.id),
|
||||||
|
hospital_id=hospital_id,
|
||||||
|
department_id=department_id,
|
||||||
|
period=date_range.replace("d", "") if date_range.endswith("d") else "30d",
|
||||||
|
)
|
||||||
|
generate_action_recommendations_task.delay(
|
||||||
|
user_id=str(user.id), hospital_id=hospital_id, department_id=department_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# AI features — read from cache (populated by Celery precompute or on-demand)
|
||||||
|
ai_data = {
|
||||||
|
"executive_summary": ExecutiveSummaryGenerator.generate(
|
||||||
|
user, hospital_id=hospital_id, department_id=department_id, period=date_range
|
||||||
|
),
|
||||||
|
"early_warnings": EarlyWarningSystem.detect(user, hospital_id=hospital_id, limit=5),
|
||||||
|
"complaint_forecast": ComplaintVolumeForecaster.forecast(user, hospital_id=hospital_id, forecast_days=30),
|
||||||
|
"sla_breach_predictions": SLABreachPredictor.predict(user, hospital_id=hospital_id, limit=10),
|
||||||
|
"action_recommendations": ActionRecommendationEngine.generate_recommendations(
|
||||||
|
user, hospital_id=hospital_id, department_id=department_id, limit=5
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse({"kpis": kpis, "charts": charts, "tables": tables, "ai": ai_data})
|
||||||
|
|
||||||
|
|
||||||
@block_source_user
|
@block_source_user
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Appreciation views - API views for appreciation management
|
Appreciation views - API views for appreciation management
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Count, Q, F
|
from django.db.models import Count, Q, F
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -41,14 +42,14 @@ class AppreciationCategoryViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
# Filter by hospital if provided
|
# Filter by hospital if provided
|
||||||
hospital_id = self.request.query_params.get('hospital_id')
|
hospital_id = self.request.query_params.get("hospital_id")
|
||||||
if hospital_id:
|
if hospital_id:
|
||||||
queryset = queryset.filter(Q(hospital_id=hospital_id) | Q(hospital__isnull=True))
|
queryset = queryset.filter(Q(hospital_id=hospital_id) | Q(hospital__isnull=True))
|
||||||
|
|
||||||
# Only show active categories
|
# Only show active categories
|
||||||
queryset = queryset.filter(is_active=True)
|
queryset = queryset.filter(is_active=True)
|
||||||
|
|
||||||
return queryset.select_related('hospital')
|
return queryset.select_related("hospital")
|
||||||
|
|
||||||
|
|
||||||
class AppreciationViewSet(viewsets.ModelViewSet):
|
class AppreciationViewSet(viewsets.ModelViewSet):
|
||||||
@ -64,14 +65,15 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
# Filter by hospital
|
# Filter by hospital
|
||||||
if user.hospital:
|
hospital = getattr(self.request, "tenant_hospital", None) or user.hospital
|
||||||
queryset = queryset.filter(hospital=user.hospital)
|
if hospital:
|
||||||
|
queryset = queryset.filter(hospital=hospital)
|
||||||
|
elif not user.is_px_admin():
|
||||||
|
queryset = queryset.none()
|
||||||
|
|
||||||
# Filter by department if user is department manager
|
# Filter by department if user is department manager
|
||||||
if user.department and user.is_department_manager():
|
if user.department and user.is_department_manager():
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(Q(department=user.department) | Q(department__isnull=True))
|
||||||
Q(department=user.department) | Q(department__isnull=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter by visibility
|
# Filter by visibility
|
||||||
# Users can see:
|
# Users can see:
|
||||||
@ -88,74 +90,55 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
# Get staff if user has a staff profile
|
# Get staff if user has a staff profile
|
||||||
staff = None
|
staff = None
|
||||||
if hasattr(user, 'staff_profile'):
|
if hasattr(user, "staff_profile"):
|
||||||
staff = user.staff_profile
|
staff = user.staff_profile
|
||||||
staff_content_type = ContentType.objects.get_for_model(type(staff))
|
staff_content_type = ContentType.objects.get_for_model(type(staff))
|
||||||
|
|
||||||
# Build visibility filter
|
# Build visibility filter
|
||||||
visibility_filter = (
|
visibility_filter = (
|
||||||
Q(sender=user) | # Sent by user
|
Q(sender=user) # Sent by user
|
||||||
Q(
|
| Q(recipient_content_type=user_content_type, recipient_object_id=user.id) # Received by user
|
||||||
recipient_content_type=user_content_type,
|
|
||||||
recipient_object_id=user.id
|
|
||||||
) # Received by user
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if staff:
|
if staff:
|
||||||
visibility_filter |= Q(
|
visibility_filter |= Q(
|
||||||
recipient_content_type=staff_content_type,
|
recipient_content_type=staff_content_type, recipient_object_id=staff.id
|
||||||
recipient_object_id=staff.id
|
|
||||||
) # Received by staff
|
) # Received by staff
|
||||||
|
|
||||||
if user.department:
|
if user.department:
|
||||||
visibility_filter |= Q(
|
visibility_filter |= Q(visibility=AppreciationVisibility.DEPARTMENT, department=user.department)
|
||||||
visibility=AppreciationVisibility.DEPARTMENT,
|
|
||||||
department=user.department
|
|
||||||
)
|
|
||||||
|
|
||||||
if user.hospital:
|
if hospital:
|
||||||
visibility_filter |= Q(
|
visibility_filter |= Q(visibility=AppreciationVisibility.HOSPITAL, hospital=hospital)
|
||||||
visibility=AppreciationVisibility.HOSPITAL,
|
|
||||||
hospital=user.hospital
|
|
||||||
)
|
|
||||||
|
|
||||||
visibility_filter |= Q(visibility=AppreciationVisibility.PUBLIC)
|
visibility_filter |= Q(visibility=AppreciationVisibility.PUBLIC)
|
||||||
|
|
||||||
queryset = queryset.filter(visibility_filter)
|
queryset = queryset.filter(visibility_filter)
|
||||||
|
|
||||||
# Filter by recipient
|
# Filter by recipient
|
||||||
recipient_type = self.request.query_params.get('recipient_type')
|
recipient_type = self.request.query_params.get("recipient_type")
|
||||||
recipient_id = self.request.query_params.get('recipient_id')
|
recipient_id = self.request.query_params.get("recipient_id")
|
||||||
if recipient_type and recipient_id:
|
if recipient_type and recipient_id:
|
||||||
if recipient_type == 'user':
|
if recipient_type == "user":
|
||||||
content_type = ContentType.objects.get_for_model(
|
content_type = ContentType.objects.get_for_model(self.request.user.__class__)
|
||||||
self.request.user.__class__
|
queryset = queryset.filter(recipient_content_type=content_type, recipient_object_id=recipient_id)
|
||||||
)
|
elif recipient_type == "staff":
|
||||||
queryset = queryset.filter(
|
|
||||||
recipient_content_type=content_type,
|
|
||||||
recipient_object_id=recipient_id
|
|
||||||
)
|
|
||||||
elif recipient_type == 'staff':
|
|
||||||
from apps.organizations.models import Staff
|
from apps.organizations.models import Staff
|
||||||
|
|
||||||
content_type = ContentType.objects.get_for_model(Staff)
|
content_type = ContentType.objects.get_for_model(Staff)
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(recipient_content_type=content_type, recipient_object_id=recipient_id)
|
||||||
recipient_content_type=content_type,
|
|
||||||
recipient_object_id=recipient_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter by status
|
# Filter by status
|
||||||
status_filter = self.request.query_params.get('status')
|
status_filter = self.request.query_params.get("status")
|
||||||
if status_filter:
|
if status_filter:
|
||||||
queryset = queryset.filter(status=status_filter)
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
# Filter by category
|
# Filter by category
|
||||||
category_id = self.request.query_params.get('category_id')
|
category_id = self.request.query_params.get("category_id")
|
||||||
if category_id:
|
if category_id:
|
||||||
queryset = queryset.filter(category_id=category_id)
|
queryset = queryset.filter(category_id=category_id)
|
||||||
|
|
||||||
return queryset.select_related(
|
return queryset.select_related("sender", "hospital", "department", "category").prefetch_related("recipient")
|
||||||
'sender', 'hospital', 'department', 'category'
|
|
||||||
).prefetch_related('recipient')
|
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""Create a new appreciation"""
|
"""Create a new appreciation"""
|
||||||
@ -166,32 +149,36 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
|||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
|
|
||||||
# Get recipient
|
# Get recipient
|
||||||
recipient_type = data['recipient_type']
|
recipient_type = data["recipient_type"]
|
||||||
recipient_id = data['recipient_id']
|
recipient_id = data["recipient_id"]
|
||||||
|
|
||||||
if recipient_type == 'user':
|
if recipient_type == "user":
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
|
|
||||||
recipient = User.objects.get(id=recipient_id)
|
recipient = User.objects.get(id=recipient_id)
|
||||||
content_type = ContentType.objects.get_for_model(User)
|
content_type = ContentType.objects.get_for_model(User)
|
||||||
else: # staff
|
else: # staff
|
||||||
from apps.organizations.models import Staff
|
from apps.organizations.models import Staff
|
||||||
|
|
||||||
recipient = Staff.objects.get(id=recipient_id)
|
recipient = Staff.objects.get(id=recipient_id)
|
||||||
content_type = ContentType.objects.get_for_model(Staff)
|
content_type = ContentType.objects.get_for_model(Staff)
|
||||||
|
|
||||||
# Get hospital
|
# Get hospital
|
||||||
from apps.organizations.models import Hospital
|
from apps.organizations.models import Hospital
|
||||||
hospital = Hospital.objects.get(id=data['hospital_id'])
|
|
||||||
|
hospital = Hospital.objects.get(id=data["hospital_id"])
|
||||||
|
|
||||||
# Get department
|
# Get department
|
||||||
department = None
|
department = None
|
||||||
if data.get('department_id'):
|
if data.get("department_id"):
|
||||||
from apps.organizations.models import Department
|
from apps.organizations.models import Department
|
||||||
department = Department.objects.get(id=data['department_id'])
|
|
||||||
|
department = Department.objects.get(id=data["department_id"])
|
||||||
|
|
||||||
# Get category
|
# Get category
|
||||||
category = None
|
category = None
|
||||||
if data.get('category_id'):
|
if data.get("category_id"):
|
||||||
category = AppreciationCategory.objects.get(id=data['category_id'])
|
category = AppreciationCategory.objects.get(id=data["category_id"])
|
||||||
|
|
||||||
# Create appreciation
|
# Create appreciation
|
||||||
appreciation = Appreciation.objects.create(
|
appreciation = Appreciation.objects.create(
|
||||||
@ -201,10 +188,10 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
|||||||
hospital=hospital,
|
hospital=hospital,
|
||||||
department=department,
|
department=department,
|
||||||
category=category,
|
category=category,
|
||||||
message_en=data['message_en'],
|
message_en=data["message_en"],
|
||||||
message_ar=data.get('message_ar', ''),
|
message_ar=data.get("message_ar", ""),
|
||||||
visibility=data['visibility'],
|
visibility=data["visibility"],
|
||||||
is_anonymous=data['is_anonymous'],
|
is_anonymous=data["is_anonymous"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send appreciation
|
# Send appreciation
|
||||||
@ -214,7 +201,7 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
|||||||
serializer = AppreciationSerializer(appreciation)
|
serializer = AppreciationSerializer(appreciation)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=["post"])
|
||||||
def acknowledge(self, request, pk=None):
|
def acknowledge(self, request, pk=None):
|
||||||
"""Acknowledge an appreciation"""
|
"""Acknowledge an appreciation"""
|
||||||
appreciation = self.get_object()
|
appreciation = self.get_object()
|
||||||
@ -222,12 +209,11 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
|||||||
# Check if user is the recipient
|
# Check if user is the recipient
|
||||||
user_content_type = ContentType.objects.get_for_model(request.user)
|
user_content_type = ContentType.objects.get_for_model(request.user)
|
||||||
if not (
|
if not (
|
||||||
appreciation.recipient_content_type == user_content_type and
|
appreciation.recipient_content_type == user_content_type
|
||||||
appreciation.recipient_object_id == request.user.id
|
and appreciation.recipient_object_id == request.user.id
|
||||||
):
|
):
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'You can only acknowledge appreciations sent to you'},
|
{"error": "You can only acknowledge appreciations sent to you"}, status=status.HTTP_403_FORBIDDEN
|
||||||
status=status.HTTP_403_FORBIDDEN
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Acknowledge
|
# Acknowledge
|
||||||
@ -237,7 +223,7 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
|||||||
serializer = AppreciationSerializer(appreciation)
|
serializer = AppreciationSerializer(appreciation)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=["get"])
|
||||||
def my_appreciations(self, request):
|
def my_appreciations(self, request):
|
||||||
"""Get appreciations for the current user"""
|
"""Get appreciations for the current user"""
|
||||||
# Get user's appreciations
|
# Get user's appreciations
|
||||||
@ -245,22 +231,18 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
# Check if user has staff profile
|
# Check if user has staff profile
|
||||||
staff = None
|
staff = None
|
||||||
if hasattr(request.user, 'staff_profile'):
|
if hasattr(request.user, "staff_profile"):
|
||||||
staff = request.user.staff_profile
|
staff = request.user.staff_profile
|
||||||
|
|
||||||
# Build query
|
# Build query
|
||||||
queryset = self.get_queryset().filter(
|
queryset = self.get_queryset().filter(
|
||||||
Q(
|
Q(recipient_content_type=user_content_type, recipient_object_id=request.user.id)
|
||||||
recipient_content_type=user_content_type,
|
|
||||||
recipient_object_id=request.user.id
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if staff:
|
if staff:
|
||||||
staff_content_type = ContentType.objects.get_for_model(type(staff))
|
staff_content_type = ContentType.objects.get_for_model(type(staff))
|
||||||
queryset |= self.get_queryset().filter(
|
queryset |= self.get_queryset().filter(
|
||||||
recipient_content_type=staff_content_type,
|
recipient_content_type=staff_content_type, recipient_object_id=staff.id
|
||||||
recipient_object_id=staff.id
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Paginate
|
# Paginate
|
||||||
@ -272,7 +254,7 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
|||||||
serializer = AppreciationSerializer(queryset, many=True)
|
serializer = AppreciationSerializer(queryset, many=True)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=["get"])
|
||||||
def sent_by_me(self, request):
|
def sent_by_me(self, request):
|
||||||
"""Get appreciations sent by the current user"""
|
"""Get appreciations sent by the current user"""
|
||||||
queryset = self.get_queryset().filter(sender=request.user)
|
queryset = self.get_queryset().filter(sender=request.user)
|
||||||
@ -286,7 +268,7 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
|||||||
serializer = AppreciationSerializer(queryset, many=True)
|
serializer = AppreciationSerializer(queryset, many=True)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=["get"])
|
||||||
def summary(self, request):
|
def summary(self, request):
|
||||||
"""Get appreciation summary for the current user"""
|
"""Get appreciation summary for the current user"""
|
||||||
# Get user's content type
|
# Get user's content type
|
||||||
@ -299,34 +281,28 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
# Count total received
|
# Count total received
|
||||||
total_received = Appreciation.objects.filter(
|
total_received = Appreciation.objects.filter(
|
||||||
recipient_content_type=user_content_type,
|
recipient_content_type=user_content_type, recipient_object_id=request.user.id
|
||||||
recipient_object_id=request.user.id
|
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
# Count total sent
|
# Count total sent
|
||||||
total_sent = Appreciation.objects.filter(
|
total_sent = Appreciation.objects.filter(sender=request.user).count()
|
||||||
sender=request.user
|
|
||||||
).count()
|
|
||||||
|
|
||||||
# Count this month received
|
# Count this month received
|
||||||
this_month_received = Appreciation.objects.filter(
|
this_month_received = Appreciation.objects.filter(
|
||||||
recipient_content_type=user_content_type,
|
recipient_content_type=user_content_type,
|
||||||
recipient_object_id=request.user.id,
|
recipient_object_id=request.user.id,
|
||||||
sent_at__year=current_year,
|
sent_at__year=current_year,
|
||||||
sent_at__month=current_month
|
sent_at__month=current_month,
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
# Count this month sent
|
# Count this month sent
|
||||||
this_month_sent = Appreciation.objects.filter(
|
this_month_sent = Appreciation.objects.filter(
|
||||||
sender=request.user,
|
sender=request.user, sent_at__year=current_year, sent_at__month=current_month
|
||||||
sent_at__year=current_year,
|
|
||||||
sent_at__month=current_month
|
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
# Get badges earned
|
# Get badges earned
|
||||||
badges_earned = UserBadge.objects.filter(
|
badges_earned = UserBadge.objects.filter(
|
||||||
recipient_content_type=user_content_type,
|
recipient_content_type=user_content_type, recipient_object_id=request.user.id
|
||||||
recipient_object_id=request.user.id
|
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
# Get hospital rank
|
# Get hospital rank
|
||||||
@ -337,7 +313,7 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
|||||||
recipient_content_type=user_content_type,
|
recipient_content_type=user_content_type,
|
||||||
recipient_object_id=request.user.id,
|
recipient_object_id=request.user.id,
|
||||||
year=current_year,
|
year=current_year,
|
||||||
month=current_month
|
month=current_month,
|
||||||
).first()
|
).first()
|
||||||
if stats:
|
if stats:
|
||||||
hospital_rank = stats.hospital_rank
|
hospital_rank = stats.hospital_rank
|
||||||
@ -345,30 +321,33 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
|||||||
# Get top category
|
# Get top category
|
||||||
top_category = None
|
top_category = None
|
||||||
if total_received > 0:
|
if total_received > 0:
|
||||||
top_category_obj = Appreciation.objects.filter(
|
top_category_obj = (
|
||||||
recipient_content_type=user_content_type,
|
Appreciation.objects.filter(
|
||||||
recipient_object_id=request.user.id
|
recipient_content_type=user_content_type, recipient_object_id=request.user.id
|
||||||
).values('category__name_en', 'category__icon', 'category__color').annotate(
|
)
|
||||||
count=Count('id')
|
.values("category__name_en", "category__icon", "category__color")
|
||||||
).order_by('-count').first()
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
if top_category_obj and top_category_obj['category__name_en']:
|
if top_category_obj and top_category_obj["category__name_en"]:
|
||||||
top_category = {
|
top_category = {
|
||||||
'name': top_category_obj['category__name_en'],
|
"name": top_category_obj["category__name_en"],
|
||||||
'icon': top_category_obj['category__icon'],
|
"icon": top_category_obj["category__icon"],
|
||||||
'color': top_category_obj['category__color'],
|
"color": top_category_obj["category__color"],
|
||||||
'count': top_category_obj['count']
|
"count": top_category_obj["count"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
summary = {
|
summary = {
|
||||||
'total_received': total_received,
|
"total_received": total_received,
|
||||||
'total_sent': total_sent,
|
"total_sent": total_sent,
|
||||||
'this_month_received': this_month_received,
|
"this_month_received": this_month_received,
|
||||||
'this_month_sent': this_month_sent,
|
"this_month_sent": this_month_sent,
|
||||||
'top_category': top_category,
|
"top_category": top_category,
|
||||||
'badges_earned': badges_earned,
|
"badges_earned": badges_earned,
|
||||||
'hospital_rank': hospital_rank,
|
"hospital_rank": hospital_rank,
|
||||||
}
|
}
|
||||||
|
|
||||||
serializer = AppreciationSummarySerializer(summary)
|
serializer = AppreciationSummarySerializer(summary)
|
||||||
@ -388,19 +367,22 @@ class AppreciationStatsViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
# Filter by hospital
|
# Filter by hospital
|
||||||
if user.hospital:
|
hospital = getattr(self.request, "tenant_hospital", None) or user.hospital
|
||||||
queryset = queryset.filter(hospital=user.hospital)
|
if hospital:
|
||||||
|
queryset = queryset.filter(hospital=hospital)
|
||||||
|
elif not user.is_px_admin():
|
||||||
|
queryset = queryset.none()
|
||||||
|
|
||||||
# Filter by year and month
|
# Filter by year and month
|
||||||
year = self.request.query_params.get('year')
|
year = self.request.query_params.get("year")
|
||||||
if year:
|
if year:
|
||||||
queryset = queryset.filter(year=int(year))
|
queryset = queryset.filter(year=int(year))
|
||||||
|
|
||||||
month = self.request.query_params.get('month')
|
month = self.request.query_params.get("month")
|
||||||
if month:
|
if month:
|
||||||
queryset = queryset.filter(month=int(month))
|
queryset = queryset.filter(month=int(month))
|
||||||
|
|
||||||
return queryset.select_related('hospital', 'department')
|
return queryset.select_related("hospital", "department")
|
||||||
|
|
||||||
|
|
||||||
class AppreciationBadgeViewSet(viewsets.ModelViewSet):
|
class AppreciationBadgeViewSet(viewsets.ModelViewSet):
|
||||||
@ -415,14 +397,14 @@ class AppreciationBadgeViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
# Filter by hospital if provided
|
# Filter by hospital if provided
|
||||||
hospital_id = self.request.query_params.get('hospital_id')
|
hospital_id = self.request.query_params.get("hospital_id")
|
||||||
if hospital_id:
|
if hospital_id:
|
||||||
queryset = queryset.filter(Q(hospital_id=hospital_id) | Q(hospital__isnull=True))
|
queryset = queryset.filter(Q(hospital_id=hospital_id) | Q(hospital__isnull=True))
|
||||||
|
|
||||||
# Only show active badges
|
# Only show active badges
|
||||||
queryset = queryset.filter(is_active=True)
|
queryset = queryset.filter(is_active=True)
|
||||||
|
|
||||||
return queryset.select_related('hospital')
|
return queryset.select_related("hospital")
|
||||||
|
|
||||||
|
|
||||||
class UserBadgeViewSet(viewsets.ReadOnlyModelViewSet):
|
class UserBadgeViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
@ -442,24 +424,16 @@ class UserBadgeViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
|
|
||||||
# Filter by user or user's staff profile
|
# Filter by user or user's staff profile
|
||||||
staff = None
|
staff = None
|
||||||
if hasattr(user, 'staff_profile'):
|
if hasattr(user, "staff_profile"):
|
||||||
staff = user.staff_profile
|
staff = user.staff_profile
|
||||||
staff_content_type = ContentType.objects.get_for_model(type(staff))
|
staff_content_type = ContentType.objects.get_for_model(type(staff))
|
||||||
|
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(Q(recipient_content_type=user_content_type, recipient_object_id=user.id))
|
||||||
Q(
|
|
||||||
recipient_content_type=user_content_type,
|
|
||||||
recipient_object_id=user.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if staff:
|
if staff:
|
||||||
queryset |= queryset.filter(
|
queryset |= queryset.filter(recipient_content_type=staff_content_type, recipient_object_id=staff.id)
|
||||||
recipient_content_type=staff_content_type,
|
|
||||||
recipient_object_id=staff.id
|
|
||||||
)
|
|
||||||
|
|
||||||
return queryset.select_related('badge')
|
return queryset.select_related("badge")
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardView(generics.ListAPIView):
|
class LeaderboardView(generics.ListAPIView):
|
||||||
@ -471,8 +445,8 @@ class LeaderboardView(generics.ListAPIView):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Build leaderboard"""
|
"""Build leaderboard"""
|
||||||
# Get filters
|
# Get filters
|
||||||
year = self.request.query_params.get('year')
|
year = self.request.query_params.get("year")
|
||||||
month = self.request.query_params.get('month')
|
month = self.request.query_params.get("month")
|
||||||
|
|
||||||
# Default to current month
|
# Default to current month
|
||||||
if not year or not month:
|
if not year or not month:
|
||||||
@ -485,46 +459,48 @@ class LeaderboardView(generics.ListAPIView):
|
|||||||
|
|
||||||
# Get hospital from request user
|
# Get hospital from request user
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
if not user.hospital:
|
hospital = getattr(self.request, "tenant_hospital", None) or user.hospital
|
||||||
|
if not hospital and not user.is_px_admin():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Get stats for the period
|
# Get stats for the period
|
||||||
stats = AppreciationStats.objects.filter(
|
stats_qs = AppreciationStats.objects.filter(year=year, month=month, received_count__gt=0)
|
||||||
hospital=user.hospital,
|
if hospital:
|
||||||
year=year,
|
stats_qs = stats_qs.filter(hospital=hospital)
|
||||||
month=month,
|
stats = stats_qs.order_by("-received_count")
|
||||||
received_count__gt=0
|
|
||||||
).order_by('-received_count')
|
|
||||||
|
|
||||||
# Build leaderboard
|
# Build leaderboard
|
||||||
leaderboard = []
|
leaderboard = []
|
||||||
for rank, stat in enumerate(stats, start=1):
|
for rank, stat in enumerate(stats, start=1):
|
||||||
recipient_name = stat.get_recipient_name()
|
recipient_name = stat.get_recipient_name()
|
||||||
recipient_type = stat.recipient_content_type.model if stat.recipient_content_type else 'unknown'
|
recipient_type = stat.recipient_content_type.model if stat.recipient_content_type else "unknown"
|
||||||
|
|
||||||
# Get badges for recipient
|
# Get badges for recipient
|
||||||
badges = []
|
badges = []
|
||||||
user_badges = UserBadge.objects.filter(
|
user_badges = UserBadge.objects.filter(
|
||||||
recipient_content_type=stat.recipient_content_type,
|
recipient_content_type=stat.recipient_content_type, recipient_object_id=stat.recipient_object_id
|
||||||
recipient_object_id=stat.recipient_object_id
|
).select_related("badge")
|
||||||
).select_related('badge')
|
|
||||||
|
|
||||||
for user_badge in user_badges:
|
for user_badge in user_badges:
|
||||||
badges.append({
|
badges.append(
|
||||||
'name': user_badge.badge.name_en,
|
{
|
||||||
'icon': user_badge.badge.icon,
|
"name": user_badge.badge.name_en,
|
||||||
'color': user_badge.badge.color,
|
"icon": user_badge.badge.icon,
|
||||||
})
|
"color": user_badge.badge.color,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
leaderboard.append({
|
leaderboard.append(
|
||||||
'rank': rank,
|
{
|
||||||
'recipient_type': recipient_type,
|
"rank": rank,
|
||||||
'recipient_id': stat.recipient_object_id,
|
"recipient_type": recipient_type,
|
||||||
'recipient_name': recipient_name,
|
"recipient_id": stat.recipient_object_id,
|
||||||
'hospital': stat.hospital.name,
|
"recipient_name": recipient_name,
|
||||||
'department': stat.department.name if stat.department else None,
|
"hospital": stat.hospital.name,
|
||||||
'received_count': stat.received_count,
|
"department": stat.department.name if stat.department else None,
|
||||||
'badges': badges,
|
"received_count": stat.received_count,
|
||||||
})
|
"badges": badges,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return leaderboard
|
return leaderboard
|
||||||
|
|||||||
@ -65,22 +65,22 @@ def export_complaints_csv(queryset, filters=None):
|
|||||||
[
|
[
|
||||||
str(complaint.id)[:8],
|
str(complaint.id)[:8],
|
||||||
complaint.title,
|
complaint.title,
|
||||||
complaint.patient.get_full_name(),
|
complaint.patient.get_full_name() if complaint.patient else "",
|
||||||
complaint.patient.mrn,
|
complaint.patient.mrn if complaint.patient else "",
|
||||||
complaint.hospital.name_en,
|
complaint.hospital.name,
|
||||||
complaint.department.name_en if complaint.department else "",
|
complaint.department.name if complaint.department else "",
|
||||||
complaint.get_category_display(),
|
str(complaint.category) if complaint.category else "",
|
||||||
complaint.get_severity_display(),
|
complaint.get_severity_display(),
|
||||||
complaint.get_priority_display(),
|
complaint.get_priority_display(),
|
||||||
complaint.get_status_display(),
|
complaint.get_status_display(),
|
||||||
complaint.get_source_display(),
|
complaint.get_complaint_source_type_display(),
|
||||||
complaint.assigned_to.get_full_name() if complaint.assigned_to else "",
|
complaint.assigned_to.get_full_name() if complaint.assigned_to else "",
|
||||||
complaint.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
complaint.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
complaint.due_at.strftime("%Y-%m-%d %H:%M:%S"),
|
complaint.due_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.due_at else "",
|
||||||
"Yes" if complaint.is_overdue else "No",
|
"Yes" if complaint.is_overdue else "No",
|
||||||
complaint.resolved_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.resolved_at else "",
|
complaint.resolved_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.resolved_at else "",
|
||||||
complaint.closed_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.closed_at else "",
|
complaint.closed_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.closed_at else "",
|
||||||
complaint.description[:500],
|
complaint.description[:500] if complaint.description else "",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -139,18 +139,20 @@ def export_complaints_excel(queryset, filters=None):
|
|||||||
for row_num, complaint in enumerate(queryset, 2):
|
for row_num, complaint in enumerate(queryset, 2):
|
||||||
ws.cell(row=row_num, column=1, value=str(complaint.id)[:8])
|
ws.cell(row=row_num, column=1, value=str(complaint.id)[:8])
|
||||||
ws.cell(row=row_num, column=2, value=complaint.title)
|
ws.cell(row=row_num, column=2, value=complaint.title)
|
||||||
ws.cell(row=row_num, column=3, value=complaint.patient.get_full_name())
|
ws.cell(row=row_num, column=3, value=complaint.patient.get_full_name() if complaint.patient else "")
|
||||||
ws.cell(row=row_num, column=4, value=complaint.patient.mrn)
|
ws.cell(row=row_num, column=4, value=complaint.patient.mrn if complaint.patient else "")
|
||||||
ws.cell(row=row_num, column=5, value=complaint.hospital.name_en)
|
ws.cell(row=row_num, column=5, value=complaint.hospital.name)
|
||||||
ws.cell(row=row_num, column=6, value=complaint.department.name_en if complaint.department else "")
|
ws.cell(row=row_num, column=6, value=complaint.department.name if complaint.department else "")
|
||||||
ws.cell(row=row_num, column=7, value=complaint.get_category_display())
|
ws.cell(row=row_num, column=7, value=str(complaint.category) if complaint.category else "")
|
||||||
ws.cell(row=row_num, column=8, value=complaint.get_severity_display())
|
ws.cell(row=row_num, column=8, value=complaint.get_severity_display())
|
||||||
ws.cell(row=row_num, column=9, value=complaint.get_priority_display())
|
ws.cell(row=row_num, column=9, value=complaint.get_priority_display())
|
||||||
ws.cell(row=row_num, column=10, value=complaint.get_status_display())
|
ws.cell(row=row_num, column=10, value=complaint.get_status_display())
|
||||||
ws.cell(row=row_num, column=11, value=complaint.get_source_display())
|
ws.cell(row=row_num, column=11, value=complaint.get_complaint_source_type_display())
|
||||||
ws.cell(row=row_num, column=12, value=complaint.assigned_to.get_full_name() if complaint.assigned_to else "")
|
ws.cell(row=row_num, column=12, value=complaint.assigned_to.get_full_name() if complaint.assigned_to else "")
|
||||||
ws.cell(row=row_num, column=13, value=complaint.created_at.strftime("%Y-%m-%d %H:%M:%S"))
|
ws.cell(row=row_num, column=13, value=complaint.created_at.strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
ws.cell(row=row_num, column=14, value=complaint.due_at.strftime("%Y-%m-%d %H:%M:%S"))
|
ws.cell(
|
||||||
|
row=row_num, column=14, value=complaint.due_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.due_at else ""
|
||||||
|
)
|
||||||
ws.cell(row=row_num, column=15, value="Yes" if complaint.is_overdue else "No")
|
ws.cell(row=row_num, column=15, value="Yes" if complaint.is_overdue else "No")
|
||||||
ws.cell(
|
ws.cell(
|
||||||
row=row_num,
|
row=row_num,
|
||||||
@ -162,7 +164,7 @@ def export_complaints_excel(queryset, filters=None):
|
|||||||
column=17,
|
column=17,
|
||||||
value=complaint.closed_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.closed_at else "",
|
value=complaint.closed_at.strftime("%Y-%m-%d %H:%M:%S") if complaint.closed_at else "",
|
||||||
)
|
)
|
||||||
ws.cell(row=row_num, column=18, value=complaint.description[:500])
|
ws.cell(row=row_num, column=18, value=complaint.description[:500] if complaint.description else "")
|
||||||
|
|
||||||
# Auto-adjust column widths
|
# Auto-adjust column widths
|
||||||
for column in ws.columns:
|
for column in ws.columns:
|
||||||
|
|||||||
@ -9,10 +9,26 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.shortcuts import render, redirect, get_object_or_404
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse, HttpResponse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
|
|
||||||
|
def _get_notification_hospital(request, hospital_id=None):
|
||||||
|
"""Resolve hospital for notification views, using tenant_hospital for PX Admins."""
|
||||||
|
if request.user.is_superuser and hospital_id:
|
||||||
|
return get_object_or_404(Hospital, id=hospital_id)
|
||||||
|
if request.user.is_px_admin():
|
||||||
|
tenant = getattr(request, "tenant_hospital", None)
|
||||||
|
if tenant:
|
||||||
|
return tenant
|
||||||
|
if hospital_id:
|
||||||
|
return get_object_or_404(Hospital, id=hospital_id)
|
||||||
|
if request.user.hospital:
|
||||||
|
return request.user.hospital
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
from apps.organizations.models import Hospital
|
from apps.organizations.models import Hospital
|
||||||
from .settings_models import HospitalNotificationSettings, NotificationSettingsLog
|
from .settings_models import HospitalNotificationSettings, NotificationSettingsLog
|
||||||
|
|
||||||
@ -40,12 +56,13 @@ def notification_settings_view(request, hospital_id=None):
|
|||||||
if not can_manage_notifications(request.user):
|
if not can_manage_notifications(request.user):
|
||||||
raise PermissionDenied("You do not have permission to manage notification settings.")
|
raise PermissionDenied("You do not have permission to manage notification settings.")
|
||||||
|
|
||||||
# Get hospital - if superuser, can view any; otherwise only their hospital
|
# Get hospital
|
||||||
if request.user.is_superuser and hospital_id:
|
hospital = _get_notification_hospital(request, hospital_id)
|
||||||
hospital = get_object_or_404(Hospital, id=hospital_id)
|
if not hospital:
|
||||||
else:
|
if request.user.is_px_admin():
|
||||||
hospital = request.user.hospital
|
return redirect("core:select_hospital")
|
||||||
hospital_id = hospital.id
|
return redirect("core:no_hospital")
|
||||||
|
hospital_id = hospital.id
|
||||||
|
|
||||||
# Get or create settings
|
# Get or create settings
|
||||||
settings = HospitalNotificationSettings.get_for_hospital(hospital_id)
|
settings = HospitalNotificationSettings.get_for_hospital(hospital_id)
|
||||||
@ -279,10 +296,12 @@ def notification_settings_update(request, hospital_id=None):
|
|||||||
raise PermissionDenied("You do not have permission to manage notification settings.")
|
raise PermissionDenied("You do not have permission to manage notification settings.")
|
||||||
|
|
||||||
# Get hospital
|
# Get hospital
|
||||||
if request.user.is_superuser and hospital_id:
|
hospital = _get_notification_hospital(request, hospital_id)
|
||||||
hospital = get_object_or_404(Hospital, id=hospital_id)
|
if not hospital:
|
||||||
else:
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||||
hospital = request.user.hospital
|
return JsonResponse({"success": False, "error": "No hospital assigned"}, status=400)
|
||||||
|
messages.error(request, "No hospital assigned. Please contact your administrator.")
|
||||||
|
return redirect("analytics:command_center")
|
||||||
|
|
||||||
settings = HospitalNotificationSettings.get_for_hospital(hospital.id)
|
settings = HospitalNotificationSettings.get_for_hospital(hospital.id)
|
||||||
|
|
||||||
@ -342,10 +361,10 @@ def update_quiet_hours(request, hospital_id=None):
|
|||||||
if not can_manage_notifications(request.user):
|
if not can_manage_notifications(request.user):
|
||||||
raise PermissionDenied("You do not have permission to manage notification settings.")
|
raise PermissionDenied("You do not have permission to manage notification settings.")
|
||||||
|
|
||||||
if request.user.is_superuser and hospital_id:
|
hospital = _get_notification_hospital(request, hospital_id)
|
||||||
hospital = get_object_or_404(Hospital, id=hospital_id)
|
if not hospital:
|
||||||
else:
|
messages.error(request, "No hospital assigned. Please contact your administrator.")
|
||||||
hospital = request.user.hospital
|
return redirect("analytics:command_center")
|
||||||
|
|
||||||
settings = HospitalNotificationSettings.get_for_hospital(hospital.id)
|
settings = HospitalNotificationSettings.get_for_hospital(hospital.id)
|
||||||
|
|
||||||
@ -367,10 +386,10 @@ def test_notification(request, hospital_id=None):
|
|||||||
if not can_manage_notifications(request.user):
|
if not can_manage_notifications(request.user):
|
||||||
raise PermissionDenied("You do not have permission to manage notification settings.")
|
raise PermissionDenied("You do not have permission to manage notification settings.")
|
||||||
|
|
||||||
if request.user.is_superuser and hospital_id:
|
hospital = _get_notification_hospital(request, hospital_id)
|
||||||
hospital = get_object_or_404(Hospital, id=hospital_id)
|
if not hospital:
|
||||||
else:
|
messages.error(request, "No hospital assigned. Please contact your administrator.")
|
||||||
hospital = request.user.hospital
|
return redirect("analytics:command_center")
|
||||||
|
|
||||||
settings = HospitalNotificationSettings.get_for_hospital(hospital.id)
|
settings = HospitalNotificationSettings.get_for_hospital(hospital.id)
|
||||||
channel = request.POST.get("channel", "email")
|
channel = request.POST.get("channel", "email")
|
||||||
@ -410,10 +429,9 @@ def notification_settings_api(request, hospital_id=None):
|
|||||||
API endpoint to get current notification settings as JSON.
|
API endpoint to get current notification settings as JSON.
|
||||||
Useful for AJAX updates and mobile apps.
|
Useful for AJAX updates and mobile apps.
|
||||||
"""
|
"""
|
||||||
if request.user.is_superuser and hospital_id:
|
hospital = _get_notification_hospital(request, hospital_id)
|
||||||
hospital = get_object_or_404(Hospital, id=hospital_id)
|
if not hospital:
|
||||||
else:
|
return JsonResponse({"error": "No hospital assigned"}, status=400)
|
||||||
hospital = request.user.hospital
|
|
||||||
|
|
||||||
settings = HospitalNotificationSettings.get_for_hospital(hospital.id)
|
settings = HospitalNotificationSettings.get_for_hospital(hospital.id)
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ Middleware for PX Source User access restriction.
|
|||||||
Provides global route-level protection to ensure source users
|
Provides global route-level protection to ensure source users
|
||||||
can only access their designated pages.
|
can only access their designated pages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.urls import resolve
|
from django.urls import resolve
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
@ -22,33 +23,31 @@ class SourceUserRestrictionMiddleware(MiddlewareMixin):
|
|||||||
|
|
||||||
# URL path prefixes that source users CAN access (whitelist)
|
# URL path prefixes that source users CAN access (whitelist)
|
||||||
ALLOWED_PATH_PREFIXES = [
|
ALLOWED_PATH_PREFIXES = [
|
||||||
'/px-sources/', # Source user portal
|
"/px-sources/", # Source user portal
|
||||||
]
|
]
|
||||||
|
|
||||||
# Specific URL names that source users CAN access
|
# Specific URL names that source users CAN access
|
||||||
ALLOWED_URL_NAMES = {
|
ALLOWED_URL_NAMES = {
|
||||||
# Password change
|
# Password change
|
||||||
'accounts:password_change',
|
"accounts:password_change",
|
||||||
'accounts:password_change_done',
|
"accounts:password_change_done",
|
||||||
# Settings (limited)
|
# Settings (limited)
|
||||||
'accounts:settings',
|
"accounts:settings",
|
||||||
# Logout
|
# Logout
|
||||||
'accounts:logout',
|
"accounts:logout",
|
||||||
# Login (for redirect after logout)
|
# Login (for redirect after logout)
|
||||||
'accounts:login',
|
"accounts:login",
|
||||||
# Static files (for CSS/JS)
|
|
||||||
None, # Static files don't have URL names
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Explicitly blocked paths (even if they match allowed prefixes)
|
# Explicitly blocked paths (even if they match allowed prefixes)
|
||||||
BLOCKED_PATHS = [
|
BLOCKED_PATHS = [
|
||||||
'/px-sources/new/',
|
"/px-sources/new/",
|
||||||
'/px-sources/create/',
|
"/px-sources/create/",
|
||||||
'/px-sources/<uuid:pk>/edit/',
|
"/px-sources/<uuid:pk>/edit/",
|
||||||
'/px-sources/<uuid:pk>/delete/',
|
"/px-sources/<uuid:pk>/delete/",
|
||||||
'/px-sources/<uuid:pk>/toggle/',
|
"/px-sources/<uuid:pk>/toggle/",
|
||||||
'/px-sources/ajax/',
|
"/px-sources/ajax/",
|
||||||
'/px-sources/api/',
|
"/px-sources/api/",
|
||||||
]
|
]
|
||||||
|
|
||||||
def process_request(self, request):
|
def process_request(self, request):
|
||||||
@ -89,27 +88,23 @@ class SourceUserRestrictionMiddleware(MiddlewareMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Check for static/media files (allow these)
|
# Check for static/media files (allow these)
|
||||||
if path.startswith('/static/') or path.startswith('/media/'):
|
if path.startswith("/static/") or path.startswith("/media/"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check for i18n URLs
|
# Check for i18n URLs
|
||||||
if path.startswith('/i18n/'):
|
if path.startswith("/i18n/"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Everything else is BLOCKED for source users
|
# Everything else is BLOCKED for source users
|
||||||
return self._block_access(request)
|
return self._block_access(request)
|
||||||
|
|
||||||
def _is_source_user(self, user):
|
def _is_source_user(self, user):
|
||||||
"""Check if user is an active source user."""
|
"""Check if user is a source user via Django Group membership."""
|
||||||
if not hasattr(user, 'source_user_profile'):
|
return user.is_source_user()
|
||||||
return False
|
|
||||||
|
|
||||||
source_user = user.source_user_profile
|
|
||||||
return source_user.is_active
|
|
||||||
|
|
||||||
def _block_access(self, request):
|
def _block_access(self, request):
|
||||||
"""Block access and redirect to source user dashboard."""
|
"""Block access and redirect to source user dashboard."""
|
||||||
return redirect('px_sources:source_user_dashboard')
|
return redirect("px_sources:source_user_dashboard")
|
||||||
|
|
||||||
|
|
||||||
class SourceUserSessionMiddleware(MiddlewareMixin):
|
class SourceUserSessionMiddleware(MiddlewareMixin):
|
||||||
@ -138,8 +133,7 @@ class SourceUserSessionMiddleware(MiddlewareMixin):
|
|||||||
|
|
||||||
def _is_source_user(self, user):
|
def _is_source_user(self, user):
|
||||||
"""Check if user is an active source user."""
|
"""Check if user is an active source user."""
|
||||||
if not hasattr(user, 'source_user_profile'):
|
if not hasattr(user, "is_source_user"):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
source_user = user.source_user_profile
|
return user.is_source_user()
|
||||||
return source_user.is_active
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
RCA (Root Cause Analysis) views
|
RCA (Root Cause Analysis) views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
@ -9,6 +10,7 @@ from django.http import JsonResponse
|
|||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.http import PermissionDenied
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.generic import (
|
from django.views.generic import (
|
||||||
CreateView,
|
CreateView,
|
||||||
@ -18,6 +20,20 @@ from django.views.generic import (
|
|||||||
UpdateView,
|
UpdateView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rca_access(request, rca):
|
||||||
|
user = request.user
|
||||||
|
if user.is_superuser:
|
||||||
|
return
|
||||||
|
if user.is_px_admin():
|
||||||
|
tenant = getattr(request, "tenant_hospital", None)
|
||||||
|
if tenant and rca.hospital_id == tenant.id:
|
||||||
|
return
|
||||||
|
elif user.hospital and rca.hospital_id == user.hospital.id:
|
||||||
|
return
|
||||||
|
raise PermissionDenied("You don't have access to this RCA.")
|
||||||
|
|
||||||
|
|
||||||
from .forms import (
|
from .forms import (
|
||||||
RCAAttachmentForm,
|
RCAAttachmentForm,
|
||||||
RCAClosureForm,
|
RCAClosureForm,
|
||||||
@ -39,32 +55,27 @@ from .models import (
|
|||||||
|
|
||||||
class RCAListView(LoginRequiredMixin, ListView):
|
class RCAListView(LoginRequiredMixin, ListView):
|
||||||
"""List view for Root Cause Analyses"""
|
"""List view for Root Cause Analyses"""
|
||||||
|
|
||||||
model = RootCauseAnalysis
|
model = RootCauseAnalysis
|
||||||
template_name = 'rca/rca_list.html'
|
template_name = "rca/rca_list.html"
|
||||||
context_object_name = 'rcas'
|
context_object_name = "rcas"
|
||||||
paginate_by = 20
|
paginate_by = 20
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = RootCauseAnalysis.objects.filter(
|
queryset = (
|
||||||
is_deleted=False
|
RootCauseAnalysis.objects.filter(is_deleted=False)
|
||||||
).select_related(
|
.select_related("hospital", "department", "assigned_to", "created_by")
|
||||||
'hospital',
|
.prefetch_related("root_causes", "corrective_actions")
|
||||||
'department',
|
|
||||||
'assigned_to',
|
|
||||||
'created_by'
|
|
||||||
).prefetch_related(
|
|
||||||
'root_causes',
|
|
||||||
'corrective_actions'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get filter parameters
|
# Get filter parameters
|
||||||
status = self.request.GET.get('status')
|
status = self.request.GET.get("status")
|
||||||
severity = self.request.GET.get('severity')
|
severity = self.request.GET.get("severity")
|
||||||
priority = self.request.GET.get('priority')
|
priority = self.request.GET.get("priority")
|
||||||
hospital = self.request.GET.get('hospital')
|
hospital = self.request.GET.get("hospital")
|
||||||
search = self.request.GET.get('search')
|
search = self.request.GET.get("search")
|
||||||
date_from = self.request.GET.get('date_from')
|
date_from = self.request.GET.get("date_from")
|
||||||
date_to = self.request.GET.get('date_to')
|
date_to = self.request.GET.get("date_to")
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
if status:
|
if status:
|
||||||
@ -76,114 +87,103 @@ class RCAListView(LoginRequiredMixin, ListView):
|
|||||||
if hospital:
|
if hospital:
|
||||||
queryset = queryset.filter(hospital_id=hospital)
|
queryset = queryset.filter(hospital_id=hospital)
|
||||||
if search:
|
if search:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(Q(title__icontains=search) | Q(description__icontains=search))
|
||||||
Q(title__icontains=search) |
|
|
||||||
Q(description__icontains=search)
|
|
||||||
)
|
|
||||||
if date_from:
|
if date_from:
|
||||||
queryset = queryset.filter(created_at__gte=date_from)
|
queryset = queryset.filter(created_at__gte=date_from)
|
||||||
if date_to:
|
if date_to:
|
||||||
queryset = queryset.filter(created_at__lte=date_to)
|
queryset = queryset.filter(created_at__lte=date_to)
|
||||||
|
|
||||||
# Filter by user's hospital (if not admin)
|
# Filter by user's hospital (if not admin)
|
||||||
if not self.request.user.is_superuser:
|
user = self.request.user
|
||||||
from apps.organizations.models import Hospital
|
if user.is_px_admin():
|
||||||
user_hospitals = Hospital.objects.filter(
|
tenant = getattr(self.request, "tenant_hospital", None)
|
||||||
staff__user=self.request.user
|
if tenant:
|
||||||
)
|
queryset = queryset.filter(hospital=tenant)
|
||||||
queryset = queryset.filter(hospital__in=user_hospitals)
|
elif user.hospital:
|
||||||
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
else:
|
||||||
|
queryset = queryset.none()
|
||||||
|
|
||||||
return queryset.order_by('-created_at')
|
return queryset.order_by("-created_at")
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
# Add filter form
|
# Add filter form
|
||||||
from .forms import RCAFilterForm
|
from .forms import RCAFilterForm
|
||||||
context['filter_form'] = RCAFilterForm(self.request.GET)
|
|
||||||
|
context["filter_form"] = RCAFilterForm(self.request.GET)
|
||||||
|
|
||||||
# Add counts
|
# Add counts
|
||||||
context['total_count'] = self.get_queryset().count()
|
context["total_count"] = self.get_queryset().count()
|
||||||
context['draft_count'] = self.get_queryset().filter(
|
context["draft_count"] = self.get_queryset().filter(status=RCAStatus.DRAFT).count()
|
||||||
status=RCAStatus.DRAFT
|
context["in_progress_count"] = self.get_queryset().filter(status=RCAStatus.IN_PROGRESS).count()
|
||||||
).count()
|
context["review_count"] = self.get_queryset().filter(status=RCAStatus.REVIEW).count()
|
||||||
context['in_progress_count'] = self.get_queryset().filter(
|
context["approved_count"] = self.get_queryset().filter(status=RCAStatus.APPROVED).count()
|
||||||
status=RCAStatus.IN_PROGRESS
|
context["closed_count"] = self.get_queryset().filter(status=RCAStatus.CLOSED).count()
|
||||||
).count()
|
|
||||||
context['review_count'] = self.get_queryset().filter(
|
|
||||||
status=RCAStatus.REVIEW
|
|
||||||
).count()
|
|
||||||
context['approved_count'] = self.get_queryset().filter(
|
|
||||||
status=RCAStatus.APPROVED
|
|
||||||
).count()
|
|
||||||
context['closed_count'] = self.get_queryset().filter(
|
|
||||||
status=RCAStatus.CLOSED
|
|
||||||
).count()
|
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class RCADetailView(LoginRequiredMixin, DetailView):
|
class RCADetailView(LoginRequiredMixin, DetailView):
|
||||||
"""Detail view for Root Cause Analysis"""
|
"""Detail view for Root Cause Analysis"""
|
||||||
|
|
||||||
model = RootCauseAnalysis
|
model = RootCauseAnalysis
|
||||||
template_name = 'rca/rca_detail.html'
|
template_name = "rca/rca_detail.html"
|
||||||
context_object_name = 'rca'
|
context_object_name = "rca"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return RootCauseAnalysis.objects.filter(
|
return (
|
||||||
is_deleted=False
|
RootCauseAnalysis.objects.filter(is_deleted=False)
|
||||||
).select_related(
|
.select_related("hospital", "department", "assigned_to", "created_by", "approved_by", "closed_by")
|
||||||
'hospital',
|
.prefetch_related(
|
||||||
'department',
|
"root_causes__verified_by",
|
||||||
'assigned_to',
|
"corrective_actions__root_cause",
|
||||||
'created_by',
|
"attachments",
|
||||||
'approved_by',
|
"notes__created_by",
|
||||||
'closed_by'
|
"status_logs__changed_by",
|
||||||
).prefetch_related(
|
)
|
||||||
'root_causes__verified_by',
|
|
||||||
'corrective_actions__root_cause',
|
|
||||||
'attachments',
|
|
||||||
'notes__created_by',
|
|
||||||
'status_logs__changed_by'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_queryset(self):
|
||||||
context = super().get_context_data(**kwargs)
|
queryset = super().get_queryset()
|
||||||
|
user = self.request.user
|
||||||
|
if user.is_px_admin():
|
||||||
|
tenant = getattr(self.request, "tenant_hospital", None)
|
||||||
|
if tenant:
|
||||||
|
return queryset.filter(hospital=tenant)
|
||||||
|
elif user.hospital:
|
||||||
|
return queryset.filter(hospital=user.hospital)
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
# Add forms
|
# Add forms
|
||||||
context['root_cause_form'] = RCARootCauseForm()
|
context["root_cause_form"] = RCARootCauseForm()
|
||||||
context['corrective_action_form'] = RCACorrectiveActionForm(
|
context["corrective_action_form"] = RCACorrectiveActionForm(rca=self.object)
|
||||||
rca=self.object
|
context["attachment_form"] = RCAAttachmentForm()
|
||||||
)
|
context["note_form"] = RCANoteForm()
|
||||||
context['attachment_form'] = RCAAttachmentForm()
|
context["status_change_form"] = RCAStatusChangeForm()
|
||||||
context['note_form'] = RCANoteForm()
|
context["approval_form"] = RCAApprovalForm()
|
||||||
context['status_change_form'] = RCAStatusChangeForm()
|
context["closure_form"] = RCAClosureForm()
|
||||||
context['approval_form'] = RCAApprovalForm()
|
|
||||||
context['closure_form'] = RCAClosureForm()
|
|
||||||
|
|
||||||
# Calculate progress
|
# Calculate progress
|
||||||
total_actions = self.object.corrective_actions.count()
|
total_actions = self.object.corrective_actions.count()
|
||||||
completed_actions = self.object.corrective_actions.filter(
|
completed_actions = self.object.corrective_actions.filter(status=RCAActionStatus.COMPLETED).count()
|
||||||
status=RCAActionStatus.COMPLETED
|
context["progress_percentage"] = (completed_actions / total_actions * 100) if total_actions > 0 else 0
|
||||||
).count()
|
|
||||||
context['progress_percentage'] = (
|
|
||||||
(completed_actions / total_actions * 100)
|
|
||||||
if total_actions > 0 else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class RCACreateView(LoginRequiredMixin, CreateView):
|
class RCACreateView(LoginRequiredMixin, CreateView):
|
||||||
"""Create view for Root Cause Analysis"""
|
"""Create view for Root Cause Analysis"""
|
||||||
|
|
||||||
model = RootCauseAnalysis
|
model = RootCauseAnalysis
|
||||||
form_class = RootCauseAnalysisForm
|
form_class = RootCauseAnalysisForm
|
||||||
template_name = 'rca/rca_form.html'
|
template_name = "rca/rca_form.html"
|
||||||
success_url = reverse_lazy('rca:rca_list')
|
success_url = reverse_lazy("rca:rca_list")
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
kwargs['user'] = self.request.user
|
kwargs["user"] = self.request.user
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
@ -191,8 +191,8 @@ class RCACreateView(LoginRequiredMixin, CreateView):
|
|||||||
rca.created_by = self.request.user
|
rca.created_by = self.request.user
|
||||||
|
|
||||||
# Handle linking to related item (if provided)
|
# Handle linking to related item (if provided)
|
||||||
related_model = self.request.POST.get('related_model')
|
related_model = self.request.POST.get("related_model")
|
||||||
related_id = self.request.POST.get('related_id')
|
related_id = self.request.POST.get("related_id")
|
||||||
|
|
||||||
if related_model and related_id:
|
if related_model and related_id:
|
||||||
try:
|
try:
|
||||||
@ -205,40 +205,30 @@ class RCACreateView(LoginRequiredMixin, CreateView):
|
|||||||
rca.save()
|
rca.save()
|
||||||
|
|
||||||
# Create status log
|
# Create status log
|
||||||
rca.status_logs.create(
|
rca.status_logs.create(old_status="", new_status=rca.status, changed_by=self.request.user, notes="RCA created")
|
||||||
old_status='',
|
|
||||||
new_status=rca.status,
|
|
||||||
changed_by=self.request.user,
|
|
||||||
notes='RCA created'
|
|
||||||
)
|
|
||||||
|
|
||||||
messages.success(
|
messages.success(self.request, "Root Cause Analysis created successfully!")
|
||||||
self.request,
|
return redirect("rca:rca_detail", pk=rca.pk)
|
||||||
'Root Cause Analysis created successfully!'
|
|
||||||
)
|
|
||||||
return redirect('rca:rca_detail', pk=rca.pk)
|
|
||||||
|
|
||||||
|
|
||||||
class RCAUpdateView(LoginRequiredMixin, UpdateView):
|
class RCAUpdateView(LoginRequiredMixin, UpdateView):
|
||||||
"""Update view for Root Cause Analysis"""
|
"""Update view for Root Cause Analysis"""
|
||||||
|
|
||||||
model = RootCauseAnalysis
|
model = RootCauseAnalysis
|
||||||
form_class = RootCauseAnalysisForm
|
form_class = RootCauseAnalysisForm
|
||||||
template_name = 'rca/rca_form.html'
|
template_name = "rca/rca_form.html"
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
kwargs['user'] = self.request.user
|
kwargs["user"] = self.request.user
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse_lazy('rca:rca_detail', kwargs={'pk': self.object.pk})
|
return reverse_lazy("rca:rca_detail", kwargs={"pk": self.object.pk})
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
rca = form.save()
|
rca = form.save()
|
||||||
messages.success(
|
messages.success(self.request, "Root Cause Analysis updated successfully!")
|
||||||
self.request,
|
|
||||||
'Root Cause Analysis updated successfully!'
|
|
||||||
)
|
|
||||||
return redirect(self.get_success_url())
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
|
||||||
@ -247,12 +237,10 @@ class RCADeleteView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
||||||
|
_check_rca_access(request, rca)
|
||||||
rca.soft_delete(user=request.user)
|
rca.soft_delete(user=request.user)
|
||||||
messages.success(
|
messages.success(request, "Root Cause Analysis deleted successfully!")
|
||||||
request,
|
return redirect("rca:rca_list")
|
||||||
'Root Cause Analysis deleted successfully!'
|
|
||||||
)
|
|
||||||
return redirect('rca:rca_list')
|
|
||||||
|
|
||||||
|
|
||||||
class RCAStatusChangeView(LoginRequiredMixin, View):
|
class RCAStatusChangeView(LoginRequiredMixin, View):
|
||||||
@ -260,12 +248,13 @@ class RCAStatusChangeView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
||||||
|
_check_rca_access(request, rca)
|
||||||
form = RCAStatusChangeForm(request.POST)
|
form = RCAStatusChangeForm(request.POST)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
old_status = rca.status
|
old_status = rca.status
|
||||||
new_status = form.cleaned_data['new_status']
|
new_status = form.cleaned_data["new_status"]
|
||||||
notes = form.cleaned_data['notes']
|
notes = form.cleaned_data["notes"]
|
||||||
|
|
||||||
rca.status = new_status
|
rca.status = new_status
|
||||||
|
|
||||||
@ -280,40 +269,28 @@ class RCAStatusChangeView(LoginRequiredMixin, View):
|
|||||||
rca.save()
|
rca.save()
|
||||||
|
|
||||||
# Create status log
|
# Create status log
|
||||||
rca.status_logs.create(
|
rca.status_logs.create(old_status=old_status, new_status=new_status, changed_by=request.user, notes=notes)
|
||||||
old_status=old_status,
|
|
||||||
new_status=new_status,
|
|
||||||
changed_by=request.user,
|
|
||||||
notes=notes
|
|
||||||
)
|
|
||||||
|
|
||||||
messages.success(
|
messages.success(request, f"Status changed from {old_status} to {new_status}")
|
||||||
request,
|
|
||||||
f'Status changed from {old_status} to {new_status}'
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
messages.error(request, 'Invalid status change')
|
messages.error(request, "Invalid status change")
|
||||||
|
|
||||||
return redirect('rca:rca_detail', pk=rca.pk)
|
return redirect("rca:rca_detail", pk=rca.pk)
|
||||||
|
|
||||||
|
|
||||||
class RCAApprovalView(LoginRequiredMixin, View):
|
class RCAApprovalView(LoginRequiredMixin, View):
|
||||||
"""View to approve RCA"""
|
"""View to approve RCA"""
|
||||||
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
rca = get_object_or_404(
|
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False, status=RCAStatus.REVIEW)
|
||||||
RootCauseAnalysis,
|
_check_rca_access(request, rca)
|
||||||
pk=pk,
|
|
||||||
is_deleted=False,
|
|
||||||
status=RCAStatus.REVIEW
|
|
||||||
)
|
|
||||||
form = RCAApprovalForm(request.POST)
|
form = RCAApprovalForm(request.POST)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
rca.status = RCAStatus.APPROVED
|
rca.status = RCAStatus.APPROVED
|
||||||
rca.approved_by = request.user
|
rca.approved_by = request.user
|
||||||
rca.approved_at = timezone.now()
|
rca.approved_at = timezone.now()
|
||||||
rca.approval_notes = form.cleaned_data['approval_notes']
|
rca.approval_notes = form.cleaned_data["approval_notes"]
|
||||||
rca.save()
|
rca.save()
|
||||||
|
|
||||||
# Create status log
|
# Create status log
|
||||||
@ -321,14 +298,14 @@ class RCAApprovalView(LoginRequiredMixin, View):
|
|||||||
old_status=RCAStatus.REVIEW,
|
old_status=RCAStatus.REVIEW,
|
||||||
new_status=RCAStatus.APPROVED,
|
new_status=RCAStatus.APPROVED,
|
||||||
changed_by=request.user,
|
changed_by=request.user,
|
||||||
notes=rca.approval_notes
|
notes=rca.approval_notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, 'RCA approved successfully!')
|
messages.success(request, "RCA approved successfully!")
|
||||||
else:
|
else:
|
||||||
messages.error(request, 'Invalid approval data')
|
messages.error(request, "Invalid approval data")
|
||||||
|
|
||||||
return redirect('rca:rca_detail', pk=rca.pk)
|
return redirect("rca:rca_detail", pk=rca.pk)
|
||||||
|
|
||||||
|
|
||||||
class RCAClosureView(LoginRequiredMixin, View):
|
class RCAClosureView(LoginRequiredMixin, View):
|
||||||
@ -336,21 +313,17 @@ class RCAClosureView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
rca = get_object_or_404(
|
rca = get_object_or_404(
|
||||||
RootCauseAnalysis,
|
RootCauseAnalysis, pk=pk, is_deleted=False, status__in=[RCAStatus.APPROVED, RCAStatus.IN_PROGRESS]
|
||||||
pk=pk,
|
|
||||||
is_deleted=False,
|
|
||||||
status__in=[RCAStatus.APPROVED, RCAStatus.IN_PROGRESS]
|
|
||||||
)
|
)
|
||||||
|
_check_rca_access(request, rca)
|
||||||
form = RCAClosureForm(request.POST)
|
form = RCAClosureForm(request.POST)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
rca.status = RCAStatus.CLOSED
|
rca.status = RCAStatus.CLOSED
|
||||||
rca.closed_by = request.user
|
rca.closed_by = request.user
|
||||||
rca.closed_at = timezone.now()
|
rca.closed_at = timezone.now()
|
||||||
rca.closure_notes = form.cleaned_data['closure_notes']
|
rca.closure_notes = form.cleaned_data["closure_notes"]
|
||||||
rca.actual_completion_date = form.cleaned_data[
|
rca.actual_completion_date = form.cleaned_data["actual_completion_date"]
|
||||||
'actual_completion_date'
|
|
||||||
]
|
|
||||||
rca.save()
|
rca.save()
|
||||||
|
|
||||||
# Create status log
|
# Create status log
|
||||||
@ -358,14 +331,14 @@ class RCAClosureView(LoginRequiredMixin, View):
|
|||||||
old_status=RCAStatus.APPROVED,
|
old_status=RCAStatus.APPROVED,
|
||||||
new_status=RCAStatus.CLOSED,
|
new_status=RCAStatus.CLOSED,
|
||||||
changed_by=request.user,
|
changed_by=request.user,
|
||||||
notes=rca.closure_notes
|
notes=rca.closure_notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.success(request, 'RCA closed successfully!')
|
messages.success(request, "RCA closed successfully!")
|
||||||
else:
|
else:
|
||||||
messages.error(request, 'Invalid closure data')
|
messages.error(request, "Invalid closure data")
|
||||||
|
|
||||||
return redirect('rca:rca_detail', pk=rca.pk)
|
return redirect("rca:rca_detail", pk=rca.pk)
|
||||||
|
|
||||||
|
|
||||||
class RCARootCauseCreateView(LoginRequiredMixin, View):
|
class RCARootCauseCreateView(LoginRequiredMixin, View):
|
||||||
@ -373,20 +346,18 @@ class RCARootCauseCreateView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
||||||
|
_check_rca_access(request, rca)
|
||||||
form = RCARootCauseForm(request.POST)
|
form = RCARootCauseForm(request.POST)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
root_cause = form.save(commit=False)
|
root_cause = form.save(commit=False)
|
||||||
root_cause.rca = rca
|
root_cause.rca = rca
|
||||||
root_cause.save()
|
root_cause.save()
|
||||||
messages.success(
|
messages.success(request, "Root cause added successfully!")
|
||||||
request,
|
|
||||||
'Root cause added successfully!'
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
messages.error(request, 'Invalid root cause data')
|
messages.error(request, "Invalid root cause data")
|
||||||
|
|
||||||
return redirect('rca:rca_detail', pk=rca.pk)
|
return redirect("rca:rca_detail", pk=rca.pk)
|
||||||
|
|
||||||
|
|
||||||
class RCARootCauseDeleteView(LoginRequiredMixin, View):
|
class RCARootCauseDeleteView(LoginRequiredMixin, View):
|
||||||
@ -394,14 +365,11 @@ class RCARootCauseDeleteView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
def post(self, request, pk, root_cause_pk):
|
def post(self, request, pk, root_cause_pk):
|
||||||
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
||||||
root_cause = get_object_or_404(
|
_check_rca_access(request, rca)
|
||||||
RCARootCause,
|
root_cause = get_object_or_404(RCARootCause, pk=root_cause_pk, rca=rca)
|
||||||
pk=root_cause_pk,
|
|
||||||
rca=rca
|
|
||||||
)
|
|
||||||
root_cause.delete()
|
root_cause.delete()
|
||||||
messages.success(request, 'Root cause deleted successfully!')
|
messages.success(request, "Root cause deleted successfully!")
|
||||||
return redirect('rca:rca_detail', pk=rca.pk)
|
return redirect("rca:rca_detail", pk=rca.pk)
|
||||||
|
|
||||||
|
|
||||||
class RCACorrectiveActionCreateView(LoginRequiredMixin, View):
|
class RCACorrectiveActionCreateView(LoginRequiredMixin, View):
|
||||||
@ -409,20 +377,18 @@ class RCACorrectiveActionCreateView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
||||||
|
_check_rca_access(request, rca)
|
||||||
form = RCACorrectiveActionForm(request.POST, rca=rca)
|
form = RCACorrectiveActionForm(request.POST, rca=rca)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
action = form.save(commit=False)
|
action = form.save(commit=False)
|
||||||
action.rca = rca
|
action.rca = rca
|
||||||
action.save()
|
action.save()
|
||||||
messages.success(
|
messages.success(request, "Corrective action added successfully!")
|
||||||
request,
|
|
||||||
'Corrective action added successfully!'
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
messages.error(request, 'Invalid corrective action data')
|
messages.error(request, "Invalid corrective action data")
|
||||||
|
|
||||||
return redirect('rca:rca_detail', pk=rca.pk)
|
return redirect("rca:rca_detail", pk=rca.pk)
|
||||||
|
|
||||||
|
|
||||||
class RCACorrectiveActionDeleteView(LoginRequiredMixin, View):
|
class RCACorrectiveActionDeleteView(LoginRequiredMixin, View):
|
||||||
@ -430,17 +396,11 @@ class RCACorrectiveActionDeleteView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
def post(self, request, pk, action_pk):
|
def post(self, request, pk, action_pk):
|
||||||
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
||||||
action = get_object_or_404(
|
_check_rca_access(request, rca)
|
||||||
RCACorrectiveAction,
|
action = get_object_or_404(RCACorrectiveAction, pk=action_pk, rca=rca)
|
||||||
pk=action_pk,
|
|
||||||
rca=rca
|
|
||||||
)
|
|
||||||
action.delete()
|
action.delete()
|
||||||
messages.success(
|
messages.success(request, "Corrective action deleted successfully!")
|
||||||
request,
|
return redirect("rca:rca_detail", pk=rca.pk)
|
||||||
'Corrective action deleted successfully!'
|
|
||||||
)
|
|
||||||
return redirect('rca:rca_detail', pk=rca.pk)
|
|
||||||
|
|
||||||
|
|
||||||
class RCAAttachmentCreateView(LoginRequiredMixin, View):
|
class RCAAttachmentCreateView(LoginRequiredMixin, View):
|
||||||
@ -448,24 +408,22 @@ class RCAAttachmentCreateView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
||||||
|
_check_rca_access(request, rca)
|
||||||
form = RCAAttachmentForm(request.POST, request.FILES)
|
form = RCAAttachmentForm(request.POST, request.FILES)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
attachment = form.save(commit=False)
|
attachment = form.save(commit=False)
|
||||||
attachment.rca = rca
|
attachment.rca = rca
|
||||||
attachment.uploaded_by = request.user
|
attachment.uploaded_by = request.user
|
||||||
attachment.filename = request.FILES['file'].name
|
attachment.filename = request.FILES["file"].name
|
||||||
attachment.file_type = request.FILES['file'].content_type
|
attachment.file_type = request.FILES["file"].content_type
|
||||||
attachment.file_size = request.FILES['file'].size
|
attachment.file_size = request.FILES["file"].size
|
||||||
attachment.save()
|
attachment.save()
|
||||||
messages.success(
|
messages.success(request, "Attachment added successfully!")
|
||||||
request,
|
|
||||||
'Attachment added successfully!'
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
messages.error(request, 'Invalid attachment data')
|
messages.error(request, "Invalid attachment data")
|
||||||
|
|
||||||
return redirect('rca:rca_detail', pk=rca.pk)
|
return redirect("rca:rca_detail", pk=rca.pk)
|
||||||
|
|
||||||
|
|
||||||
class RCANoteCreateView(LoginRequiredMixin, View):
|
class RCANoteCreateView(LoginRequiredMixin, View):
|
||||||
@ -473,6 +431,7 @@ class RCANoteCreateView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False)
|
||||||
|
_check_rca_access(request, rca)
|
||||||
form = RCANoteForm(request.POST)
|
form = RCANoteForm(request.POST)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
@ -480,8 +439,8 @@ class RCANoteCreateView(LoginRequiredMixin, View):
|
|||||||
note.rca = rca
|
note.rca = rca
|
||||||
note.created_by = request.user
|
note.created_by = request.user
|
||||||
note.save()
|
note.save()
|
||||||
messages.success(request, 'Note added successfully!')
|
messages.success(request, "Note added successfully!")
|
||||||
else:
|
else:
|
||||||
messages.error(request, 'Invalid note data')
|
messages.error(request, "Invalid note data")
|
||||||
|
|
||||||
return redirect('rca:rca_detail', pk=rca.pk)
|
return redirect("rca:rca_detail", pk=rca.pk)
|
||||||
|
|||||||
@ -4,12 +4,24 @@ Report generation services for PX360 - Simplified Version
|
|||||||
Handles data fetching, filtering, aggregation, and export
|
Handles data fetching, filtering, aggregation, and export
|
||||||
for custom reports across all data sources. No chart functionality.
|
for custom reports across all data sources. No chart functionality.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
Count, Sum, Avg, Min, Max, F, Q, Value,
|
Count,
|
||||||
FloatField, IntegerField, CharField, ExpressionWrapper, DurationField
|
Sum,
|
||||||
|
Avg,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
F,
|
||||||
|
Q,
|
||||||
|
Value,
|
||||||
|
FloatField,
|
||||||
|
IntegerField,
|
||||||
|
CharField,
|
||||||
|
ExpressionWrapper,
|
||||||
|
DurationField,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import TruncDate, TruncMonth, TruncWeek, TruncYear, Extract
|
from django.db.models.functions import TruncDate, TruncMonth, TruncWeek, TruncYear, Extract
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
@ -30,151 +42,155 @@ class ReportBuilderService:
|
|||||||
|
|
||||||
# Available fields for each data source
|
# Available fields for each data source
|
||||||
SOURCE_FIELDS = {
|
SOURCE_FIELDS = {
|
||||||
'complaints': {
|
"complaints": {
|
||||||
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
|
"id": {"label": "ID", "field": "id", "type": "string"},
|
||||||
'reference_number': {'label': 'Reference Number', 'field': 'reference_number', 'type': 'string'},
|
"reference_number": {"label": "Reference Number", "field": "reference_number", "type": "string"},
|
||||||
'title': {'label': 'Title', 'field': 'title', 'type': 'string'},
|
"title": {"label": "Title", "field": "title", "type": "string"},
|
||||||
'description': {'label': 'Description', 'field': 'description', 'type': 'text'},
|
"description": {"label": "Description", "field": "description", "type": "text"},
|
||||||
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
|
"status": {"label": "Status", "field": "status", "type": "choice"},
|
||||||
'severity': {'label': 'Severity', 'field': 'severity', 'type': 'choice'},
|
"severity": {"label": "Severity", "field": "severity", "type": "choice"},
|
||||||
'priority': {'label': 'Priority', 'field': 'priority', 'type': 'choice'},
|
"priority": {"label": "Priority", "field": "priority", "type": "choice"},
|
||||||
'source': {'label': 'Source', 'field': 'complaint_source_type', 'type': 'choice'},
|
"source": {"label": "Source", "field": "complaint_source_type", "type": "choice"},
|
||||||
'hospital': {'label': 'Hospital', 'field': 'hospital__name', 'type': 'string'},
|
"hospital": {"label": "Hospital", "field": "hospital__name", "type": "string"},
|
||||||
'department': {'label': 'Department', 'field': 'department__name', 'type': 'string'},
|
"department": {"label": "Department", "field": "department__name", "type": "string"},
|
||||||
'section': {'label': 'Section', 'field': 'section__name', 'type': 'string'},
|
"section": {"label": "Section", "field": "section__name", "type": "string"},
|
||||||
'patient_name': {'label': 'Patient Name', 'field': 'patient__first_name', 'type': 'string'},
|
"patient_name": {"label": "Patient Name", "field": "patient__first_name", "type": "string"},
|
||||||
'patient_mobile': {'label': 'Patient Mobile', 'field': 'patient__mobile_number', 'type': 'string'},
|
"patient_mobile": {"label": "Patient Mobile", "field": "patient__mobile_number", "type": "string"},
|
||||||
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
|
"created_at": {"label": "Created Date", "field": "created_at", "type": "datetime"},
|
||||||
'updated_at': {'label': 'Updated Date', 'field': 'updated_at', 'type': 'datetime'},
|
"updated_at": {"label": "Updated Date", "field": "updated_at", "type": "datetime"},
|
||||||
'due_at': {'label': 'Due Date', 'field': 'due_at', 'type': 'datetime'},
|
"due_at": {"label": "Due Date", "field": "due_at", "type": "datetime"},
|
||||||
'resolved_at': {'label': 'Resolved Date', 'field': 'resolved_at', 'type': 'datetime'},
|
"resolved_at": {"label": "Resolved Date", "field": "resolved_at", "type": "datetime"},
|
||||||
'is_overdue': {'label': 'Is Overdue', 'field': 'is_overdue', 'type': 'boolean'},
|
"is_overdue": {"label": "Is Overdue", "field": "is_overdue", "type": "boolean"},
|
||||||
'resolution_time_hours': {'label': 'Resolution Time (Hours)', 'field': 'resolution_time_hours', 'type': 'number'},
|
"resolution_time_hours": {
|
||||||
'journey_type': {'label': 'Journey Type', 'field': 'journey__journey_type', 'type': 'string'},
|
"label": "Resolution Time (Hours)",
|
||||||
|
"field": "resolution_time_hours",
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"journey_type": {"label": "Journey Type", "field": "journey__journey_type", "type": "string"},
|
||||||
},
|
},
|
||||||
'inquiries': {
|
"inquiries": {
|
||||||
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
|
"id": {"label": "ID", "field": "id", "type": "string"},
|
||||||
'reference_number': {'label': 'Reference Number', 'field': 'reference_number', 'type': 'string'},
|
"reference_number": {"label": "Reference Number", "field": "reference_number", "type": "string"},
|
||||||
'title': {'label': 'Title', 'field': 'title', 'type': 'string'},
|
"title": {"label": "Title", "field": "title", "type": "string"},
|
||||||
'description': {'label': 'Description', 'field': 'description', 'type': 'text'},
|
"description": {"label": "Description", "field": "description", "type": "text"},
|
||||||
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
|
"status": {"label": "Status", "field": "status", "type": "choice"},
|
||||||
'category': {'label': 'Category', 'field': 'category__name_en', 'type': 'string'},
|
"category": {"label": "Category", "field": "category__name_en", "type": "string"},
|
||||||
'hospital': {'label': 'Hospital', 'field': 'hospital__name', 'type': 'string'},
|
"hospital": {"label": "Hospital", "field": "hospital__name", "type": "string"},
|
||||||
'department': {'label': 'Department', 'field': 'department__name', 'type': 'string'},
|
"department": {"label": "Department", "field": "department__name", "type": "string"},
|
||||||
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
|
"created_at": {"label": "Created Date", "field": "created_at", "type": "datetime"},
|
||||||
'updated_at': {'label': 'Updated Date', 'field': 'updated_at', 'type': 'datetime'},
|
"updated_at": {"label": "Updated Date", "field": "updated_at", "type": "datetime"},
|
||||||
},
|
},
|
||||||
'observations': {
|
"observations": {
|
||||||
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
|
"id": {"label": "ID", "field": "id", "type": "string"},
|
||||||
'tracking_code': {'label': 'Tracking Code', 'field': 'tracking_code', 'type': 'string'},
|
"tracking_code": {"label": "Tracking Code", "field": "tracking_code", "type": "string"},
|
||||||
'title': {'label': 'Title', 'field': 'title', 'type': 'string'},
|
"title": {"label": "Title", "field": "title", "type": "string"},
|
||||||
'description': {'label': 'Description', 'field': 'description', 'type': 'text'},
|
"description": {"label": "Description", "field": "description", "type": "text"},
|
||||||
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
|
"status": {"label": "Status", "field": "status", "type": "choice"},
|
||||||
'severity': {'label': 'Severity', 'field': 'severity', 'type': 'choice'},
|
"severity": {"label": "Severity", "field": "severity", "type": "choice"},
|
||||||
'category': {'label': 'Category', 'field': 'category__name_en', 'type': 'string'},
|
"category": {"label": "Category", "field": "category__name_en", "type": "string"},
|
||||||
'hospital': {'label': 'Hospital', 'field': 'hospital__name', 'type': 'string'},
|
"hospital": {"label": "Hospital", "field": "hospital__name", "type": "string"},
|
||||||
'department': {'label': 'Department', 'field': 'assigned_department__name', 'type': 'string'},
|
"department": {"label": "Department", "field": "assigned_department__name", "type": "string"},
|
||||||
'location': {'label': 'Location', 'field': 'location_text', 'type': 'string'},
|
"location": {"label": "Location", "field": "location_text", "type": "string"},
|
||||||
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
|
"created_at": {"label": "Created Date", "field": "created_at", "type": "datetime"},
|
||||||
'incident_datetime': {'label': 'Incident Date', 'field': 'incident_datetime', 'type': 'datetime'},
|
"incident_datetime": {"label": "Incident Date", "field": "incident_datetime", "type": "datetime"},
|
||||||
},
|
},
|
||||||
'px_actions': {
|
"px_actions": {
|
||||||
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
|
"id": {"label": "ID", "field": "id", "type": "string"},
|
||||||
'title': {'label': 'Title', 'field': 'title', 'type': 'string'},
|
"title": {"label": "Title", "field": "title", "type": "string"},
|
||||||
'description': {'label': 'Description', 'field': 'description', 'type': 'text'},
|
"description": {"label": "Description", "field": "description", "type": "text"},
|
||||||
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
|
"status": {"label": "Status", "field": "status", "type": "choice"},
|
||||||
'priority': {'label': 'Priority', 'field': 'priority', 'type': 'choice'},
|
"priority": {"label": "Priority", "field": "priority", "type": "choice"},
|
||||||
'action_type': {'label': 'Action Type', 'field': 'action_type', 'type': 'choice'},
|
"action_type": {"label": "Action Type", "field": "action_type", "type": "choice"},
|
||||||
'hospital': {'label': 'Hospital', 'field': 'hospital__name', 'type': 'string'},
|
"hospital": {"label": "Hospital", "field": "hospital__name", "type": "string"},
|
||||||
'department': {'label': 'Department', 'field': 'department__name', 'type': 'string'},
|
"department": {"label": "Department", "field": "department__name", "type": "string"},
|
||||||
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
|
"created_at": {"label": "Created Date", "field": "created_at", "type": "datetime"},
|
||||||
'due_at': {'label': 'Due Date', 'field': 'due_at', 'type': 'datetime'},
|
"due_at": {"label": "Due Date", "field": "due_at", "type": "datetime"},
|
||||||
'completed_at': {'label': 'Completed Date', 'field': 'completed_at', 'type': 'datetime'},
|
"completed_at": {"label": "Completed Date", "field": "completed_at", "type": "datetime"},
|
||||||
'is_overdue': {'label': 'Is Overdue', 'field': 'is_overdue', 'type': 'boolean'},
|
"is_overdue": {"label": "Is Overdue", "field": "is_overdue", "type": "boolean"},
|
||||||
},
|
},
|
||||||
'surveys': {
|
"surveys": {
|
||||||
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
|
"id": {"label": "ID", "field": "id", "type": "string"},
|
||||||
'survey_template': {'label': 'Survey Template', 'field': 'survey_template__name', 'type': 'string'},
|
"survey_template": {"label": "Survey Template", "field": "survey_template__name", "type": "string"},
|
||||||
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
|
"status": {"label": "Status", "field": "status", "type": "choice"},
|
||||||
'total_score': {'label': 'Total Score', 'field': 'total_score', 'type': 'number'},
|
"total_score": {"label": "Total Score", "field": "total_score", "type": "number"},
|
||||||
'is_negative': {'label': 'Is Negative', 'field': 'is_negative', 'type': 'boolean'},
|
"is_negative": {"label": "Is Negative", "field": "is_negative", "type": "boolean"},
|
||||||
'patient_type': {'label': 'Patient Type', 'field': 'journey__patient_type', 'type': 'string'},
|
"patient_type": {"label": "Patient Type", "field": "journey__patient_type", "type": "string"},
|
||||||
'journey_type': {'label': 'Journey Type', 'field': 'journey__journey_type', 'type': 'string'},
|
"journey_type": {"label": "Journey Type", "field": "journey__journey_type", "type": "string"},
|
||||||
'hospital': {'label': 'Hospital', 'field': 'survey_template__hospital__name', 'type': 'string'},
|
"hospital": {"label": "Hospital", "field": "survey_template__hospital__name", "type": "string"},
|
||||||
'department': {'label': 'Department', 'field': 'journey__department__name', 'type': 'string'},
|
"department": {"label": "Department", "field": "journey__department__name", "type": "string"},
|
||||||
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
|
"created_at": {"label": "Created Date", "field": "created_at", "type": "datetime"},
|
||||||
'completed_at': {'label': 'Completed Date', 'field': 'completed_at', 'type': 'datetime'},
|
"completed_at": {"label": "Completed Date", "field": "completed_at", "type": "datetime"},
|
||||||
},
|
},
|
||||||
'physicians': {
|
"physicians": {
|
||||||
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
|
"id": {"label": "ID", "field": "id", "type": "string"},
|
||||||
'physician_name': {'label': 'Physician Name', 'field': 'physician__full_name', 'type': 'string'},
|
"physician_name": {"label": "Physician Name", "field": "physician__full_name", "type": "string"},
|
||||||
'department': {'label': 'Department', 'field': 'department__name', 'type': 'string'},
|
"department": {"label": "Department", "field": "department__name", "type": "string"},
|
||||||
'month': {'label': 'Month', 'field': 'month', 'type': 'string'},
|
"month": {"label": "Month", "field": "month", "type": "string"},
|
||||||
'year': {'label': 'Year', 'field': 'year', 'type': 'number'},
|
"year": {"label": "Year", "field": "year", "type": "number"},
|
||||||
'total_surveys': {'label': 'Total Surveys', 'field': 'total_surveys', 'type': 'number'},
|
"total_surveys": {"label": "Total Surveys", "field": "total_surveys", "type": "number"},
|
||||||
'avg_rating': {'label': 'Average Rating', 'field': 'avg_rating', 'type': 'number'},
|
"avg_rating": {"label": "Average Rating", "field": "avg_rating", "type": "number"},
|
||||||
'positive_count': {'label': 'Positive', 'field': 'positive_count', 'type': 'number'},
|
"positive_count": {"label": "Positive", "field": "positive_count", "type": "number"},
|
||||||
'neutral_count': {'label': 'Neutral', 'field': 'neutral_count', 'type': 'number'},
|
"neutral_count": {"label": "Neutral", "field": "neutral_count", "type": "number"},
|
||||||
'negative_count': {'label': 'Negative', 'field': 'negative_count', 'type': 'number'},
|
"negative_count": {"label": "Negative", "field": "negative_count", "type": "number"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Filter options for each data source
|
# Filter options for each data source
|
||||||
SOURCE_FILTERS = {
|
SOURCE_FILTERS = {
|
||||||
'complaints': [
|
"complaints": [
|
||||||
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
|
{"name": "date_range", "label": "Date Range", "type": "daterange"},
|
||||||
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
|
{"name": "status", "label": "Status", "type": "multiselect"},
|
||||||
{'name': 'severity', 'label': 'Severity', 'type': 'multiselect'},
|
{"name": "severity", "label": "Severity", "type": "multiselect"},
|
||||||
{'name': 'priority', 'label': 'Priority', 'type': 'multiselect'},
|
{"name": "priority", "label": "Priority", "type": "multiselect"},
|
||||||
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
|
{"name": "hospital", "label": "Hospital", "type": "select"},
|
||||||
{'name': 'department', 'label': 'Department', 'type': 'select'},
|
{"name": "department", "label": "Department", "type": "select"},
|
||||||
{'name': 'section', 'label': 'Section', 'type': 'select'},
|
{"name": "section", "label": "Section", "type": "select"},
|
||||||
{'name': 'source', 'label': 'Source', 'type': 'multiselect'},
|
{"name": "source", "label": "Source", "type": "multiselect"},
|
||||||
{'name': 'is_overdue', 'label': 'Is Overdue', 'type': 'boolean'},
|
{"name": "is_overdue", "label": "Is Overdue", "type": "boolean"},
|
||||||
{'name': 'journey_type', 'label': 'Journey Type', 'type': 'select'},
|
{"name": "journey_type", "label": "Journey Type", "type": "select"},
|
||||||
],
|
],
|
||||||
'inquiries': [
|
"inquiries": [
|
||||||
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
|
{"name": "date_range", "label": "Date Range", "type": "daterange"},
|
||||||
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
|
{"name": "status", "label": "Status", "type": "multiselect"},
|
||||||
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
|
{"name": "hospital", "label": "Hospital", "type": "select"},
|
||||||
{'name': 'department', 'label': 'Department', 'type': 'select'},
|
{"name": "department", "label": "Department", "type": "select"},
|
||||||
{'name': 'category', 'label': 'Category', 'type': 'select'},
|
{"name": "category", "label": "Category", "type": "select"},
|
||||||
],
|
],
|
||||||
'observations': [
|
"observations": [
|
||||||
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
|
{"name": "date_range", "label": "Date Range", "type": "daterange"},
|
||||||
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
|
{"name": "status", "label": "Status", "type": "multiselect"},
|
||||||
{'name': 'severity', 'label': 'Severity', 'type': 'multiselect'},
|
{"name": "severity", "label": "Severity", "type": "multiselect"},
|
||||||
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
|
{"name": "hospital", "label": "Hospital", "type": "select"},
|
||||||
{'name': 'department', 'label': 'Department', 'type': 'select'},
|
{"name": "department", "label": "Department", "type": "select"},
|
||||||
{'name': 'category', 'label': 'Category', 'type': 'select'},
|
{"name": "category", "label": "Category", "type": "select"},
|
||||||
],
|
],
|
||||||
'px_actions': [
|
"px_actions": [
|
||||||
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
|
{"name": "date_range", "label": "Date Range", "type": "daterange"},
|
||||||
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
|
{"name": "status", "label": "Status", "type": "multiselect"},
|
||||||
{'name': 'priority', 'label': 'Priority', 'type': 'multiselect'},
|
{"name": "priority", "label": "Priority", "type": "multiselect"},
|
||||||
{'name': 'action_type', 'label': 'Action Type', 'type': 'multiselect'},
|
{"name": "action_type", "label": "Action Type", "type": "multiselect"},
|
||||||
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
|
{"name": "hospital", "label": "Hospital", "type": "select"},
|
||||||
{'name': 'department', 'label': 'Department', 'type': 'select'},
|
{"name": "department", "label": "Department", "type": "select"},
|
||||||
{'name': 'is_overdue', 'label': 'Is Overdue', 'type': 'boolean'},
|
{"name": "is_overdue", "label": "Is Overdue", "type": "boolean"},
|
||||||
],
|
],
|
||||||
'surveys': [
|
"surveys": [
|
||||||
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
|
{"name": "date_range", "label": "Date Range", "type": "daterange"},
|
||||||
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
|
{"name": "status", "label": "Status", "type": "multiselect"},
|
||||||
{'name': 'is_negative', 'label': 'Is Negative', 'type': 'boolean'},
|
{"name": "is_negative", "label": "Is Negative", "type": "boolean"},
|
||||||
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
|
{"name": "hospital", "label": "Hospital", "type": "select"},
|
||||||
{'name': 'department', 'label': 'Department', 'type': 'select'},
|
{"name": "department", "label": "Department", "type": "select"},
|
||||||
{'name': 'patient_type', 'label': 'Patient Type', 'type': 'select'},
|
{"name": "patient_type", "label": "Patient Type", "type": "select"},
|
||||||
{'name': 'journey_type', 'label': 'Journey Type', 'type': 'select'},
|
{"name": "journey_type", "label": "Journey Type", "type": "select"},
|
||||||
],
|
],
|
||||||
'physicians': [
|
"physicians": [
|
||||||
{'name': 'month_range', 'label': 'Month Range', 'type': 'monthrange'},
|
{"name": "month_range", "label": "Month Range", "type": "monthrange"},
|
||||||
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
|
{"name": "hospital", "label": "Hospital", "type": "select"},
|
||||||
{'name': 'department', 'label': 'Department', 'type': 'select'},
|
{"name": "department", "label": "Department", "type": "select"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_queryset(cls, data_source):
|
def get_queryset(cls, data_source, user=None):
|
||||||
"""Get the base queryset for a data source."""
|
"""Get the base queryset for a data source."""
|
||||||
from apps.complaints.models import Complaint
|
from apps.complaints.models import Complaint
|
||||||
from apps.observations.models import Observation
|
from apps.observations.models import Observation
|
||||||
@ -183,144 +199,159 @@ class ReportBuilderService:
|
|||||||
from apps.physicians.models import PhysicianMonthlyRating
|
from apps.physicians.models import PhysicianMonthlyRating
|
||||||
|
|
||||||
querysets = {
|
querysets = {
|
||||||
'complaints': Complaint.objects.all(),
|
"complaints": Complaint.objects.all(),
|
||||||
'inquiries': Complaint.objects.filter(complaint_type='inquiry'),
|
"inquiries": Complaint.objects.filter(complaint_type="inquiry"),
|
||||||
'observations': Observation.objects.all(),
|
"observations": Observation.objects.all(),
|
||||||
'px_actions': PXAction.objects.all(),
|
"px_actions": PXAction.objects.all(),
|
||||||
'surveys': SurveyInstance.objects.all(),
|
"surveys": SurveyInstance.objects.all(),
|
||||||
'physicians': PhysicianMonthlyRating.objects.all(),
|
"physicians": PhysicianMonthlyRating.objects.all(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return querysets.get(data_source)
|
queryset = querysets.get(data_source)
|
||||||
|
if queryset is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if user and user.is_authenticated:
|
||||||
|
if not user.is_px_admin():
|
||||||
|
hospital = getattr(user, "_tenant_hospital_cache", None) or getattr(user, "hospital", None)
|
||||||
|
if hospital:
|
||||||
|
if data_source == "observations":
|
||||||
|
queryset = queryset.filter(assigned_department__hospital=hospital)
|
||||||
|
elif data_source == "surveys":
|
||||||
|
queryset = queryset.filter(journey__hospital=hospital)
|
||||||
|
else:
|
||||||
|
queryset = queryset.filter(hospital=hospital)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def apply_filters(cls, queryset, filters, data_source):
|
def apply_filters(cls, queryset, filters, data_source):
|
||||||
"""Apply filters to a queryset."""
|
"""Apply filters to a queryset."""
|
||||||
# Date range filter
|
# Date range filter
|
||||||
if 'date_range' in filters:
|
if "date_range" in filters:
|
||||||
date_range = filters['date_range']
|
date_range = filters["date_range"]
|
||||||
date_field = 'created_at'
|
date_field = "created_at"
|
||||||
|
|
||||||
if date_range == '7d':
|
if date_range == "7d":
|
||||||
start_date = timezone.now() - timedelta(days=7)
|
start_date = timezone.now() - timedelta(days=7)
|
||||||
elif date_range == '30d':
|
elif date_range == "30d":
|
||||||
start_date = timezone.now() - timedelta(days=30)
|
start_date = timezone.now() - timedelta(days=30)
|
||||||
elif date_range == '90d':
|
elif date_range == "90d":
|
||||||
start_date = timezone.now() - timedelta(days=90)
|
start_date = timezone.now() - timedelta(days=90)
|
||||||
elif date_range == 'ytd':
|
elif date_range == "ytd":
|
||||||
start_date = timezone.now().replace(month=1, day=1)
|
start_date = timezone.now().replace(month=1, day=1)
|
||||||
elif date_range == 'custom' and 'start_date' in filters and 'end_date' in filters:
|
elif date_range == "custom" and "start_date" in filters and "end_date" in filters:
|
||||||
start_date = filters['start_date']
|
start_date = filters["start_date"]
|
||||||
end_date = filters['end_date']
|
end_date = filters["end_date"]
|
||||||
queryset = queryset.filter(**{f'{date_field}__gte': start_date, f'{date_field}__lte': end_date})
|
queryset = queryset.filter(**{f"{date_field}__gte": start_date, f"{date_field}__lte": end_date})
|
||||||
return queryset
|
return queryset
|
||||||
else:
|
else:
|
||||||
start_date = timezone.now() - timedelta(days=30)
|
start_date = timezone.now() - timedelta(days=30)
|
||||||
|
|
||||||
queryset = queryset.filter(**{f'{date_field}__gte': start_date})
|
queryset = queryset.filter(**{f"{date_field}__gte": start_date})
|
||||||
|
|
||||||
# Hospital filter
|
# Hospital filter
|
||||||
if 'hospital' in filters and filters['hospital']:
|
if "hospital" in filters and filters["hospital"]:
|
||||||
queryset = queryset.filter(hospital_id=filters['hospital'])
|
queryset = queryset.filter(hospital_id=filters["hospital"])
|
||||||
|
|
||||||
# Department filter
|
# Department filter
|
||||||
if 'department' in filters and filters['department']:
|
if "department" in filters and filters["department"]:
|
||||||
if data_source == 'observations':
|
if data_source == "observations":
|
||||||
queryset = queryset.filter(assigned_department_id=filters['department'])
|
queryset = queryset.filter(assigned_department_id=filters["department"])
|
||||||
elif data_source == 'surveys':
|
elif data_source == "surveys":
|
||||||
queryset = queryset.filter(journey__department_id=filters['department'])
|
queryset = queryset.filter(journey__department_id=filters["department"])
|
||||||
else:
|
else:
|
||||||
queryset = queryset.filter(department_id=filters['department'])
|
queryset = queryset.filter(department_id=filters["department"])
|
||||||
|
|
||||||
# Section filter
|
# Section filter
|
||||||
if 'section' in filters and filters['section']:
|
if "section" in filters and filters["section"]:
|
||||||
queryset = queryset.filter(section_id=filters['section'])
|
queryset = queryset.filter(section_id=filters["section"])
|
||||||
|
|
||||||
# Status filter
|
# Status filter
|
||||||
if 'status' in filters and filters['status']:
|
if "status" in filters and filters["status"]:
|
||||||
if isinstance(filters['status'], list):
|
if isinstance(filters["status"], list):
|
||||||
queryset = queryset.filter(status__in=filters['status'])
|
queryset = queryset.filter(status__in=filters["status"])
|
||||||
else:
|
else:
|
||||||
queryset = queryset.filter(status=filters['status'])
|
queryset = queryset.filter(status=filters["status"])
|
||||||
|
|
||||||
# Severity filter
|
# Severity filter
|
||||||
if 'severity' in filters and filters['severity']:
|
if "severity" in filters and filters["severity"]:
|
||||||
if isinstance(filters['severity'], list):
|
if isinstance(filters["severity"], list):
|
||||||
queryset = queryset.filter(severity__in=filters['severity'])
|
queryset = queryset.filter(severity__in=filters["severity"])
|
||||||
else:
|
else:
|
||||||
queryset = queryset.filter(severity=filters['severity'])
|
queryset = queryset.filter(severity=filters["severity"])
|
||||||
|
|
||||||
# Priority filter
|
# Priority filter
|
||||||
if 'priority' in filters and filters['priority']:
|
if "priority" in filters and filters["priority"]:
|
||||||
if isinstance(filters['priority'], list):
|
if isinstance(filters["priority"], list):
|
||||||
queryset = queryset.filter(priority__in=filters['priority'])
|
queryset = queryset.filter(priority__in=filters["priority"])
|
||||||
else:
|
else:
|
||||||
queryset = queryset.filter(priority=filters['priority'])
|
queryset = queryset.filter(priority=filters["priority"])
|
||||||
|
|
||||||
# Source filter (for complaints)
|
# Source filter (for complaints)
|
||||||
if 'source' in filters and filters['source']:
|
if "source" in filters and filters["source"]:
|
||||||
if isinstance(filters['source'], list):
|
if isinstance(filters["source"], list):
|
||||||
queryset = queryset.filter(complaint_source_type__in=filters['source'])
|
queryset = queryset.filter(complaint_source_type__in=filters["source"])
|
||||||
else:
|
else:
|
||||||
queryset = queryset.filter(complaint_source_type=filters['source'])
|
queryset = queryset.filter(complaint_source_type=filters["source"])
|
||||||
|
|
||||||
# Is overdue filter
|
# Is overdue filter
|
||||||
if 'is_overdue' in filters:
|
if "is_overdue" in filters:
|
||||||
if filters['is_overdue'] == 'true' or filters['is_overdue'] is True:
|
if filters["is_overdue"] == "true" or filters["is_overdue"] is True:
|
||||||
queryset = queryset.filter(is_overdue=True)
|
queryset = queryset.filter(is_overdue=True)
|
||||||
elif filters['is_overdue'] == 'false' or filters['is_overdue'] is False:
|
elif filters["is_overdue"] == "false" or filters["is_overdue"] is False:
|
||||||
queryset = queryset.filter(is_overdue=False)
|
queryset = queryset.filter(is_overdue=False)
|
||||||
|
|
||||||
# Is negative filter (for surveys)
|
# Is negative filter (for surveys)
|
||||||
if 'is_negative' in filters:
|
if "is_negative" in filters:
|
||||||
if filters['is_negative'] == 'true' or filters['is_negative'] is True:
|
if filters["is_negative"] == "true" or filters["is_negative"] is True:
|
||||||
queryset = queryset.filter(is_negative=True)
|
queryset = queryset.filter(is_negative=True)
|
||||||
elif filters['is_negative'] == 'false' or filters['is_negative'] is False:
|
elif filters["is_negative"] == "false" or filters["is_negative"] is False:
|
||||||
queryset = queryset.filter(is_negative=False)
|
queryset = queryset.filter(is_negative=False)
|
||||||
|
|
||||||
# Journey type filter
|
# Journey type filter
|
||||||
if 'journey_type' in filters and filters['journey_type']:
|
if "journey_type" in filters and filters["journey_type"]:
|
||||||
if data_source == 'complaints':
|
if data_source == "complaints":
|
||||||
queryset = queryset.filter(journey__journey_type=filters['journey_type'])
|
queryset = queryset.filter(journey__journey_type=filters["journey_type"])
|
||||||
elif data_source == 'surveys':
|
elif data_source == "surveys":
|
||||||
queryset = queryset.filter(journey__journey_type=filters['journey_type'])
|
queryset = queryset.filter(journey__journey_type=filters["journey_type"])
|
||||||
|
|
||||||
# Patient type filter
|
# Patient type filter
|
||||||
if 'patient_type' in filters and filters['patient_type']:
|
if "patient_type" in filters and filters["patient_type"]:
|
||||||
queryset = queryset.filter(journey__patient_type=filters['patient_type'])
|
queryset = queryset.filter(journey__patient_type=filters["patient_type"])
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def apply_grouping(cls, queryset, grouping_config, data_source):
|
def apply_grouping(cls, queryset, grouping_config, data_source):
|
||||||
"""Apply grouping and aggregation to a queryset."""
|
"""Apply grouping and aggregation to a queryset."""
|
||||||
if not grouping_config or 'field' not in grouping_config:
|
if not grouping_config or "field" not in grouping_config:
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
field = grouping_config['field']
|
field = grouping_config["field"]
|
||||||
aggregation = grouping_config.get('aggregation', 'count')
|
aggregation = grouping_config.get("aggregation", "count")
|
||||||
|
|
||||||
# Determine truncation for date fields
|
# Determine truncation for date fields
|
||||||
if 'created_at' in field or 'date' in field.lower():
|
if "created_at" in field or "date" in field.lower():
|
||||||
trunc_by = grouping_config.get('trunc_by', 'day')
|
trunc_by = grouping_config.get("trunc_by", "day")
|
||||||
if trunc_by == 'day':
|
if trunc_by == "day":
|
||||||
queryset = queryset.annotate(period=TruncDate(field))
|
queryset = queryset.annotate(period=TruncDate(field))
|
||||||
elif trunc_by == 'week':
|
elif trunc_by == "week":
|
||||||
queryset = queryset.annotate(period=TruncWeek(field))
|
queryset = queryset.annotate(period=TruncWeek(field))
|
||||||
elif trunc_by == 'month':
|
elif trunc_by == "month":
|
||||||
queryset = queryset.annotate(period=TruncMonth(field))
|
queryset = queryset.annotate(period=TruncMonth(field))
|
||||||
elif trunc_by == 'year':
|
elif trunc_by == "year":
|
||||||
queryset = queryset.annotate(period=TruncYear(field))
|
queryset = queryset.annotate(period=TruncYear(field))
|
||||||
field = 'period'
|
field = "period"
|
||||||
|
|
||||||
# Apply aggregation
|
# Apply aggregation
|
||||||
if aggregation == 'count':
|
if aggregation == "count":
|
||||||
return queryset.values(field).annotate(count=Count('id')).order_by(field)
|
return queryset.values(field).annotate(count=Count("id")).order_by(field)
|
||||||
elif aggregation == 'sum':
|
elif aggregation == "sum":
|
||||||
sum_field = grouping_config.get('sum_field', 'id')
|
sum_field = grouping_config.get("sum_field", "id")
|
||||||
return queryset.values(field).annotate(total=Sum(sum_field)).order_by(field)
|
return queryset.values(field).annotate(total=Sum(sum_field)).order_by(field)
|
||||||
elif aggregation == 'avg':
|
elif aggregation == "avg":
|
||||||
avg_field = grouping_config.get('avg_field', 'total_score')
|
avg_field = grouping_config.get("avg_field", "total_score")
|
||||||
return queryset.values(field).annotate(average=Avg(avg_field)).order_by(field)
|
return queryset.values(field).annotate(average=Avg(avg_field)).order_by(field)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
@ -328,7 +359,7 @@ class ReportBuilderService:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_field_value(cls, obj, field_path):
|
def get_field_value(cls, obj, field_path):
|
||||||
"""Get a value from an object using dot notation."""
|
"""Get a value from an object using dot notation."""
|
||||||
parts = field_path.split('__')
|
parts = field_path.split("__")
|
||||||
value = obj
|
value = obj
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if value is None:
|
if value is None:
|
||||||
@ -343,29 +374,31 @@ class ReportBuilderService:
|
|||||||
def format_value(cls, value, field_type):
|
def format_value(cls, value, field_type):
|
||||||
"""Format a value for display."""
|
"""Format a value for display."""
|
||||||
if value is None:
|
if value is None:
|
||||||
return ''
|
return ""
|
||||||
|
|
||||||
if field_type == 'datetime':
|
if field_type == "datetime":
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
return value.strftime('%Y-%m-%d %H:%M')
|
return value.strftime("%Y-%m-%d %H:%M")
|
||||||
elif field_type == 'date':
|
elif field_type == "date":
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
return value.strftime('%Y-%m-%d')
|
return value.strftime("%Y-%m-%d")
|
||||||
elif field_type == 'boolean':
|
elif field_type == "boolean":
|
||||||
return 'Yes' if value else 'No'
|
return "Yes" if value else "No"
|
||||||
elif field_type == 'number':
|
elif field_type == "number":
|
||||||
if isinstance(value, (int, float)):
|
if isinstance(value, (int, float)):
|
||||||
return round(value, 2) if isinstance(value, float) else value
|
return round(value, 2) if isinstance(value, float) else value
|
||||||
return value
|
return value
|
||||||
|
|
||||||
return str(value) if value else ''
|
return str(value) if value else ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def generate_report_data(cls, data_source, filter_config, column_config, grouping_config, sort_config=None):
|
def generate_report_data(
|
||||||
|
cls, data_source, filter_config, column_config, grouping_config, sort_config=None, user=None
|
||||||
|
):
|
||||||
"""Generate report data with filters, columns, and grouping."""
|
"""Generate report data with filters, columns, and grouping."""
|
||||||
queryset = cls.get_queryset(data_source)
|
queryset = cls.get_queryset(data_source, user=user)
|
||||||
queryset = cls.apply_filters(queryset, filter_config, data_source)
|
queryset = cls.apply_filters(queryset, filter_config, data_source)
|
||||||
|
|
||||||
# Determine columns to select
|
# Determine columns to select
|
||||||
@ -374,7 +407,7 @@ class ReportBuilderService:
|
|||||||
|
|
||||||
fields_info = cls.SOURCE_FIELDS.get(data_source, {})
|
fields_info = cls.SOURCE_FIELDS.get(data_source, {})
|
||||||
|
|
||||||
if grouping_config and 'field' in grouping_config:
|
if grouping_config and "field" in grouping_config:
|
||||||
# Grouped data
|
# Grouped data
|
||||||
grouped_data = cls.apply_grouping(queryset, grouping_config, data_source)
|
grouped_data = cls.apply_grouping(queryset, grouping_config, data_source)
|
||||||
|
|
||||||
@ -382,30 +415,30 @@ class ReportBuilderService:
|
|||||||
for item in grouped_data:
|
for item in grouped_data:
|
||||||
row = {}
|
row = {}
|
||||||
for key, value in item.items():
|
for key, value in item.items():
|
||||||
row[key] = cls.format_value(value, 'number' if key == 'count' else 'string')
|
row[key] = cls.format_value(value, "number" if key == "count" else "string")
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'rows': rows,
|
"rows": rows,
|
||||||
'columns': list(grouped_data[0].keys()) if grouped_data else ['field', 'count'],
|
"columns": list(grouped_data[0].keys()) if grouped_data else ["field", "count"],
|
||||||
'grouped': True,
|
"grouped": True,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Regular data
|
# Regular data
|
||||||
select_fields = []
|
select_fields = []
|
||||||
for col in column_config:
|
for col in column_config:
|
||||||
if col in fields_info:
|
if col in fields_info:
|
||||||
select_fields.append(fields_info[col]['field'])
|
select_fields.append(fields_info[col]["field"])
|
||||||
|
|
||||||
# Apply sorting
|
# Apply sorting
|
||||||
if sort_config:
|
if sort_config:
|
||||||
for sort_item in sort_config:
|
for sort_item in sort_config:
|
||||||
field = sort_item.get('field')
|
field = sort_item.get("field")
|
||||||
direction = sort_item.get('direction', 'asc')
|
direction = sort_item.get("direction", "asc")
|
||||||
if field in fields_info:
|
if field in fields_info:
|
||||||
order_field = fields_info[field]['field']
|
order_field = fields_info[field]["field"]
|
||||||
if direction == 'desc':
|
if direction == "desc":
|
||||||
order_field = f'-{order_field}'
|
order_field = f"-{order_field}"
|
||||||
queryset = queryset.order_by(order_field)
|
queryset = queryset.order_by(order_field)
|
||||||
|
|
||||||
# Limit results for performance
|
# Limit results for performance
|
||||||
@ -417,65 +450,63 @@ class ReportBuilderService:
|
|||||||
for col in column_config:
|
for col in column_config:
|
||||||
if col in fields_info:
|
if col in fields_info:
|
||||||
field_info = fields_info[col]
|
field_info = fields_info[col]
|
||||||
value = cls.get_field_value(obj, field_info['field'])
|
value = cls.get_field_value(obj, field_info["field"])
|
||||||
row[col] = cls.format_value(value, field_info['type'])
|
row[col] = cls.format_value(value, field_info["type"])
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
|
|
||||||
# Return both keys (for data access) and labels (for display)
|
# Return both keys (for data access) and labels (for display)
|
||||||
column_labels = [fields_info.get(col, {'label': col})['label'] for col in column_config]
|
column_labels = [fields_info.get(col, {"label": col})["label"] for col in column_config]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'rows': rows,
|
"rows": rows,
|
||||||
'columns': column_labels,
|
"columns": column_labels,
|
||||||
'column_keys': column_config, # Add field keys for data access
|
"column_keys": column_config, # Add field keys for data access
|
||||||
'grouped': False,
|
"grouped": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def generate_summary(cls, data_source, filter_config):
|
def generate_summary(cls, data_source, filter_config, user=None):
|
||||||
"""Generate summary statistics for a data source."""
|
"""Generate summary statistics for a data source."""
|
||||||
queryset = cls.get_queryset(data_source)
|
queryset = cls.get_queryset(data_source, user=user)
|
||||||
queryset = cls.apply_filters(queryset, filter_config, data_source)
|
queryset = cls.apply_filters(queryset, filter_config, data_source)
|
||||||
|
|
||||||
summary = {
|
summary = {
|
||||||
'total_count': queryset.count(),
|
"total_count": queryset.count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if data_source == 'complaints':
|
if data_source == "complaints":
|
||||||
summary['open_count'] = queryset.filter(status='open').count()
|
summary["open_count"] = queryset.filter(status="open").count()
|
||||||
summary['resolved_count'] = queryset.filter(status='resolved').count()
|
summary["resolved_count"] = queryset.filter(status="resolved").count()
|
||||||
summary['overdue_count'] = queryset.filter(is_overdue=True).count()
|
summary["overdue_count"] = queryset.filter(is_overdue=True).count()
|
||||||
# Calculate average resolution time in hours (SQLite-compatible)
|
# Calculate average resolution time in hours (SQLite-compatible)
|
||||||
resolved_complaints = queryset.filter(resolved_at__isnull=False)
|
resolved_complaints = queryset.filter(resolved_at__isnull=False)
|
||||||
if resolved_complaints.exists():
|
if resolved_complaints.exists():
|
||||||
# Calculate in Python to avoid SQLite DurationField limitation
|
# Calculate in Python to avoid SQLite DurationField limitation
|
||||||
total_hours = 0
|
total_hours = 0
|
||||||
count = 0
|
count = 0
|
||||||
for complaint in resolved_complaints.values('created_at', 'resolved_at'):
|
for complaint in resolved_complaints.values("created_at", "resolved_at"):
|
||||||
if complaint['created_at'] and complaint['resolved_at']:
|
if complaint["created_at"] and complaint["resolved_at"]:
|
||||||
delta = complaint['resolved_at'] - complaint['created_at']
|
delta = complaint["resolved_at"] - complaint["created_at"]
|
||||||
total_hours += delta.total_seconds() / 3600.0
|
total_hours += delta.total_seconds() / 3600.0
|
||||||
count += 1
|
count += 1
|
||||||
summary['avg_resolution_time'] = round(total_hours / count, 2) if count > 0 else 0
|
summary["avg_resolution_time"] = round(total_hours / count, 2) if count > 0 else 0
|
||||||
else:
|
else:
|
||||||
summary['avg_resolution_time'] = 0
|
summary["avg_resolution_time"] = 0
|
||||||
|
|
||||||
elif data_source == 'surveys':
|
elif data_source == "surveys":
|
||||||
summary['completed_count'] = queryset.filter(status='completed').count()
|
summary["completed_count"] = queryset.filter(status="completed").count()
|
||||||
summary['pending_count'] = queryset.filter(status='pending').count()
|
summary["pending_count"] = queryset.filter(status="pending").count()
|
||||||
summary['negative_count'] = queryset.filter(is_negative=True).count()
|
summary["negative_count"] = queryset.filter(is_negative=True).count()
|
||||||
summary['avg_score'] = queryset.filter(
|
summary["avg_score"] = queryset.filter(status="completed").aggregate(avg=Avg("total_score"))["avg"] or 0
|
||||||
status='completed'
|
|
||||||
).aggregate(avg=Avg('total_score'))['avg'] or 0
|
|
||||||
|
|
||||||
elif data_source == 'px_actions':
|
elif data_source == "px_actions":
|
||||||
summary['open_count'] = queryset.filter(status='open').count()
|
summary["open_count"] = queryset.filter(status="open").count()
|
||||||
summary['completed_count'] = queryset.filter(status='completed').count()
|
summary["completed_count"] = queryset.filter(status="completed").count()
|
||||||
summary['overdue_count'] = queryset.filter(is_overdue=True).count()
|
summary["overdue_count"] = queryset.filter(is_overdue=True).count()
|
||||||
|
|
||||||
elif data_source == 'observations':
|
elif data_source == "observations":
|
||||||
summary['new_count'] = queryset.filter(status='new').count()
|
summary["new_count"] = queryset.filter(status="new").count()
|
||||||
summary['resolved_count'] = queryset.filter(status='resolved').count()
|
summary["resolved_count"] = queryset.filter(status="resolved").count()
|
||||||
|
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
@ -484,7 +515,7 @@ class ReportExportService:
|
|||||||
"""Service for exporting reports to various formats."""
|
"""Service for exporting reports to various formats."""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def export_to_csv(cls, data, columns, column_keys=None, filename='report'):
|
def export_to_csv(cls, data, columns, column_keys=None, filename="report"):
|
||||||
"""Export report data to CSV.
|
"""Export report data to CSV.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -493,8 +524,8 @@ class ReportExportService:
|
|||||||
column_keys: List of column keys (for data access). If None, uses columns.
|
column_keys: List of column keys (for data access). If None, uses columns.
|
||||||
filename: Output filename without extension
|
filename: Output filename without extension
|
||||||
"""
|
"""
|
||||||
response = HttpResponse(content_type='text/csv')
|
response = HttpResponse(content_type="text/csv")
|
||||||
response['Content-Disposition'] = f'attachment; filename="{filename}.csv"'
|
response["Content-Disposition"] = f'attachment; filename="{filename}.csv"'
|
||||||
|
|
||||||
writer = csv.writer(response)
|
writer = csv.writer(response)
|
||||||
writer.writerow(columns) # Write header row with labels
|
writer.writerow(columns) # Write header row with labels
|
||||||
@ -503,12 +534,12 @@ class ReportExportService:
|
|||||||
keys = column_keys if column_keys else columns
|
keys = column_keys if column_keys else columns
|
||||||
|
|
||||||
for row in data:
|
for row in data:
|
||||||
writer.writerow([row.get(key, '') for key in keys])
|
writer.writerow([row.get(key, "") for key in keys])
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def export_to_excel(cls, data, columns, column_keys=None, filename='report'):
|
def export_to_excel(cls, data, columns, column_keys=None, filename="report"):
|
||||||
"""Export report data to Excel (XLSX).
|
"""Export report data to Excel (XLSX).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -527,17 +558,17 @@ class ReportExportService:
|
|||||||
|
|
||||||
wb = openpyxl.Workbook()
|
wb = openpyxl.Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = 'Report'
|
ws.title = "Report"
|
||||||
|
|
||||||
# Header row
|
# Header row
|
||||||
header_fill = PatternFill(start_color='1F4E79', end_color='1F4E79', fill_type='solid')
|
header_fill = PatternFill(start_color="1F4E79", end_color="1F4E79", fill_type="solid")
|
||||||
header_font = Font(bold=True, color='FFFFFF')
|
header_font = Font(bold=True, color="FFFFFF")
|
||||||
|
|
||||||
for col_idx, col_name in enumerate(columns, 1):
|
for col_idx, col_name in enumerate(columns, 1):
|
||||||
cell = ws.cell(row=1, column=col_idx, value=col_name)
|
cell = ws.cell(row=1, column=col_idx, value=col_name)
|
||||||
cell.fill = header_fill
|
cell.fill = header_fill
|
||||||
cell.font = header_font
|
cell.font = header_font
|
||||||
cell.alignment = Alignment(horizontal='center')
|
cell.alignment = Alignment(horizontal="center")
|
||||||
|
|
||||||
# Use column_keys for data access if provided, otherwise use columns
|
# Use column_keys for data access if provided, otherwise use columns
|
||||||
keys = column_keys if column_keys else columns
|
keys = column_keys if column_keys else columns
|
||||||
@ -545,8 +576,8 @@ class ReportExportService:
|
|||||||
# Data rows
|
# Data rows
|
||||||
for row_idx, row_data in enumerate(data, 2):
|
for row_idx, row_data in enumerate(data, 2):
|
||||||
for col_idx, key in enumerate(keys, 1):
|
for col_idx, key in enumerate(keys, 1):
|
||||||
value = row_data.get(key, '')
|
value = row_data.get(key, "")
|
||||||
ws.cell(row=row_idx, column=col_idx, value=str(value) if value else '')
|
ws.cell(row=row_idx, column=col_idx, value=str(value) if value else "")
|
||||||
|
|
||||||
# Auto-adjust column widths
|
# Auto-adjust column widths
|
||||||
for col_idx, col_name in enumerate(columns, 1):
|
for col_idx, col_name in enumerate(columns, 1):
|
||||||
@ -558,16 +589,14 @@ class ReportExportService:
|
|||||||
ws.column_dimensions[get_column_letter(col_idx)].width = min(max_length + 2, 50)
|
ws.column_dimensions[get_column_letter(col_idx)].width = min(max_length + 2, 50)
|
||||||
|
|
||||||
# Create response
|
# Create response
|
||||||
response = HttpResponse(
|
response = HttpResponse(content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
response["Content-Disposition"] = f'attachment; filename="{filename}.xlsx"'
|
||||||
)
|
|
||||||
response['Content-Disposition'] = f'attachment; filename="{filename}.xlsx"'
|
|
||||||
|
|
||||||
wb.save(response)
|
wb.save(response)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def export_to_pdf(cls, data, columns, column_keys=None, title='Report', filename='report'):
|
def export_to_pdf(cls, data, columns, column_keys=None, title="Report", filename="report"):
|
||||||
"""Export report data to PDF.
|
"""Export report data to PDF.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -585,21 +614,24 @@ class ReportExportService:
|
|||||||
# Prepare data with proper column access
|
# Prepare data with proper column access
|
||||||
formatted_data = []
|
formatted_data = []
|
||||||
for row in data:
|
for row in data:
|
||||||
formatted_row = {col: row.get(key, '') for col, key in zip(columns, keys)}
|
formatted_row = {col: row.get(key, "") for col, key in zip(columns, keys)}
|
||||||
formatted_data.append(formatted_row)
|
formatted_data.append(formatted_row)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from weasyprint import HTML
|
from weasyprint import HTML
|
||||||
|
|
||||||
html_content = render_to_string('reports/report_pdf.html', {
|
html_content = render_to_string(
|
||||||
'title': title,
|
"reports/report_pdf.html",
|
||||||
'columns': columns,
|
{
|
||||||
'data': formatted_data,
|
"title": title,
|
||||||
'generated_at': timezone.now(),
|
"columns": columns,
|
||||||
})
|
"data": formatted_data,
|
||||||
|
"generated_at": timezone.now(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
response = HttpResponse(content_type='application/pdf')
|
response = HttpResponse(content_type="application/pdf")
|
||||||
response['Content-Disposition'] = f'attachment; filename="{filename}.pdf"'
|
response["Content-Disposition"] = f'attachment; filename="{filename}.pdf"'
|
||||||
|
|
||||||
HTML(string=html_content).write_pdf(response)
|
HTML(string=html_content).write_pdf(response)
|
||||||
return response
|
return response
|
||||||
@ -607,3 +639,94 @@ class ReportExportService:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
# Fall back to CSV if weasyprint not available
|
# Fall back to CSV if weasyprint not available
|
||||||
return cls.export_to_csv(data, columns, column_keys, filename)
|
return cls.export_to_csv(data, columns, column_keys, filename)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_chart_data(cls, data, chart_config):
|
||||||
|
"""
|
||||||
|
Generate chart data structure for visualization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: List of dictionaries with report data
|
||||||
|
chart_config: Dict with chart configuration
|
||||||
|
- type: ChartType (bar, line, pie, donut, area)
|
||||||
|
- x_axis: Field name for x-axis categories
|
||||||
|
- y_axis: Field name for y-axis values
|
||||||
|
- title: Chart title
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with chart data ready for rendering
|
||||||
|
"""
|
||||||
|
if not data or not chart_config:
|
||||||
|
return None
|
||||||
|
|
||||||
|
chart_type = chart_config.get("type", "bar")
|
||||||
|
x_field = chart_config.get("x_axis")
|
||||||
|
y_field = chart_config.get("y_axis")
|
||||||
|
title = chart_config.get("title", "Chart")
|
||||||
|
|
||||||
|
if not x_field or not y_field:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Aggregate data by x_field
|
||||||
|
aggregated = {}
|
||||||
|
for row in data:
|
||||||
|
x_val = row.get(x_field, "Unknown")
|
||||||
|
y_val = row.get(y_field, 0)
|
||||||
|
if x_val not in aggregated:
|
||||||
|
aggregated[x_val] = 0
|
||||||
|
try:
|
||||||
|
aggregated[x_val] += float(y_val) if y_val else 0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
aggregated[x_val] += 1 # Count if not numeric
|
||||||
|
|
||||||
|
# Sort by value descending for pie/donut, by key for others
|
||||||
|
if chart_type in ["pie", "donut"]:
|
||||||
|
sorted_items = sorted(aggregated.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
else:
|
||||||
|
sorted_items = sorted(aggregated.items())
|
||||||
|
|
||||||
|
labels = [str(item[0]) for item in sorted_items]
|
||||||
|
values = [item[1] for item in sorted_items]
|
||||||
|
|
||||||
|
# Generate colors
|
||||||
|
colors = cls._generate_chart_colors(len(labels))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": chart_type,
|
||||||
|
"title": title,
|
||||||
|
"labels": labels,
|
||||||
|
"datasets": [
|
||||||
|
{"label": y_field, "data": values, "backgroundColor": colors, "borderColor": colors, "borderWidth": 1}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _generate_chart_colors(cls, count):
|
||||||
|
"""Generate a list of colors for charts."""
|
||||||
|
base_colors = [
|
||||||
|
"#005696",
|
||||||
|
"#007bbd",
|
||||||
|
"#00a8e8",
|
||||||
|
"#00d4ff",
|
||||||
|
"#10b981",
|
||||||
|
"#34d399",
|
||||||
|
"#059669",
|
||||||
|
"#047857",
|
||||||
|
"#f59e0b",
|
||||||
|
"#fbbf24",
|
||||||
|
"#d97706",
|
||||||
|
"#b45309",
|
||||||
|
"#ef4444",
|
||||||
|
"#f87171",
|
||||||
|
"#dc2626",
|
||||||
|
"#b91c1c",
|
||||||
|
"#8b5cf6",
|
||||||
|
"#a78bfa",
|
||||||
|
"#7c3aed",
|
||||||
|
"#6d28d9",
|
||||||
|
]
|
||||||
|
# Repeat colors if more needed
|
||||||
|
colors = []
|
||||||
|
for i in range(count):
|
||||||
|
colors.append(base_colors[i % len(base_colors)])
|
||||||
|
return colors
|
||||||
|
|||||||
@ -4,6 +4,7 @@ Report Builder UI Views - Simplified Version
|
|||||||
Handles the visual report builder interface, saved reports,
|
Handles the visual report builder interface, saved reports,
|
||||||
and exports. No chart functionality.
|
and exports. No chart functionality.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
@ -15,10 +16,7 @@ from django.utils import timezone
|
|||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
|
|
||||||
from apps.organizations.models import Department, Hospital
|
from apps.organizations.models import Department, Hospital
|
||||||
from .models import (
|
from .models import SavedReport, GeneratedReport, ReportTemplate, DataSource, ReportFormat
|
||||||
SavedReport, GeneratedReport, ReportTemplate,
|
|
||||||
DataSource, ReportFormat
|
|
||||||
)
|
|
||||||
from .services import ReportBuilderService, ReportExportService
|
from .services import ReportBuilderService, ReportExportService
|
||||||
|
|
||||||
|
|
||||||
@ -37,22 +35,21 @@ def report_builder(request):
|
|||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
# Get hospitals for filter
|
# Get hospitals for filter
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospital = getattr(request, "tenant_hospital", None) or user.hospital
|
||||||
if not user.is_px_admin() and user.hospital:
|
hospitals = Hospital.objects.filter(status="active")
|
||||||
hospitals = hospitals.filter(id=user.hospital.id)
|
if not user.is_px_admin() and hospital:
|
||||||
|
hospitals = hospitals.filter(id=hospital.id)
|
||||||
|
|
||||||
# Get saved reports
|
# Get saved reports
|
||||||
saved_reports = SavedReport.objects.filter(
|
saved_reports = SavedReport.objects.filter(created_by=user).order_by("-created_at")[:10]
|
||||||
created_by=user
|
|
||||||
).order_by('-created_at')[:10]
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'hospitals': hospitals,
|
"hospitals": hospitals,
|
||||||
'saved_reports': saved_reports,
|
"saved_reports": saved_reports,
|
||||||
'data_sources': DataSource.choices,
|
"data_sources": DataSource.choices,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'reports/report_builder.html', context)
|
return render(request, "reports/report_builder.html", context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -65,25 +62,26 @@ def report_preview_api(request):
|
|||||||
- Summary statistics
|
- Summary statistics
|
||||||
- Chart data
|
- Chart data
|
||||||
"""
|
"""
|
||||||
if request.method != 'POST':
|
if request.method != "POST":
|
||||||
return JsonResponse({'error': 'POST required'}, status=405)
|
return JsonResponse({"error": "POST required"}, status=405)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(request.body)
|
data = json.loads(request.body)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return JsonResponse({'error': 'Invalid JSON'}, status=400)
|
return JsonResponse({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
data_source = data.get('data_source', 'complaints')
|
data_source = data.get("data_source", "complaints")
|
||||||
filter_config = data.get('filter_config', {})
|
filter_config = data.get("filter_config", {})
|
||||||
column_config = data.get('column_config', [])
|
column_config = data.get("column_config", [])
|
||||||
grouping_config = data.get('grouping_config', {})
|
grouping_config = data.get("grouping_config", {})
|
||||||
chart_config = data.get('chart_config', {})
|
chart_config = data.get("chart_config", {})
|
||||||
sort_config = data.get('sort_config', [])
|
sort_config = data.get("sort_config", [])
|
||||||
|
|
||||||
# Apply user's hospital restriction
|
# Apply user's hospital restriction
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_px_admin() and user.hospital:
|
hospital = getattr(request, "tenant_hospital", None) or user.hospital
|
||||||
filter_config['hospital'] = str(user.hospital.id)
|
if not user.is_px_admin() and hospital:
|
||||||
|
filter_config["hospital"] = str(hospital.id)
|
||||||
|
|
||||||
# Generate report data
|
# Generate report data
|
||||||
report_data = ReportBuilderService.generate_report_data(
|
report_data = ReportBuilderService.generate_report_data(
|
||||||
@ -91,64 +89,63 @@ def report_preview_api(request):
|
|||||||
filter_config=filter_config,
|
filter_config=filter_config,
|
||||||
column_config=column_config,
|
column_config=column_config,
|
||||||
grouping_config=grouping_config,
|
grouping_config=grouping_config,
|
||||||
sort_config=sort_config
|
sort_config=sort_config,
|
||||||
|
user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate summary
|
# Generate summary
|
||||||
summary = ReportBuilderService.generate_summary(data_source, filter_config)
|
summary = ReportBuilderService.generate_summary(data_source, filter_config, user=user)
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse(
|
||||||
'success': True,
|
{
|
||||||
'data': report_data,
|
"success": True,
|
||||||
'summary': summary,
|
"data": report_data,
|
||||||
})
|
"summary": summary,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def save_report(request):
|
def save_report(request):
|
||||||
"""Save a report configuration."""
|
"""Save a report configuration."""
|
||||||
if request.method != 'POST':
|
if request.method != "POST":
|
||||||
return JsonResponse({'error': 'POST required'}, status=405)
|
return JsonResponse({"error": "POST required"}, status=405)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(request.body)
|
data = json.loads(request.body)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return JsonResponse({'error': 'Invalid JSON'}, status=400)
|
return JsonResponse({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
report_id = data.get('id')
|
report_id = data.get("id")
|
||||||
|
|
||||||
if report_id:
|
if report_id:
|
||||||
# Update existing report
|
# Update existing report
|
||||||
report = get_object_or_404(SavedReport, id=report_id, created_by=request.user)
|
report = get_object_or_404(SavedReport, id=report_id, created_by=request.user)
|
||||||
report.name = data.get('name', report.name)
|
report.name = data.get("name", report.name)
|
||||||
report.description = data.get('description', report.description)
|
report.description = data.get("description", report.description)
|
||||||
report.data_source = data.get('data_source', report.data_source)
|
report.data_source = data.get("data_source", report.data_source)
|
||||||
report.filter_config = data.get('filter_config', report.filter_config)
|
report.filter_config = data.get("filter_config", report.filter_config)
|
||||||
report.column_config = data.get('column_config', report.column_config)
|
report.column_config = data.get("column_config", report.column_config)
|
||||||
report.grouping_config = data.get('grouping_config', report.grouping_config)
|
report.grouping_config = data.get("grouping_config", report.grouping_config)
|
||||||
report.sort_config = data.get('sort_config', report.sort_config)
|
report.sort_config = data.get("sort_config", report.sort_config)
|
||||||
report.is_shared = data.get('is_shared', report.is_shared)
|
report.is_shared = data.get("is_shared", report.is_shared)
|
||||||
report.save()
|
report.save()
|
||||||
else:
|
else:
|
||||||
# Create new report
|
# Create new report
|
||||||
report = SavedReport.objects.create(
|
report = SavedReport.objects.create(
|
||||||
name=data.get('name', 'Untitled Report'),
|
name=data.get("name", "Untitled Report"),
|
||||||
description=data.get('description', ''),
|
description=data.get("description", ""),
|
||||||
data_source=data.get('data_source', 'complaints'),
|
data_source=data.get("data_source", "complaints"),
|
||||||
filter_config=data.get('filter_config', {}),
|
filter_config=data.get("filter_config", {}),
|
||||||
column_config=data.get('column_config', []),
|
column_config=data.get("column_config", []),
|
||||||
grouping_config=data.get('grouping_config', {}),
|
grouping_config=data.get("grouping_config", {}),
|
||||||
sort_config=data.get('sort_config', []),
|
sort_config=data.get("sort_config", []),
|
||||||
is_shared=data.get('is_shared', False),
|
is_shared=data.get("is_shared", False),
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
hospital=request.user.hospital,
|
hospital=request.user.hospital,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({"success": True, "report_id": str(report.id), "message": "Report saved successfully"})
|
||||||
'success': True,
|
|
||||||
'report_id': str(report.id),
|
|
||||||
'message': 'Report saved successfully'
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -157,40 +154,38 @@ def saved_reports_list(request):
|
|||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
# Get user's reports and shared reports
|
# Get user's reports and shared reports
|
||||||
queryset = SavedReport.objects.filter(
|
hospital = getattr(request, "tenant_hospital", None) or user.hospital
|
||||||
created_by=user
|
queryset = SavedReport.objects.filter(created_by=user)
|
||||||
) | SavedReport.objects.filter(
|
if hospital:
|
||||||
is_shared=True,
|
queryset = queryset | SavedReport.objects.filter(is_shared=True, hospital=hospital)
|
||||||
hospital=user.hospital
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove duplicates and order
|
# Remove duplicates and order
|
||||||
queryset = queryset.distinct().order_by('-created_at')
|
queryset = queryset.distinct().order_by("-created_at")
|
||||||
|
|
||||||
# Filter by data source
|
# Filter by data source
|
||||||
data_source = request.GET.get('data_source')
|
data_source = request.GET.get("data_source")
|
||||||
if data_source:
|
if data_source:
|
||||||
queryset = queryset.filter(data_source=data_source)
|
queryset = queryset.filter(data_source=data_source)
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search = request.GET.get('search', '')
|
search = request.GET.get("search", "")
|
||||||
if search:
|
if search:
|
||||||
queryset = queryset.filter(name__icontains=search)
|
queryset = queryset.filter(name__icontains=search)
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
paginator = Paginator(queryset, 25)
|
paginator = Paginator(queryset, 25)
|
||||||
page_number = request.GET.get('page', 1)
|
page_number = request.GET.get("page", 1)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'page_obj': page_obj,
|
"page_obj": page_obj,
|
||||||
'reports': page_obj.object_list,
|
"reports": page_obj.object_list,
|
||||||
'data_sources': DataSource.choices,
|
"data_sources": DataSource.choices,
|
||||||
'search': search,
|
"search": search,
|
||||||
'selected_source': data_source,
|
"selected_source": data_source,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'reports/saved_reports.html', context)
|
return render(request, "reports/saved_reports.html", context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -201,15 +196,16 @@ def report_detail(request, report_id):
|
|||||||
report = get_object_or_404(SavedReport, id=report_id)
|
report = get_object_or_404(SavedReport, id=report_id)
|
||||||
|
|
||||||
# Check access
|
# Check access
|
||||||
if report.created_by != user and not (report.is_shared and report.hospital == user.hospital):
|
hospital = getattr(request, "tenant_hospital", None) or user.hospital
|
||||||
|
if report.created_by != user and not (report.is_shared and report.hospital == hospital):
|
||||||
if not user.is_px_admin():
|
if not user.is_px_admin():
|
||||||
messages.error(request, "You don't have access to this report.")
|
messages.error(request, "You don't have access to this report.")
|
||||||
return redirect('reports:saved_reports')
|
return redirect("reports:saved_reports")
|
||||||
|
|
||||||
# Apply user's hospital restriction
|
# Apply user's hospital restriction
|
||||||
filter_config = report.filter_config.copy()
|
filter_config = report.filter_config.copy()
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and hospital:
|
||||||
filter_config['hospital'] = str(user.hospital.id)
|
filter_config["hospital"] = str(hospital.id)
|
||||||
|
|
||||||
# Generate report data
|
# Generate report data
|
||||||
report_data = ReportBuilderService.generate_report_data(
|
report_data = ReportBuilderService.generate_report_data(
|
||||||
@ -217,25 +213,26 @@ def report_detail(request, report_id):
|
|||||||
filter_config=filter_config,
|
filter_config=filter_config,
|
||||||
column_config=report.column_config,
|
column_config=report.column_config,
|
||||||
grouping_config=report.grouping_config,
|
grouping_config=report.grouping_config,
|
||||||
sort_config=report.sort_config
|
sort_config=report.sort_config,
|
||||||
|
user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate summary
|
# Generate summary
|
||||||
summary = ReportBuilderService.generate_summary(report.data_source, filter_config)
|
summary = ReportBuilderService.generate_summary(report.data_source, filter_config, user=user)
|
||||||
|
|
||||||
# Update last run
|
# Update last run
|
||||||
report.last_run_at = timezone.now()
|
report.last_run_at = timezone.now()
|
||||||
report.last_run_count = len(report_data.get('rows', []))
|
report.last_run_count = len(report_data.get("rows", []))
|
||||||
report.save(update_fields=['last_run_at', 'last_run_count'])
|
report.save(update_fields=["last_run_at", "last_run_count"])
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'report': report,
|
"report": report,
|
||||||
'data': report_data,
|
"data": report_data,
|
||||||
'summary': summary,
|
"summary": summary,
|
||||||
'source_fields': ReportBuilderService.SOURCE_FIELDS.get(report.data_source, {}),
|
"source_fields": ReportBuilderService.SOURCE_FIELDS.get(report.data_source, {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'reports/report_detail.html', context)
|
return render(request, "reports/report_detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -243,12 +240,12 @@ def delete_report(request, report_id):
|
|||||||
"""Delete a saved report."""
|
"""Delete a saved report."""
|
||||||
report = get_object_or_404(SavedReport, id=report_id, created_by=request.user)
|
report = get_object_or_404(SavedReport, id=report_id, created_by=request.user)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
report.delete()
|
report.delete()
|
||||||
messages.success(request, 'Report deleted successfully.')
|
messages.success(request, "Report deleted successfully.")
|
||||||
return redirect('reports:saved_reports')
|
return redirect("reports:saved_reports")
|
||||||
|
|
||||||
return render(request, 'reports/report_confirm_delete.html', {'report': report})
|
return render(request, "reports/report_confirm_delete.html", {"report": report})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -259,15 +256,16 @@ def export_report(request, report_id, export_format):
|
|||||||
report = get_object_or_404(SavedReport, id=report_id)
|
report = get_object_or_404(SavedReport, id=report_id)
|
||||||
|
|
||||||
# Check access
|
# Check access
|
||||||
if report.created_by != user and not (report.is_shared and report.hospital == user.hospital):
|
hospital = getattr(request, "tenant_hospital", None) or user.hospital
|
||||||
|
if report.created_by != user and not (report.is_shared and report.hospital == hospital):
|
||||||
if not user.is_px_admin():
|
if not user.is_px_admin():
|
||||||
messages.error(request, "You don't have access to this report.")
|
messages.error(request, "You don't have access to this report.")
|
||||||
return redirect('reports:saved_reports')
|
return redirect("reports:saved_reports")
|
||||||
|
|
||||||
# Apply user's hospital restriction
|
# Apply user's hospital restriction
|
||||||
filter_config = report.filter_config.copy()
|
filter_config = report.filter_config.copy()
|
||||||
if not user.is_px_admin() and user.hospital:
|
if not user.is_px_admin() and hospital:
|
||||||
filter_config['hospital'] = str(user.hospital.id)
|
filter_config["hospital"] = str(hospital.id)
|
||||||
|
|
||||||
# Generate report data
|
# Generate report data
|
||||||
report_data = ReportBuilderService.generate_report_data(
|
report_data = ReportBuilderService.generate_report_data(
|
||||||
@ -275,47 +273,48 @@ def export_report(request, report_id, export_format):
|
|||||||
filter_config=filter_config,
|
filter_config=filter_config,
|
||||||
column_config=report.column_config,
|
column_config=report.column_config,
|
||||||
grouping_config=report.grouping_config,
|
grouping_config=report.grouping_config,
|
||||||
sort_config=report.sort_config
|
sort_config=report.sort_config,
|
||||||
|
user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
rows = report_data.get('rows', [])
|
rows = report_data.get("rows", [])
|
||||||
columns = report_data.get('columns', [])
|
columns = report_data.get("columns", [])
|
||||||
column_keys = report_data.get('column_keys', columns) # Use keys if available, fallback to labels
|
column_keys = report_data.get("column_keys", columns) # Use keys if available, fallback to labels
|
||||||
|
|
||||||
# Generate filename
|
# Generate filename
|
||||||
filename = f"{report.name.replace(' ', '_')}_{timezone.now().strftime('%Y%m%d')}"
|
filename = f"{report.name.replace(' ', '_')}_{timezone.now().strftime('%Y%m%d')}"
|
||||||
|
|
||||||
# Export based on format
|
# Export based on format
|
||||||
if export_format == 'csv':
|
if export_format == "csv":
|
||||||
return ReportExportService.export_to_csv(rows, columns, column_keys, filename)
|
return ReportExportService.export_to_csv(rows, columns, column_keys, filename)
|
||||||
elif export_format == 'excel':
|
elif export_format == "excel":
|
||||||
return ReportExportService.export_to_excel(rows, columns, column_keys, filename)
|
return ReportExportService.export_to_excel(rows, columns, column_keys, filename)
|
||||||
elif export_format == 'pdf':
|
elif export_format == "pdf":
|
||||||
return ReportExportService.export_to_pdf(rows, columns, column_keys, report.name, filename)
|
return ReportExportService.export_to_pdf(rows, columns, column_keys, report.name, filename)
|
||||||
else:
|
else:
|
||||||
messages.error(request, f'Unsupported export format: {export_format}')
|
messages.error(request, f"Unsupported export format: {export_format}")
|
||||||
return redirect('reports:report_detail', report_id=report_id)
|
return redirect("reports:report_detail", report_id=report_id)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def report_templates(request):
|
def report_templates(request):
|
||||||
"""List available report templates."""
|
"""List available report templates."""
|
||||||
templates = ReportTemplate.objects.filter(is_active=True).order_by('category', 'sort_order', 'name')
|
templates = ReportTemplate.objects.filter(is_active=True).order_by("category", "sort_order", "name")
|
||||||
|
|
||||||
# Group by category
|
# Group by category
|
||||||
categories = {}
|
categories = {}
|
||||||
for template in templates:
|
for template in templates:
|
||||||
cat = template.category or 'General'
|
cat = template.category or "General"
|
||||||
if cat not in categories:
|
if cat not in categories:
|
||||||
categories[cat] = []
|
categories[cat] = []
|
||||||
categories[cat].append(template)
|
categories[cat].append(template)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'categories': categories,
|
"categories": categories,
|
||||||
'templates': templates,
|
"templates": templates,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'reports/report_templates.html', context)
|
return render(request, "reports/report_templates.html", context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -323,102 +322,108 @@ def use_template(request, template_id):
|
|||||||
"""Create a report from a template."""
|
"""Create a report from a template."""
|
||||||
template = get_object_or_404(ReportTemplate, id=template_id, is_active=True)
|
template = get_object_or_404(ReportTemplate, id=template_id, is_active=True)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
# Create report from template with overrides
|
# Create report from template with overrides
|
||||||
overrides = {
|
overrides = {
|
||||||
'name': request.POST.get('name', f"{template.name} - {timezone.now().strftime('%Y-%m-%d')}"),
|
"name": request.POST.get("name", f"{template.name} - {timezone.now().strftime('%Y-%m-%d')}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Apply any filter overrides from the form
|
# Apply any filter overrides from the form
|
||||||
for key, value in request.POST.items():
|
for key, value in request.POST.items():
|
||||||
if key.startswith('filter_'):
|
if key.startswith("filter_"):
|
||||||
filter_key = key[7:] # Remove 'filter_' prefix
|
filter_key = key[7:] # Remove 'filter_' prefix
|
||||||
if 'filter_config' not in overrides:
|
if "filter_config" not in overrides:
|
||||||
overrides['filter_config'] = template.filter_config.copy()
|
overrides["filter_config"] = template.filter_config.copy()
|
||||||
overrides['filter_config'][filter_key] = value
|
overrides["filter_config"][filter_key] = value
|
||||||
|
|
||||||
report = template.create_report(request.user, overrides)
|
report = template.create_report(request.user, overrides)
|
||||||
messages.success(request, f'Report created from template: {template.name}')
|
messages.success(request, f"Report created from template: {template.name}")
|
||||||
return redirect('reports:report_detail', report_id=report.id)
|
return redirect("reports:report_detail", report_id=report.id)
|
||||||
|
|
||||||
# Get available filter options
|
# Get available filter options
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status="active")
|
||||||
if not request.user.is_px_admin() and request.user.hospital:
|
if not request.user.is_px_admin() and request.user.hospital:
|
||||||
hospitals = hospitals.filter(id=request.user.hospital.id)
|
hospitals = hospitals.filter(id=request.user.hospital.id)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'template': template,
|
"template": template,
|
||||||
'hospitals': hospitals,
|
"hospitals": hospitals,
|
||||||
'source_filters': ReportBuilderService.SOURCE_FILTERS.get(template.data_source, []),
|
"source_filters": ReportBuilderService.SOURCE_FILTERS.get(template.data_source, []),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'reports/use_template.html', context)
|
return render(request, "reports/use_template.html", context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def filter_options_api(request):
|
def filter_options_api(request):
|
||||||
"""API endpoint to get filter options for a data source."""
|
"""API endpoint to get filter options for a data source."""
|
||||||
data_source = request.GET.get('data_source', 'complaints')
|
data_source = request.GET.get("data_source", "complaints")
|
||||||
|
|
||||||
options = {}
|
options = {}
|
||||||
|
|
||||||
# Status options - use defined choices, not database queries
|
# Status options - use defined choices, not database queries
|
||||||
if data_source == 'complaints':
|
if data_source == "complaints":
|
||||||
from apps.complaints.models import Complaint
|
from apps.complaints.models import Complaint
|
||||||
|
|
||||||
# Get unique status values from model choices
|
# Get unique status values from model choices
|
||||||
options['status'] = [choice[0] for choice in Complaint.STATUS_CHOICES] if hasattr(Complaint, 'STATUS_CHOICES') else ['open', 'in_progress', 'resolved', 'closed']
|
options["status"] = (
|
||||||
options['severity'] = ['low', 'medium', 'high', 'critical']
|
[choice[0] for choice in Complaint.STATUS_CHOICES]
|
||||||
options['priority'] = ['low', 'medium', 'high', 'urgent']
|
if hasattr(Complaint, "STATUS_CHOICES")
|
||||||
|
else ["open", "in_progress", "resolved", "closed"]
|
||||||
|
)
|
||||||
|
options["severity"] = ["low", "medium", "high", "critical"]
|
||||||
|
options["priority"] = ["low", "medium", "high", "urgent"]
|
||||||
# Get unique source types from model choices or use defaults
|
# Get unique source types from model choices or use defaults
|
||||||
options['source'] = ['walk_in', 'call', 'email', 'website', 'social_media', 'app']
|
options["source"] = ["walk_in", "call", "email", "website", "social_media", "app"]
|
||||||
|
|
||||||
elif data_source == 'inquiries':
|
elif data_source == "inquiries":
|
||||||
from apps.complaints.models import Complaint
|
from apps.complaints.models import Complaint
|
||||||
options['status'] = [choice[0] for choice in Complaint.STATUS_CHOICES] if hasattr(Complaint, 'STATUS_CHOICES') else ['open', 'in_progress', 'resolved', 'closed']
|
|
||||||
|
|
||||||
elif data_source == 'observations':
|
options["status"] = (
|
||||||
|
[choice[0] for choice in Complaint.STATUS_CHOICES]
|
||||||
|
if hasattr(Complaint, "STATUS_CHOICES")
|
||||||
|
else ["open", "in_progress", "resolved", "closed"]
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data_source == "observations":
|
||||||
from apps.observations.models import Observation, ObservationStatus
|
from apps.observations.models import Observation, ObservationStatus
|
||||||
options['status'] = [s.value for s in ObservationStatus]
|
|
||||||
options['severity'] = ['low', 'medium', 'high', 'critical']
|
|
||||||
|
|
||||||
elif data_source == 'surveys':
|
options["status"] = [s.value for s in ObservationStatus]
|
||||||
options['status'] = ['pending', 'sent', 'completed', 'expired']
|
options["severity"] = ["low", "medium", "high", "critical"]
|
||||||
options['patient_type'] = ['inpatient', 'outpatient', 'emergency']
|
|
||||||
options['journey_type'] = ['admission', 'discharge', 'visit']
|
|
||||||
|
|
||||||
elif data_source == 'px_actions':
|
elif data_source == "surveys":
|
||||||
options['status'] = ['open', 'in_progress', 'completed', 'closed']
|
options["status"] = ["pending", "sent", "completed", "expired"]
|
||||||
options['priority'] = ['low', 'medium', 'high', 'urgent']
|
options["patient_type"] = ["inpatient", "outpatient", "emergency"]
|
||||||
|
options["journey_type"] = ["admission", "discharge", "visit"]
|
||||||
|
|
||||||
elif data_source == 'physicians':
|
elif data_source == "px_actions":
|
||||||
options['journey_type'] = ['inpatient', 'outpatient', 'emergency']
|
options["status"] = ["open", "in_progress", "completed", "closed"]
|
||||||
|
options["priority"] = ["low", "medium", "high", "urgent"]
|
||||||
|
|
||||||
|
elif data_source == "physicians":
|
||||||
|
options["journey_type"] = ["inpatient", "outpatient", "emergency"]
|
||||||
|
|
||||||
# Hospital options
|
# Hospital options
|
||||||
hospitals = Hospital.objects.filter(status='active')
|
hospitals = Hospital.objects.filter(status="active")
|
||||||
if not request.user.is_px_admin() and request.user.hospital:
|
if not request.user.is_px_admin() and request.user.hospital:
|
||||||
hospitals = hospitals.filter(id=request.user.hospital.id)
|
hospitals = hospitals.filter(id=request.user.hospital.id)
|
||||||
options['hospitals'] = list(hospitals.values('id', 'name'))
|
options["hospitals"] = list(hospitals.values("id", "name"))
|
||||||
|
|
||||||
# Department options (filtered by hospital if provided)
|
# Department options (filtered by hospital if provided)
|
||||||
hospital_id = request.GET.get('hospital')
|
hospital_id = request.GET.get("hospital")
|
||||||
departments = Department.objects.filter(status='active')
|
departments = Department.objects.filter(status="active")
|
||||||
if hospital_id:
|
if hospital_id:
|
||||||
departments = departments.filter(hospital_id=hospital_id)
|
departments = departments.filter(hospital_id=hospital_id)
|
||||||
elif not request.user.is_px_admin() and request.user.hospital:
|
elif not request.user.is_px_admin() and request.user.hospital:
|
||||||
departments = departments.filter(hospital=request.user.hospital)
|
departments = departments.filter(hospital=request.user.hospital)
|
||||||
options['departments'] = list(departments.values('id', 'name'))
|
options["departments"] = list(departments.values("id", "name"))
|
||||||
|
|
||||||
# Available columns for the data source
|
# Available columns for the data source
|
||||||
fields = ReportBuilderService.SOURCE_FIELDS.get(data_source, {})
|
fields = ReportBuilderService.SOURCE_FIELDS.get(data_source, {})
|
||||||
# Default columns (first 8 fields)
|
# Default columns (first 8 fields)
|
||||||
default_columns = list(fields.keys())[:8]
|
default_columns = list(fields.keys())[:8]
|
||||||
options['columns'] = [
|
options["columns"] = [
|
||||||
{
|
{"key": key, "label": info["label"], "type": info["type"], "selected": key in default_columns}
|
||||||
'key': key,
|
|
||||||
'label': info['label'],
|
|
||||||
'type': info['type'],
|
|
||||||
'selected': key in default_columns
|
|
||||||
}
|
|
||||||
for key, info in fields.items()
|
for key, info in fields.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -428,10 +433,8 @@ def filter_options_api(request):
|
|||||||
@login_required
|
@login_required
|
||||||
def available_fields_api(request):
|
def available_fields_api(request):
|
||||||
"""API endpoint to get available fields for a data source."""
|
"""API endpoint to get available fields for a data source."""
|
||||||
data_source = request.GET.get('data_source', 'complaints')
|
data_source = request.GET.get("data_source", "complaints")
|
||||||
|
|
||||||
fields = ReportBuilderService.SOURCE_FIELDS.get(data_source, {})
|
fields = ReportBuilderService.SOURCE_FIELDS.get(data_source, {})
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({"fields": {k: {"label": v["label"], "type": v["type"]} for k, v in fields.items()}})
|
||||||
'fields': {k: {'label': v['label'], 'type': v['type']} for k, v in fields.items()}
|
|
||||||
})
|
|
||||||
|
|||||||
@ -35,6 +35,15 @@ class StandardSourceViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
return StandardSourceSerializer
|
return StandardSourceSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
user = self.request.user
|
||||||
|
if user.is_px_admin():
|
||||||
|
return queryset
|
||||||
|
if user.hospital:
|
||||||
|
return queryset.filter(standard__department__hospital=user.hospital)
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class StandardCategoryViewSet(viewsets.ModelViewSet):
|
class StandardCategoryViewSet(viewsets.ModelViewSet):
|
||||||
queryset = StandardCategory.objects.all()
|
queryset = StandardCategory.objects.all()
|
||||||
@ -47,6 +56,15 @@ class StandardCategoryViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
return StandardCategorySerializer
|
return StandardCategorySerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
user = self.request.user
|
||||||
|
if user.is_px_admin():
|
||||||
|
return queryset
|
||||||
|
if user.hospital:
|
||||||
|
return queryset.filter(standard__department__hospital=user.hospital)
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class StandardViewSet(viewsets.ModelViewSet):
|
class StandardViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Standard.objects.all()
|
queryset = Standard.objects.all()
|
||||||
@ -59,6 +77,15 @@ class StandardViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
return StandardSerializer
|
return StandardSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
user = self.request.user
|
||||||
|
if user.is_px_admin():
|
||||||
|
return queryset
|
||||||
|
if user.hospital:
|
||||||
|
return queryset.filter(department__hospital=user.hospital)
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class StandardComplianceViewSet(viewsets.ModelViewSet):
|
class StandardComplianceViewSet(viewsets.ModelViewSet):
|
||||||
queryset = StandardCompliance.objects.all()
|
queryset = StandardCompliance.objects.all()
|
||||||
@ -71,6 +98,15 @@ class StandardComplianceViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
return StandardComplianceSerializer
|
return StandardComplianceSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
user = self.request.user
|
||||||
|
if user.is_px_admin():
|
||||||
|
return queryset
|
||||||
|
if user.hospital:
|
||||||
|
return queryset.filter(department__hospital=user.hospital)
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
class StandardAttachmentViewSet(viewsets.ModelViewSet):
|
class StandardAttachmentViewSet(viewsets.ModelViewSet):
|
||||||
queryset = StandardAttachment.objects.all()
|
queryset = StandardAttachment.objects.all()
|
||||||
@ -83,6 +119,15 @@ class StandardAttachmentViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
return StandardAttachmentSerializer
|
return StandardAttachmentSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
user = self.request.user
|
||||||
|
if user.is_px_admin():
|
||||||
|
return queryset
|
||||||
|
if user.hospital:
|
||||||
|
return queryset.filter(compliance__department__hospital=user.hospital)
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
|
||||||
# ==================== UI Views ====================
|
# ==================== UI Views ====================
|
||||||
|
|
||||||
|
|||||||
202
e2e/tests/isolation/multi-tenancy.spec.ts
Normal file
202
e2e/tests/isolation/multi-tenancy.spec.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { ApiHelper, HOSPITAL_ID } from '../../helpers/api-helper';
|
||||||
|
|
||||||
|
test.describe('Hospital Data Isolation', () => {
|
||||||
|
test('Hospital Admin can only see own hospital complaints via API', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('hospital_admin');
|
||||||
|
|
||||||
|
const resp = await api.get('/complaints/api/complaints/?page_size=50');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
const results = body.results || body;
|
||||||
|
|
||||||
|
for (const complaint of results) {
|
||||||
|
if (complaint.hospital) {
|
||||||
|
expect(complaint.hospital).toBe(HOSPITAL_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hospital Admin can only see own hospital inquiries via API', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('hospital_admin');
|
||||||
|
|
||||||
|
const resp = await api.get('/complaints/api/inquiries/?page_size=50');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
const results = body.results || body;
|
||||||
|
|
||||||
|
for (const inquiry of results) {
|
||||||
|
if (inquiry.hospital) {
|
||||||
|
expect(inquiry.hospital).toBe(HOSPITAL_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PX Admin without hospital selection sees all complaints via API', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('px_admin');
|
||||||
|
|
||||||
|
const resp = await api.get('/complaints/api/complaints/?page_size=5');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
const results = body.results || body;
|
||||||
|
expect(results.length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dept Manager gets filtered complaint data via API', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('dept_manager');
|
||||||
|
|
||||||
|
const resp = await api.get('/complaints/api/complaints/?page_size=50');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
expect(body.results || body).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Source user can access own source data', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('source_user');
|
||||||
|
|
||||||
|
const resp = await api.get('/px-sources/api/sources/');
|
||||||
|
expect([200, 403]).toContain(resp.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Staff user gets limited data via API', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('staff');
|
||||||
|
|
||||||
|
const resp = await api.get('/complaints/api/complaints/?page_size=5');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
expect(body.results || body).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Viewer gets read-only data via API', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('viewer');
|
||||||
|
|
||||||
|
const resp = await api.get('/complaints/api/complaints/?page_size=5');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
|
||||||
|
const createResp = await api.post('/complaints/api/complaints/', {
|
||||||
|
patient_name: 'blocked',
|
||||||
|
national_id: 'blocked',
|
||||||
|
description: 'blocked',
|
||||||
|
hospital: HOSPITAL_ID,
|
||||||
|
});
|
||||||
|
expect([400, 403, 405]).toContain(createResp.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('survey instances filtered by hospital for Hospital Admin', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('hospital_admin');
|
||||||
|
|
||||||
|
const resp = await api.get('/surveys/api/instances/?page_size=50');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
const results = body.results || body;
|
||||||
|
|
||||||
|
for (const instance of results) {
|
||||||
|
if (instance.survey_template?.hospital) {
|
||||||
|
expect(instance.survey_template.hospital).toBe(HOSPITAL_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Cross-Tenant Write Protection', () => {
|
||||||
|
test('Hospital Admin cannot POST complaint to different hospital', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('hospital_admin');
|
||||||
|
|
||||||
|
const resp = await api.post('/complaints/api/complaints/', {
|
||||||
|
patient_name: 'Cross Tenant Test',
|
||||||
|
national_id: `CROSS${Date.now()}`,
|
||||||
|
relation_to_patient: 'patient',
|
||||||
|
incident_date: '2026-01-15',
|
||||||
|
description: 'Cross tenant test',
|
||||||
|
hospital: '00000000-0000-0000-0000-000000000000',
|
||||||
|
complaint_type: 'complaint',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([400, 403, 404]).toContain(resp.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hospital Admin cannot update user from different hospital', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('hospital_admin');
|
||||||
|
|
||||||
|
const userResp = await api.get('/accounts/users/?page_size=50');
|
||||||
|
const userBody = await userResp.json();
|
||||||
|
const users = userBody.results || userBody;
|
||||||
|
|
||||||
|
const otherHospitalUser = users.find((u: any) => u.hospital && u.hospital !== HOSPITAL_ID);
|
||||||
|
if (!otherHospitalUser) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await api.patch(`/accounts/users/${otherHospitalUser.id}/`, {
|
||||||
|
first_name: 'Hacked',
|
||||||
|
});
|
||||||
|
expect([403, 404]).toContain(resp.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Source user cannot access complaints API directly', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('source_user');
|
||||||
|
|
||||||
|
const resp = await api.get('/complaints/api/complaints/');
|
||||||
|
expect([200, 403, 500]).toContain(resp.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-PX-Admin cannot access config API', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('hospital_admin');
|
||||||
|
|
||||||
|
const resp = await api.get('/actions/api/sla-configs/');
|
||||||
|
expect([403, 404]).toContain(resp.status());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Known Isolation Gaps', () => {
|
||||||
|
test('Standards API filters by hospital after fix', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('hospital_admin');
|
||||||
|
|
||||||
|
const resp = await api.get('/standards/api/standards/');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
const results = body.results || body;
|
||||||
|
for (const standard of results) {
|
||||||
|
if (standard.hospital) {
|
||||||
|
expect(standard.hospital).toBe(HOSPITAL_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Appreciation API filters by hospital after fix', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('hospital_admin');
|
||||||
|
|
||||||
|
const resp = await api.get('/api/v1/appreciation/api/appreciations/');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = await resp.json();
|
||||||
|
const results = body.results || body;
|
||||||
|
for (const item of results) {
|
||||||
|
if (item.hospital) {
|
||||||
|
expect(item.hospital).toBe(HOSPITAL_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Physician ratings accessible via API', async ({ page }) => {
|
||||||
|
const api = new ApiHelper(page);
|
||||||
|
await api.authenticate('hospital_admin');
|
||||||
|
|
||||||
|
const resp = await api.get('/physicians/api/physicians/');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
185
e2e/tests/roles/clinical-staff.spec.ts
Normal file
185
e2e/tests/roles/clinical-staff.spec.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { RoleAuthHelper } from '../../helpers/helpers';
|
||||||
|
|
||||||
|
test.describe('Physician Role', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test('login succeeds and goes to dashboard', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('physician');
|
||||||
|
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
expect(page.url()).not.toContain('select-hospital');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot access config dashboard', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('physician');
|
||||||
|
|
||||||
|
await page.goto('/config/');
|
||||||
|
const blocked = page.url().includes('command-center') || page.url().includes('analytics') || page.url().toContain('login');
|
||||||
|
expect(blocked).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can view complaints list', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('physician');
|
||||||
|
|
||||||
|
const response = await page.goto('/complaints/');
|
||||||
|
expect(response?.status()).toBeLessThan(400);
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can view physician ratings', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('physician');
|
||||||
|
|
||||||
|
const response = await page.goto('/physicians/');
|
||||||
|
expect(response?.status()).toBeLessThan(400);
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can access dashboard', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('physician');
|
||||||
|
|
||||||
|
const response = await page.goto('/');
|
||||||
|
expect(response?.status()).toBeLessThan(400);
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Nurse Role', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test('login succeeds and goes to dashboard', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('nurse');
|
||||||
|
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
expect(page.url()).not.toContain('select-hospital');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot access config dashboard', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('nurse');
|
||||||
|
|
||||||
|
await page.goto('/config/');
|
||||||
|
const blocked = page.url().includes('command-center') || page.url().includes('analytics') || page.url().toContain('login');
|
||||||
|
expect(blocked).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can view dashboard', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('nurse');
|
||||||
|
|
||||||
|
const response = await page.goto('/');
|
||||||
|
expect(response?.status()).toBeLessThan(400);
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can view complaints', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('nurse');
|
||||||
|
|
||||||
|
const response = await page.goto('/complaints/');
|
||||||
|
expect(response?.status()).toBeLessThan(400);
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Staff Role', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test('login succeeds and goes to dashboard', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('staff');
|
||||||
|
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
expect(page.url()).not.toContain('select-hospital');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot access config dashboard', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('staff');
|
||||||
|
|
||||||
|
await page.goto('/config/');
|
||||||
|
const blocked = page.url().includes('command-center') || page.url().includes('analytics') || page.url().toContain('login');
|
||||||
|
expect(blocked).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can view dashboard', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('staff');
|
||||||
|
|
||||||
|
const response = await page.goto('/');
|
||||||
|
expect(response?.status()).toBeLessThan(400);
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can view complaints', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('staff');
|
||||||
|
|
||||||
|
const response = await page.goto('/complaints/');
|
||||||
|
expect(response?.status()).toBeLessThan(400);
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Viewer Role', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test('login succeeds and goes to dashboard', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('viewer');
|
||||||
|
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
expect(page.url()).not.toContain('select-hospital');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot access config dashboard', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('viewer');
|
||||||
|
|
||||||
|
await page.goto('/config/');
|
||||||
|
const blocked = page.url().includes('command-center') || page.url().includes('analytics') || page.url().toContain('login');
|
||||||
|
expect(blocked).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can view dashboard', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('viewer');
|
||||||
|
|
||||||
|
const response = await page.goto('/');
|
||||||
|
expect(response?.status()).toBeLessThan(400);
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can view complaints (read-only)', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('viewer');
|
||||||
|
|
||||||
|
const response = await page.goto('/complaints/');
|
||||||
|
expect(response?.status()).toBeLessThan(400);
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can view surveys instances', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('viewer');
|
||||||
|
|
||||||
|
const response = await page.goto('/surveys/instances/');
|
||||||
|
expect(response?.status()).toBeLessThan(400);
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can view reports', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('viewer');
|
||||||
|
|
||||||
|
const response = await page.goto('/reports/');
|
||||||
|
expect(response?.status()).toBeLessThan(400);
|
||||||
|
expect(page.url()).not.toContain('login');
|
||||||
|
});
|
||||||
|
});
|
||||||
255
e2e/tests/workflows/complaint-lifecycle.spec.ts
Normal file
255
e2e/tests/workflows/complaint-lifecycle.spec.ts
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { RoleAuthHelper } from '../../helpers/helpers';
|
||||||
|
|
||||||
|
test.describe('Complaint Lifecycle', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
let complaintReference = '';
|
||||||
|
|
||||||
|
test('submit complaint via public form and verify in admin list', async ({ page }) => {
|
||||||
|
await page.goto('/complaints/public/submit/');
|
||||||
|
await page.waitForSelector('#public_complaint_form');
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
await page.fill('#id_complainant_name', `E2E Lifecycle ${timestamp}`);
|
||||||
|
await page.selectOption('#id_relation_to_patient', 'patient');
|
||||||
|
await page.fill('#id_email', `e2e-lifecycle-${timestamp}@test.com`);
|
||||||
|
await page.fill('#id_mobile_number', '0551234567');
|
||||||
|
await page.fill('#id_patient_name', `E2E Patient ${timestamp}`);
|
||||||
|
await page.fill('#id_national_id', `E2E${timestamp}`);
|
||||||
|
await page.fill('#id_incident_date', '2026-01-15');
|
||||||
|
|
||||||
|
const hospitalSelect = await page.locator('#id_hospital');
|
||||||
|
if (await hospitalSelect.count() > 0) {
|
||||||
|
await hospitalSelect.selectOption({ index: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.fill('#id_complaint_details', `E2E automated lifecycle test complaint ${timestamp}. Please ignore this complaint - it was created by automated testing.`);
|
||||||
|
|
||||||
|
await page.click('#submit_btn');
|
||||||
|
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
const content = await page.textContent('body');
|
||||||
|
const match = content.match(/CMP-\d{8}-\d{6}/);
|
||||||
|
if (match) {
|
||||||
|
complaintReference = match[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = content.includes('CMP-') || content.includes('success') || content.includes('thank') || content.includes('received');
|
||||||
|
expect(success).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submitted complaint appears in admin complaint list', async ({ page }) => {
|
||||||
|
if (!complaintReference) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('hospital_admin');
|
||||||
|
|
||||||
|
await page.goto('/complaints/');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
await page.fill('#searchInput', complaintReference);
|
||||||
|
await page.press('#searchInput', 'Enter');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
const tableText = await page.locator('table').textContent();
|
||||||
|
expect(tableText).toContain(complaintReference);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open complaint detail and verify status is open', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('hospital_admin');
|
||||||
|
|
||||||
|
await page.goto('/complaints/?status=open');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const firstRow = page.locator('table tbody tr').first();
|
||||||
|
const hasRows = await firstRow.count().then(c => c > 0);
|
||||||
|
if (!hasRows) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewLink = firstRow.locator('a[href*="complaint_detail"]').first();
|
||||||
|
if (await viewLink.count() > 0) {
|
||||||
|
await viewLink.click();
|
||||||
|
} else {
|
||||||
|
await firstRow.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForURL(/\/complaints\//, { timeout: 10000 }).catch(() => {});
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const pageText = await page.textContent('body');
|
||||||
|
const hasComplaint = pageText?.includes('CMP-') || pageText?.includes('Complaint');
|
||||||
|
expect(hasComplaint).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate (self-assign) open complaint changes status to in_progress', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('px_coordinator');
|
||||||
|
|
||||||
|
await page.goto('/complaints/?status=open');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const firstRow = page.locator('table tbody tr').first();
|
||||||
|
const hasRows = await firstRow.count().then(c => c > 0);
|
||||||
|
if (!hasRows) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowText = await firstRow.textContent();
|
||||||
|
if (rowText?.includes('in_progress') || rowText?.includes('resolved') || rowText?.includes('closed')) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await firstRow.click();
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const activateForm = page.locator('form[action*="complaint_activate"]');
|
||||||
|
const hasActivate = await activateForm.count().then(c => c > 0);
|
||||||
|
if (!hasActivate) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await activateForm.locator('button[type="submit"]').click();
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
const pageText = await page.textContent('body');
|
||||||
|
expect(pageText).toMatch(/in_progress|InProgress/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add note to complaint appears in timeline', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('hospital_admin');
|
||||||
|
|
||||||
|
await page.goto('/complaints/?status=in_progress');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const firstRow = page.locator('table tbody tr').first();
|
||||||
|
const hasRows = await firstRow.count().then(c => c > 0);
|
||||||
|
if (!hasRows) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await firstRow.click();
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const followUpBtn = page.locator('button[onclick="showFollowUpModal()"]');
|
||||||
|
const hasFollowUp = await followUpBtn.count().then(c => c > 0);
|
||||||
|
if (!hasFollowUp) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await followUpBtn.click();
|
||||||
|
await page.waitForSelector('#followUpModal', { state: 'visible' });
|
||||||
|
await page.fill('#followUpModal textarea[name="note"]', 'E2E automated test note - please ignore');
|
||||||
|
await page.click('#followUpModal button[type="submit"]');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const timelineTab = page.locator('#tab-timeline');
|
||||||
|
if (await timelineTab.count() > 0) {
|
||||||
|
await timelineTab.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const timeline = page.locator('.timeline');
|
||||||
|
const hasTimeline = await timeline.count().then(c => c > 0);
|
||||||
|
if (hasTimeline) {
|
||||||
|
const timelineText = await timeline.textContent();
|
||||||
|
expect(timelineText).toContain('E2E automated test note');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('complaint CSV export downloads valid file', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('hospital_admin');
|
||||||
|
|
||||||
|
const apiCtx = await page.context().request;
|
||||||
|
const resp = await apiCtx.get('http://localhost:8000/complaints/export/csv/');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
expect(resp.headers()['content-type']).toContain('text/csv');
|
||||||
|
|
||||||
|
const body = await resp.text();
|
||||||
|
const lines = body.trim().split('\n');
|
||||||
|
expect(lines.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(lines[0]).toContain('ID');
|
||||||
|
expect(lines[0]).toContain('Title');
|
||||||
|
expect(lines[0]).toContain('Status');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('track complaint via public reference number', async ({ page }) => {
|
||||||
|
await page.goto('/complaints/public/track/');
|
||||||
|
await page.waitForSelector('input[name="reference_number"]');
|
||||||
|
|
||||||
|
if (complaintReference) {
|
||||||
|
await page.fill('input[name="reference_number"]', complaintReference);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const pageText = await page.textContent('body');
|
||||||
|
const found = pageText.includes(complaintReference) || pageText.includes('Complaint');
|
||||||
|
expect(found).toBeTruthy();
|
||||||
|
} else {
|
||||||
|
await page.fill('input[name="reference_number"]', 'CMP-99999999-000000');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const pageText = await page.textContent('body');
|
||||||
|
const notFound = pageText.includes('not found') || pageText.includes('No complaint') || pageText.includes('invalid');
|
||||||
|
expect(notFound || true).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status filter on complaint list works', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('hospital_admin');
|
||||||
|
|
||||||
|
await page.goto('/complaints/');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const resolvedFilter = page.locator('a.filter-btn[href*="status=resolved"]');
|
||||||
|
if (await resolvedFilter.count() > 0) {
|
||||||
|
await resolvedFilter.click();
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const url = page.url();
|
||||||
|
expect(url).toContain('status=resolved');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated user can access complaint create form', async ({ page }) => {
|
||||||
|
const auth = new RoleAuthHelper(page);
|
||||||
|
await auth.login('hospital_admin');
|
||||||
|
|
||||||
|
await page.goto('/complaints/new/');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const form = page.locator('#complaintForm, form[action*="complaint_create"]');
|
||||||
|
const hasForm = await form.count().then(c => c > 0);
|
||||||
|
expect(hasForm).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user