11759 lines
536 KiB
Markdown
11759 lines
536 KiB
Markdown
# Chat Session Export
|
|
|
|
- **Session ID**: `3e2a1965-502e-41ef-ab15-f68735ae82f9`
|
|
- **Start Time**: 2026-04-14T12:06:59.629Z
|
|
- **Exported**: 2026-04-14T14:41:09.771Z
|
|
|
|
- **Working Directory**: `/home/ismail/projects/HH`
|
|
- **Git Repository**: marwan/HH
|
|
- **Git Branch**: `main`
|
|
|
|
- **Model**: coder-model
|
|
- **Prompt Count**: 3
|
|
|
|
- **Total Tokens**: 4148669
|
|
- **Context Window Size**: 1000000
|
|
- **Context Usage**: 7.2%
|
|
|
|
- **Files Written**: 14
|
|
- **Lines Added**: 1813
|
|
- **Lines Removed**: 3
|
|
|
|
<details>
|
|
<summary><strong>Unique Files Referenced (14)</strong></summary>
|
|
|
|
- `/home/ismail/projects/HH/apps/accounts/models.py`
|
|
- `/home/ismail/projects/HH/apps/accounts/permissions.py`
|
|
- `/home/ismail/projects/HH/templates/layouts/partials/sidebar.html`
|
|
- `/home/ismail/projects/HH/config/settings/base.py`
|
|
- `/home/ismail/projects/HH/apps/executive_summary/models.py`
|
|
- `/home/ismail/projects/HH/apps/executive_summary/templates/executive/dashboard.html`
|
|
- `/home/ismail/projects/HH/apps/executive_summary/templates/executive/partials/variance_badge.html`
|
|
- `/home/ismail/projects/HH/apps/executive_summary/templates/executive/insights.html`
|
|
- `/home/ismail/projects/HH/apps/executive_summary/templates/executive/qa.html`
|
|
- `/home/ismail/projects/HH/apps/executive_summary/admin.py`
|
|
- `/home/ismail/projects/HH/apps/executive_summary/tasks.py`
|
|
- `/home/ismail/projects/HH/config/urls.py`
|
|
- `/home/ismail/projects/HH/config/celery.py`
|
|
- `/home/ismail/projects/HH/apps/executive_summary/apps.py`
|
|
</details>
|
|
|
|
---
|
|
|
|
## User
|
|
|
|
i want to add Executive Summary pages and role for top management , lets make the best good of AI in this
|
|
i want to add Executive Summary pages and role for top management , lets make the best good of AI in this
|
|
|
|
|
|
## Assistant
|
|
|
|
I need to explore the codebase first to understand the project structure, existing roles/permissions, and current pages before I can plan the Executive Summary feature.
|
|
|
|
|
|
## Assistant
|
|
|
|
I'll help you design an Executive Summary feature with AI-powered insights for top management. Let me first explore your codebase to understand the current structure and capabilities.
|
|
|
|
|
|
### Tool: Agent: Explore project structure
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"description": "Explore project structure",
|
|
"prompt": "Explore this codebase thoroughly to understand:\n1. What type of application is this (Django, Flask, etc.)?\n2. What is the current user role/permission system?\n3. What are the existing page structures and navigation?\n4. Is there any existing AI/ML integration?\n5. What data models exist, especially related to complaints, analytics, or reporting?\n6. What's the frontend stack (templates, React, etc.)?\n7. Are there any existing dashboards or summary pages?\n\nLook at:\n- manage.py, requirements.txt, pyproject.toml for framework info\n- Any models.py files\n- Any views.py or API files\n- Templates or frontend files\n- Settings/configuration files\n- Any existing role/permission system\n\nProvide a comprehensive summary of the architecture and existing features.",
|
|
"subagent_type": "Explore"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Now I have a comprehensive understanding of the codebase. Here is the detailed summary:
|
|
|
|
---
|
|
|
|
## PX360 - Comprehensive Architecture Summary
|
|
|
|
### 1. Application Type
|
|
|
|
This is a **Django 6.0** (upgraded from Django 5.0) enterprise application called **PX360 - Patient Experience 360 Management System** built for **AlHammadi Group** (a healthcare organization in Saudi Arabia). It is a multi-tenant, event-driven healthcare patient experience management system.
|
|
|
|
### 2. Tech Stack
|
|
|
|
**Backend:**
|
|
- Django 6.0.1 with Django REST Framework 3.16.1
|
|
- JWT authentication (djangorestframework-simplejwt)
|
|
- Celery 5.6.2 + Redis 7.1.0 for async task queue
|
|
- Celery Beat for scheduled tasks
|
|
- PostgreSQL 15 database
|
|
- psycopg2-binary for database connectivity
|
|
|
|
**Frontend:**
|
|
- **Server-rendered Django templates** with HTML (no React/SPA)
|
|
- **TailwindCSS 3.4** for styling (compiled via npm)
|
|
- **HTMX** for dynamic updates without full page reloads
|
|
- **ApexCharts** for data visualization
|
|
- **Lucide Icons** for UI icons
|
|
- Bootstrap-to-Tailwind migration helper script in place
|
|
- Supports RTL (right-to-left) for Arabic language
|
|
|
|
**Infrastructure:**
|
|
- Docker + Docker Compose (web, db, redis, celery, celery-beat)
|
|
- Gunicorn WSGI server
|
|
- WhiteNoise for static file serving
|
|
- OpenRouter AI API integration (LiteLLM)
|
|
|
|
---
|
|
|
|
### 3. User Role/Permission System (RBAC)
|
|
|
|
The system uses **Django Groups** mapped to a custom `Role` model. The `User` model extends `AbstractUser` with UUID primary key and email-based authentication.
|
|
|
|
**Predefined Roles** (defined in `/home/ismail/projects/HH/apps/accounts/models.py`):
|
|
- **PX Admin** - Full system access, can see all hospitals
|
|
- **Hospital Admin** - Hospital-level access
|
|
- **Department Manager** - Department-level access
|
|
- **PX Staff** - Action management capabilities
|
|
- **Physician** - Limited access
|
|
- **Nurse** - Limited access
|
|
- **Staff** - Limited access
|
|
- **Viewer** - Read-only access
|
|
- **PX Source User** - Source-specific data access (special restricted role with shorter sessions)
|
|
|
|
**Permission Classes** (in `/home/ismail/projects/HH/apps/accounts/permissions.py`):
|
|
- `IsPXAdmin`, `IsHospitalAdmin`, `IsDepartmentManager` - DRF permission classes
|
|
- `IsPXAdminOrHospitalAdmin`, `IsPXAdminOrReadOnly`, `IsOwnerOrPXAdmin`
|
|
- `IsSourceUser`, `IsSourceUserOrAdmin`, `IsSourceUserOwnData`
|
|
- `CanAccessHospitalData`, `CanAccessDepartmentData`
|
|
- Onboarding-specific permissions for provisional users
|
|
|
|
**Key features:**
|
|
- Multi-tenancy via `TenantMiddleware` - users see only their hospital's data
|
|
- Source user session restriction middleware (shorter sessions)
|
|
- User model has helper methods: `is_px_admin()`, `is_hospital_admin()`, etc.
|
|
- Role hierarchy levels for escalation logic
|
|
|
|
---
|
|
|
|
### 4. Existing Page Structure and Navigation
|
|
|
|
The sidebar navigation (in `/home/ismail/projects/HH/templates/layouts/partials/sidebar.html`) contains:
|
|
|
|
**Top Level:**
|
|
- Dashboard
|
|
- Command Center
|
|
- Performance (submenu: Admin Evaluation, Employee Evaluation) - Admin only
|
|
- My Source Dashboard - Source Users only
|
|
- Complaints (with count badge)
|
|
- Inquiries
|
|
- Observations
|
|
- QI Projects (submenu: All Projects, Create Project, Templates)
|
|
- Surveys (submenu: Survey List, Templates, Create Survey, Manual Send, HIS Import, Mapping)
|
|
- Staff (submenu: All Staff, Hierarchy)
|
|
- Patients
|
|
- Social Media - PX Admin only
|
|
- Physicians (submenu: Dashboard, All Physicians, Leaderboard, Fetch Ratings, Import Ratings)
|
|
- Call Center
|
|
- AI Engine
|
|
- Actions (PX Action Center)
|
|
- Reports
|
|
- RCA (Root Cause Analysis)
|
|
- Standards
|
|
- References
|
|
- Settings (submenu: Notifications, Hospitals, Departments, Acknowledgements, Roles & Permissions, Integrations)
|
|
- User profile / Logout
|
|
|
|
**Base Templates:**
|
|
- `/home/ismail/projects/HH/templates/layouts/base.html` - Main authenticated layout with sidebar
|
|
- `/home/ismail/projects/HH/templates/layouts/public_base.html` - Public-facing pages
|
|
- `/home/ismail/projects/HH/templates/layouts/source_user_base.html` - Source user specific layout
|
|
|
|
---
|
|
|
|
### 5. AI/ML Integration
|
|
|
|
The system has an **AI Engine** app (`/home/ismail/projects/HH/apps/ai_engine/`) with:
|
|
|
|
**Current Implementation:**
|
|
- **Keyword-based sentiment analysis** (stub implementation) - supports English and Arabic
|
|
- Sentiment detection: positive, neutral, negative with scores (-1 to 1)
|
|
- Keyword extraction from text
|
|
- Entity recognition (email, phone pattern matching)
|
|
- Emotion detection (joy, anger, sadness, fear, surprise)
|
|
- Language auto-detection (English vs Arabic)
|
|
|
|
**Models:**
|
|
- `SentimentResult` - stores analysis results linked to any model via GenericForeignKey
|
|
|
|
**API Endpoints:**
|
|
- `POST /api/ai-engine/analyze/` - Single text analysis
|
|
- `POST /api/ai-engine/analyze-batch/` - Batch analysis
|
|
- `GET /api/ai-engine/sentiment/{content_type_id}/{object_id}/` - Get sentiment for object
|
|
|
|
**AI Configuration** (in settings):
|
|
- LiteLLM with OpenRouter integration configured
|
|
- Model: `google/gemma-3-27b-it:free` (configurable)
|
|
- AI comment analysis for surveys
|
|
|
|
**Where AI is applied:**
|
|
- Complaint descriptions generate AI brief summaries (`ai_brief_en`, `ai_brief_ar`)
|
|
- Observations have AI analysis stored in metadata (emotion, suggested actions)
|
|
- Survey comments analyzed by AI
|
|
- Sentiment analysis can be applied to complaints, survey responses, social media mentions, call center notes
|
|
|
|
---
|
|
|
|
### 6. Data Models
|
|
|
|
#### Core Models (`/home/ismail/projects/HH/apps/core/models.py`)
|
|
- `TimeStampedModel`, `UUIDModel`, `SoftDeleteModel` - Base abstract models
|
|
- `AuditEvent` - Generic audit logging system
|
|
- `TenantModel` - Multi-tenant base model
|
|
- `PriorityChoices`, `SeverityChoices`, `StatusChoices` - Common enums
|
|
|
|
#### Organizations (`/home/ismail/projects/HH/apps/organizations/models.py`)
|
|
- `Organization` - Top-level organization
|
|
- `Hospital` - Hospital/facility with executive leadership (CEO, Medical Director, COO, CFO)
|
|
- `Department` - Hierarchical departments within hospitals
|
|
- `Staff` - Staff members (physicians, nurses, admin) with section/subsection hierarchy
|
|
- `Patient` - Patients with MRN, encrypted national ID
|
|
- `Location`, `MainSection`, `SubSection` - Location hierarchy for complaints
|
|
- `StaffSection`, `StaffSubsection` - Staff organization hierarchy
|
|
|
|
#### Accounts (`/home/ismail/projects/HH/apps/accounts/models.py`)
|
|
- `User` - Custom user model with UUID PK, email auth, hospital/department links
|
|
- `Role` - Predefined roles mapped to Django Groups
|
|
- `AcknowledgementCategory`, `AcknowledgementContent`, `AcknowledgementChecklistItem` - Onboarding system
|
|
- `UserAcknowledgement` - Digital signature acknowledgements
|
|
- `UserProvisionalLog` - Onboarding audit trail
|
|
- `SimpleAcknowledgement`, `EmployeeAcknowledgement` - Employee acknowledgement tracking
|
|
|
|
#### Complaints (`/home/ismail/projects/HH/apps/complaints/models.py`) - 2790+ lines
|
|
- `ComplaintCategory` - 4-level SHCT taxonomy (Domain > Category > Subcategory > Classification)
|
|
- `Complaint` - Full complaint lifecycle with SLA tracking, AI briefs, resolution tracking, satisfaction
|
|
- `ComplaintSLAConfig` - Per-source and per-severity SLA configuration
|
|
- `ComplaintUpdate`, `ComplaintAttachment` - Updates and file attachments
|
|
- `Inquiry` - Inquiry management
|
|
- `ComplaintThreshold`, `EscalationRule` - Threshold and escalation configuration
|
|
- `OncallSchedule`, `OncallShift` - On-call scheduling
|
|
|
|
#### Surveys (`/home/ismail/projects/HH/apps/surveys/models.py`)
|
|
- `SurveyTemplate` - Bilingual survey templates with scoring configuration
|
|
- `SurveyQuestion` - Questions with types (Rating, NPS, Yes/No, Multiple Choice, Text, Likert)
|
|
- `QuestionRoutingRule` - Conditional survey flow logic
|
|
- `SurveyInstance` - Actual survey sent to patient/staff with token-based access
|
|
- `SurveyResponse` - Individual question responses
|
|
- `SurveyTracking` - Engagement tracking (page views, time spent, abandonment)
|
|
- `BulkSurveyJob` - Background bulk survey sending
|
|
|
|
#### Journeys (`/home/ismail/projects/HH/apps/journeys/models.py`)
|
|
- `PatientJourneyTemplate` - Journey templates (EMS, Inpatient, OPD, Day Case)
|
|
- `PatientJourneyStageTemplate` - Stage definitions with event triggers
|
|
- `PatientJourneyInstance` - Actual patient journey instances
|
|
- `PatientJourneyStageInstance` - Stage completion tracking
|
|
|
|
#### Integrations (`/home/ismail/projects/HH/apps/integrations/models.py`)
|
|
- `InboundEvent` - External system events (HIS, Lab, Radiology, Pharmacy)
|
|
- `IntegrationConfig` - External system API configuration
|
|
- `SurveyTemplateMapping` - Maps patient types to survey templates
|
|
- `HISPatientVisit`, `HISVisitEvent` - HIS visit data
|
|
- `HISEventType` - Unique event types from HIS
|
|
- `EventMapping` - External-to-internal event code mapping
|
|
|
|
#### PX Action Center (`/home/ismail/projects/HH/apps/px_action_center/models.py`)
|
|
- `PXAction` - Improvement actions with SLA, approval workflow, escalation
|
|
- `PXActionLog` - Action activity timeline
|
|
- `PXActionAttachment` - Evidence attachments
|
|
- `PXActionSLAConfig` - Per-hospital SLA configuration
|
|
- `RoutingRule` - Automatic action routing rules
|
|
|
|
#### Analytics (`/home/ismail/projects/HH/apps/analytics/models.py`)
|
|
- `KPI` - Key Performance Indicator definitions
|
|
- `KPIValue` - KPI measurements over time (hourly, daily, weekly, monthly, quarterly, yearly)
|
|
|
|
#### Observations (`/home/ismail/projects/HH/apps/observations/models.py`)
|
|
- `Observation` - Staff-reported issues with anonymous submission support
|
|
- `ObservationCategory` - Classification categories
|
|
- `ObservationSLAConfig` - SLA per severity
|
|
- `ObservationAttachment`, `ObservationNote`, `ObservationStatusLog`
|
|
|
|
#### Dashboard (`/home/ismail/projects/HH/apps/dashboard/models.py`)
|
|
- `EvaluationNote` - Employee evaluation notes
|
|
- `ComplaintRequest` - Complaint request tracking
|
|
- `ReportCompletion` - Weekly report completion tracking
|
|
- `EscalatedComplaintLog` - Escalation timing logs
|
|
- `InquiryDetail` - Detailed inquiry tracking
|
|
|
|
#### Reports (`/home/ismail/projects/HH/apps/reports/models.py`)
|
|
- `SavedReport`, `GeneratedReport`, `ReportTemplate`, `DataSource`, `ReportFormat`
|
|
|
|
#### Other Apps:
|
|
- **Call Center** (`apps/callcenter/`) - Call center interactions and ratings
|
|
- **Social Media** (`apps/social/`) - Social media monitoring (YouTube, Facebook, Instagram, Twitter, LinkedIn, Google Reviews)
|
|
- **Physicians** (`apps/physicians/`) - Physician monthly ratings and leaderboards
|
|
- **Projects** (`apps/projects/`) - QI projects with tasks
|
|
- **Notifications** (`apps/notifications/`) - SMS, WhatsApp, Email delivery
|
|
- **Feedback** (`apps/feedback/`) - General feedback
|
|
- **Appreciation** (`apps/appreciation/`) - Appreciation management
|
|
- **PX Sources** (`apps/px_sources/`) - Source management (where complaints come from)
|
|
- **References** (`apps/references/`) - Reference data management
|
|
- **Standards** (`apps/standards/`) - Standards management
|
|
- **RCA** (`apps/rca/`) - Root Cause Analysis
|
|
- **Simulator** (`apps/simulator/`) - Testing/simulation tools
|
|
|
|
---
|
|
|
|
### 7. Existing Dashboards and Summary Pages
|
|
|
|
**Command Center Dashboard** (`/home/ismail/projects/HH/apps/dashboard/views.py`, `CommandCenterView`):
|
|
- Red Alert Banner (critical complaints, overdue items, escalated actions, negative surveys)
|
|
- KPI cards: Complaints (current vs previous, variance), Surveys (satisfaction, NPS, response rate), PX Actions (open, in-progress, overdue)
|
|
- Charts: Complaints trend, severity donut, department heatmap, NPS trend, actions funnel
|
|
- Live feed: Latest high-severity complaints, escalated actions, inquiries, observations, integration events
|
|
- Physician leaderboard (top 5 staff by rating)
|
|
- Filters: Date range, hospital, department
|
|
- API endpoint: `/dashboard/api/` for live data refresh
|
|
|
|
**My Dashboard** (`my_dashboard` view):
|
|
- Personal view of all items assigned to the current user
|
|
- Tabbed interface: Complaints, Inquiries, Observations, PX Actions, Tasks, Feedback
|
|
- Search, date range, status, priority filtering
|
|
- Bulk actions, export (CSV/Excel)
|
|
|
|
**Admin Evaluation Dashboard** (`admin_evaluation` view):
|
|
- Staff performance evaluation
|
|
- Department benchmarks
|
|
- Staff performance detail views
|
|
- Export functionality
|
|
|
|
**Employee Evaluation** (`employee_evaluation` view):
|
|
- PAD Department Weekly Dashboard
|
|
- Evaluation notes tracking
|
|
- Complaint request monitoring
|
|
- Report completion tracking
|
|
|
|
**Complaint Request Report** (`complaint_request_list` view):
|
|
- Step 0 complaint request tracking
|
|
- Export functionality
|
|
|
|
**Physician Ratings Dashboard** (`apps/physicians/`):
|
|
- Physician monthly ratings
|
|
- Leaderboards
|
|
- External rating fetching (Google, etc.)
|
|
|
|
**Social Media Dashboard** (`apps/social/`):
|
|
- Social media mentions monitoring
|
|
- Sentiment analysis integration
|
|
|
|
**Analytics Reports** (via Surveys app):
|
|
- Survey analytics and reports
|
|
|
|
---
|
|
|
|
### Key URLs
|
|
|
|
- Main site: `http://localhost:8000/`
|
|
- Admin: `http://localhost:8000/admin/`
|
|
- API Docs (Swagger): `http://localhost:8000/api/docs/`
|
|
- API Docs (ReDoc): `http://localhost:8000/api/redoc/`
|
|
- Health Check: `http://localhost:8000/health/`
|
|
|
|
### Celery Scheduled Tasks
|
|
- Process integration events: Every 1 minute
|
|
- Check overdue complaints: Every 15 minutes
|
|
- Check overdue actions: Every 15 minutes
|
|
- Send SLA reminders: Every hour
|
|
- Calculate daily KPIs: Daily at 1 AM
|
|
- Calculate physician ratings: Monthly on 1st at 2 AM
|
|
|
|
### Internationalization
|
|
- Arabic and English supported throughout
|
|
- Bilingual models (name_en/name_ar, title_en/title_ar, etc.)
|
|
- RTL-ready UI components
|
|
- Language code stored in user preferences
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now that I have a comprehensive understanding of the codebase, I should clarify the user's preferences for the Executive Summary feature before presenting the final plan.
|
|
|
|
|
|
## Assistant
|
|
|
|
Excellent! This is a sophisticated healthcare patient experience management system. Now let me explore the existing dashboard structure and AI capabilities in more detail to design the best Executive Summary feature.
|
|
|
|
|
|
### Tool: AskUserQuestion
|
|
**Status**: failed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"metadata": {
|
|
"source": "remember"
|
|
},
|
|
"questions": [
|
|
{
|
|
"header": "Executive Focus",
|
|
"options": [
|
|
{
|
|
"description": "High-level organizational health metrics, trends, and AI-powered insights across all hospitals",
|
|
"label": "Overall KPIs & Metrics"
|
|
},
|
|
{
|
|
"description": "Predictive risk scores, early warning indicators, and proactive issue identification using AI",
|
|
"label": "Risk & Early Warnings"
|
|
},
|
|
{
|
|
"description": "AI-generated natural language summaries of performance, trends, concerns, and recommendations",
|
|
"label": "AI-Generated Summaries"
|
|
},
|
|
{
|
|
"description": "Hospital/department comparisons, leaderboards, and performance rankings",
|
|
"label": "Comparative Analytics"
|
|
}
|
|
],
|
|
"question": "What key metrics would top management (executives/leadership) want to see in the Executive Summary? (Select all that apply)",
|
|
"multiSelect": true
|
|
},
|
|
{
|
|
"header": "AI Features",
|
|
"options": [
|
|
{
|
|
"description": "Natural language summary of monthly/weekly performance (e.g., 'This month saw 15% improvement in patient satisfaction...')",
|
|
"label": "Automated Narrative Reports"
|
|
},
|
|
{
|
|
"description": "AI detects emerging patterns, recurring issues, and suggests root causes",
|
|
"label": "Predictive Insights & Anomaly Detection"
|
|
},
|
|
{
|
|
"description": "AI suggests specific actions to improve metrics based on historical data and best practices",
|
|
"label": "Recommendation Engine"
|
|
},
|
|
{
|
|
"description": "\"What was the complaint rate for Hospital A last month?\" - AI answers from data",
|
|
"label": "Natural Language Q&A"
|
|
}
|
|
],
|
|
"question": "Which AI-powered features would you like to include? (Select all that apply)",
|
|
"multiSelect": true
|
|
},
|
|
{
|
|
"header": "Access Level",
|
|
"options": [
|
|
{
|
|
"description": "System-wide view across all hospitals with ability to drill down",
|
|
"label": "All Hospitals Overview"
|
|
},
|
|
{
|
|
"description": "See only their own hospital's metrics and comparisons",
|
|
"label": "Individual Hospital View"
|
|
},
|
|
{
|
|
"description": "Both - can toggle between system-wide and individual hospital views",
|
|
"label": "Both (Recommended)"
|
|
},
|
|
{
|
|
"description": "Custom filters to select specific hospitals, departments, or time periods",
|
|
"label": "Custom Selection"
|
|
}
|
|
],
|
|
"question": "Should executives be able to see data across all hospitals, or should it be restricted?",
|
|
"multiSelect": false
|
|
},
|
|
{
|
|
"header": "Delivery",
|
|
"options": [
|
|
{
|
|
"description": "Dedicated Executive Summary page with interactive charts and AI insights",
|
|
"label": "Web Dashboard (Recommended)"
|
|
},
|
|
{
|
|
"description": "Scheduled PDF reports emailed to executives (daily/weekly/monthly)",
|
|
"label": "PDF Report Generation"
|
|
},
|
|
{
|
|
"description": "Mobile-responsive view optimized for executives checking on-the-go",
|
|
"label": "Mobile-Friendly View"
|
|
},
|
|
{
|
|
"description": "All of the above",
|
|
"label": "All Channels (Recommended)"
|
|
}
|
|
],
|
|
"question": "How should the Executive Summary be delivered?",
|
|
"multiSelect": true
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Question 1: "header" must be 12 characters or less.
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
The previous question failed validation due to header length; I need to retry with shorter headers while keeping the same question content.
|
|
|
|
|
|
### Tool: AskUserQuestion: Ask user 4 questions
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"metadata": {
|
|
"source": "remember"
|
|
},
|
|
"questions": [
|
|
{
|
|
"header": "Key Metrics",
|
|
"options": [
|
|
{
|
|
"description": "High-level organizational health metrics, trends, and AI-powered insights across all hospitals",
|
|
"label": "Overall KPIs & Metrics"
|
|
},
|
|
{
|
|
"description": "Predictive risk scores, early warning indicators, and proactive issue identification using AI",
|
|
"label": "Risk & Early Warnings"
|
|
},
|
|
{
|
|
"description": "AI-generated natural language summaries of performance, trends, concerns, and recommendations",
|
|
"label": "AI-Generated Summaries"
|
|
},
|
|
{
|
|
"description": "Hospital/department comparisons, leaderboards, and performance rankings",
|
|
"label": "Comparative Analytics"
|
|
}
|
|
],
|
|
"question": "What key metrics would top management (executives/leadership) want to see in the Executive Summary? (Select all that apply)",
|
|
"multiSelect": true
|
|
},
|
|
{
|
|
"header": "AI Features",
|
|
"options": [
|
|
{
|
|
"description": "Natural language summary of monthly/weekly performance (e.g., 'This month saw 15% improvement in patient satisfaction...')",
|
|
"label": "Automated Narratives"
|
|
},
|
|
{
|
|
"description": "AI detects emerging patterns, recurring issues, and suggests root causes",
|
|
"label": "Predictive Insights"
|
|
},
|
|
{
|
|
"description": "AI suggests specific actions to improve metrics based on historical data and best practices",
|
|
"label": "Recommendations"
|
|
},
|
|
{
|
|
"description": "\"What was the complaint rate for Hospital A last month?\" - AI answers from data",
|
|
"label": "Natural Language Q&A"
|
|
}
|
|
],
|
|
"question": "Which AI-powered features would you like to include? (Select all that apply)",
|
|
"multiSelect": true
|
|
},
|
|
{
|
|
"header": "Data Access",
|
|
"options": [
|
|
{
|
|
"description": "System-wide view across all hospitals with ability to drill down",
|
|
"label": "All Hospitals"
|
|
},
|
|
{
|
|
"description": "See only their own hospital's metrics and comparisons",
|
|
"label": "Single Hospital"
|
|
},
|
|
{
|
|
"description": "Both - can toggle between system-wide and individual hospital views",
|
|
"label": "Both (Recommended)"
|
|
},
|
|
{
|
|
"description": "Custom filters to select specific hospitals, departments, or time periods",
|
|
"label": "Custom Selection"
|
|
}
|
|
],
|
|
"question": "Should executives be able to see data across all hospitals, or restricted to their hospital?",
|
|
"multiSelect": false
|
|
},
|
|
{
|
|
"header": "Delivery",
|
|
"options": [
|
|
{
|
|
"description": "Dedicated Executive Summary page with interactive charts and AI insights",
|
|
"label": "Web Dashboard"
|
|
},
|
|
{
|
|
"description": "Scheduled PDF reports emailed to executives (daily/weekly/monthly)",
|
|
"label": "PDF Reports"
|
|
},
|
|
{
|
|
"description": "Mobile-responsive view optimized for executives checking on-the-go",
|
|
"label": "Mobile View"
|
|
},
|
|
{
|
|
"description": "All of the above",
|
|
"label": "All Channels"
|
|
}
|
|
],
|
|
"question": "How should the Executive Summary be delivered?",
|
|
"multiSelect": true
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
User has provided the following answers:
|
|
|
|
**Key Metrics**: Overall KPIs & Metrics, Risk & Early Warnings, AI-Generated Summaries
|
|
**AI Features**: Automated Narratives, Predictive Insights, Recommendations, Natural Language Q&A
|
|
**Data Access**: All Hospitals
|
|
**Delivery**: Web Dashboard, PDF Reports, Mobile View, All Channels
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now that I have the user's requirements confirmed, I need to examine the existing dashboard views, URL routing, and AI engine implementation to understand the patterns I should follow when designing the Executive Summary feature.
|
|
|
|
|
|
## Assistant
|
|
|
|
Perfect! Now let me examine the existing dashboard implementation and AI engine to create a comprehensive plan.
|
|
|
|
|
|
### Tool: ReadFile: apps/dashboard/views.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/dashboard/views.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/dashboard/views.py`
|
|
|
|
**Output:**
|
|
```python
|
|
Showing lines 1-569 of 1754 total lines.
|
|
|
|
---
|
|
|
|
"""
|
|
Dashboard views - PX Command Center and analytics dashboards
|
|
"""
|
|
|
|
import json
|
|
from datetime import timedelta, datetime
|
|
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
from django.core.paginator import Paginator
|
|
from django.db.models import Avg, Count, Q, Sum
|
|
from django.http import JsonResponse
|
|
from django.shortcuts import redirect, render, reverse
|
|
from django.utils import timezone
|
|
from django.views.generic import TemplateView
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.contrib import messages
|
|
|
|
|
|
class CommandCenterView(LoginRequiredMixin, TemplateView):
|
|
"""
|
|
PX Command Center Dashboard - Real-time control panel.
|
|
|
|
Shows:
|
|
- Red Alert Banner (urgent items requiring immediate attention)
|
|
- Top KPI cards (complaints, actions, surveys, etc.) with drill-down
|
|
- Charts (trends, satisfaction, leaderboards)
|
|
- Live feed (latest complaints, actions, events)
|
|
- Enhanced modules (Inquiries, Observations)
|
|
- Filters (date range, hospital, department)
|
|
|
|
Follows the "5-Second Rule": Critical signals dominant and comprehensible within 5 seconds.
|
|
Uses modular tile/card system with 30-60 second auto-refresh capability.
|
|
"""
|
|
|
|
template_name = "dashboard/command_center.html"
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
"""Check user type and redirect accordingly"""
|
|
# Redirect Source Users to their dashboard
|
|
if request.user.is_authenticated and request.user.is_source_user():
|
|
return redirect("px_sources:source_user_dashboard")
|
|
|
|
# Check PX Admin has selected a hospital before processing request
|
|
if request.user.is_authenticated and request.user.is_px_admin() and not request.tenant_hospital:
|
|
return redirect("core:select_hospital")
|
|
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
user = self.request.user
|
|
|
|
# Import models
|
|
from apps.complaints.models import Complaint, Inquiry
|
|
from apps.px_action_center.models import PXAction
|
|
from apps.surveys.models import SurveyInstance
|
|
from apps.callcenter.models import CallCenterInteraction
|
|
from apps.integrations.models import InboundEvent
|
|
from apps.physicians.models import PhysicianMonthlyRating
|
|
from apps.organizations.models import Staff
|
|
from apps.observations.models import Observation
|
|
|
|
# Date filters
|
|
now = timezone.now()
|
|
last_24h = now - timedelta(hours=24)
|
|
last_7d = now - timedelta(days=7)
|
|
last_30d = now - timedelta(days=30)
|
|
last_60d = now - timedelta(days=60)
|
|
|
|
# Base querysets (filtered by user role and tenant_hospital)
|
|
if user.is_px_admin():
|
|
# PX Admins use their selected hospital from session
|
|
hospital = self.request.tenant_hospital
|
|
complaints_qs = Complaint.objects.filter(hospital=hospital) if hospital else Complaint.objects.none()
|
|
inquiries_qs = Inquiry.objects.filter(hospital=hospital) if hospital else Inquiry.objects.none()
|
|
actions_qs = PXAction.objects.filter(hospital=hospital) if hospital else PXAction.objects.none()
|
|
surveys_qs = (
|
|
SurveyInstance.objects.filter(hospital=hospital) if hospital else SurveyInstance.objects.none()
|
|
) # Filter by tenant hospital
|
|
calls_qs = (
|
|
CallCenterInteraction.objects.filter(hospital=hospital)
|
|
if hospital
|
|
else CallCenterInteraction.objects.none()
|
|
)
|
|
observations_qs = Observation.objects.filter(hospital=hospital) if hospital else Observation.objects.none()
|
|
elif user.is_hospital_admin() and user.hospital:
|
|
complaints_qs = Complaint.objects.filter(hospital=user.hospital)
|
|
inquiries_qs = Inquiry.objects.filter(hospital=user.hospital)
|
|
actions_qs = PXAction.objects.filter(hospital=user.hospital)
|
|
surveys_qs = SurveyInstance.objects.filter(hospital=user.hospital)
|
|
calls_qs = CallCenterInteraction.objects.filter(hospital=user.hospital)
|
|
observations_qs = Observation.objects.filter(hospital=user.hospital)
|
|
elif user.is_department_manager() and user.department:
|
|
complaints_qs = Complaint.objects.filter(department=user.department)
|
|
inquiries_qs = Inquiry.objects.filter(department=user.department)
|
|
actions_qs = PXAction.objects.filter(department=user.department)
|
|
surveys_qs = SurveyInstance.objects.filter(journey_stage_instance__department=user.department)
|
|
calls_qs = CallCenterInteraction.objects.filter(department=user.department)
|
|
observations_qs = Observation.objects.filter(assigned_department=user.department)
|
|
else:
|
|
complaints_qs = Complaint.objects.none()
|
|
inquiries_qs = Inquiry.objects.none()
|
|
actions_qs = PXAction.objects.none()
|
|
surveys_qs = SurveyInstance.objects.none()
|
|
calls_qs = CallCenterInteraction.objects.none()
|
|
observations_qs = Observation.objects.none()
|
|
|
|
# ========================================
|
|
# RED ALERT ITEMS (5-Second Rule)
|
|
# ========================================
|
|
red_alerts = []
|
|
|
|
# Critical complaints
|
|
critical_complaints = complaints_qs.filter(severity="critical", status__in=["open", "in_progress"]).count()
|
|
if critical_complaints > 0:
|
|
red_alerts.append(
|
|
{
|
|
"type": "critical_complaints",
|
|
"label": _("Critical Complaints"),
|
|
"value": critical_complaints,
|
|
"icon": "alert-octagon",
|
|
"color": "red",
|
|
"url": f"{reverse('complaints:complaint_list')}?severity=critical&status=open,in_progress",
|
|
"priority": 1,
|
|
}
|
|
)
|
|
|
|
# Overdue complaints
|
|
overdue_complaints = complaints_qs.filter(is_overdue=True, status__in=["open", "in_progress"]).count()
|
|
if overdue_complaints > 0:
|
|
red_alerts.append(
|
|
{
|
|
"type": "overdue_complaints",
|
|
"label": _("Overdue Complaints"),
|
|
"value": overdue_complaints,
|
|
"icon": "clock",
|
|
"color": "orange",
|
|
"url": f"{reverse('complaints:complaint_list')}?is_overdue=true&status=open,in_progress",
|
|
"priority": 2,
|
|
}
|
|
)
|
|
|
|
# Escalated actions
|
|
escalated_actions = actions_qs.filter(escalation_level__gt=0, status__in=["open", "in_progress"]).count()
|
|
if escalated_actions > 0:
|
|
red_alerts.append(
|
|
{
|
|
"type": "escalated_actions",
|
|
"label": _("Escalated Actions"),
|
|
"value": escalated_actions,
|
|
"icon": "arrow-up-circle",
|
|
"color": "red",
|
|
"url": reverse("actions:action_list"),
|
|
"priority": 3,
|
|
}
|
|
)
|
|
|
|
# Negative surveys in last 24h
|
|
negative_surveys_24h = surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count()
|
|
if negative_surveys_24h > 0:
|
|
red_alerts.append(
|
|
{
|
|
"type": "negative_surveys",
|
|
"label": _("Negative Surveys (24h)"),
|
|
"value": negative_surveys_24h,
|
|
"icon": "frown",
|
|
"color": "yellow",
|
|
"url": reverse("surveys:instance_list"),
|
|
"priority": 4,
|
|
}
|
|
)
|
|
|
|
# Sort by priority
|
|
red_alerts.sort(key=lambda x: x["priority"])
|
|
context["red_alerts"] = red_alerts
|
|
context["has_red_alerts"] = len(red_alerts) > 0
|
|
|
|
# ========================================
|
|
# COMPLAINTS MODULE DATA
|
|
# ========================================
|
|
complaints_current = complaints_qs.filter(created_at__gte=last_30d).count()
|
|
complaints_previous = complaints_qs.filter(created_at__gte=last_60d, created_at__lt=last_30d).count()
|
|
complaints_variance = 0
|
|
if complaints_previous > 0:
|
|
complaints_variance = round(((complaints_current - complaints_previous) / complaints_previous) * 100, 1)
|
|
|
|
# Resolution time calculation
|
|
resolved_complaints = complaints_qs.filter(status="closed", closed_at__isnull=False, created_at__gte=last_30d)
|
|
avg_resolution_hours = 0
|
|
if resolved_complaints.exists():
|
|
total_hours = sum((c.closed_at - c.created_at).total_seconds() / 3600 for c in resolved_complaints)
|
|
avg_resolution_hours = round(total_hours / resolved_complaints.count(), 1)
|
|
|
|
# Complaints by severity for donut chart
|
|
complaints_by_severity = {
|
|
"critical": complaints_qs.filter(severity="critical", status__in=["open", "in_progress"]).count(),
|
|
"high": complaints_qs.filter(severity="high", status__in=["open", "in_progress"]).count(),
|
|
"medium": complaints_qs.filter(severity="medium", status__in=["open", "in_progress"]).count(),
|
|
"low": complaints_qs.filter(severity="low", status__in=["open", "in_progress"]).count(),
|
|
}
|
|
|
|
# Complaints by department for heatmap
|
|
complaints_by_department = list(
|
|
complaints_qs.filter(status__in=["open", "in_progress"], department__isnull=False)
|
|
.values("department__name")
|
|
.annotate(count=Count("id"))
|
|
.order_by("-count")[:10]
|
|
)
|
|
|
|
context["complaints_module"] = {
|
|
"total_active": complaints_qs.filter(status__in=["open", "in_progress"]).count(),
|
|
"current_period": complaints_current,
|
|
"previous_period": complaints_previous,
|
|
"variance": complaints_variance,
|
|
"variance_direction": "up" if complaints_variance > 0 else "down" if complaints_variance < 0 else "neutral",
|
|
"avg_resolution_hours": avg_resolution_hours,
|
|
"overdue": complaints_qs.filter(is_overdue=True, status__in=["open", "in_progress"]).count(),
|
|
"by_severity": complaints_by_severity,
|
|
"by_department": complaints_by_department,
|
|
"critical_new": complaints_qs.filter(severity="critical", created_at__gte=last_24h).count(),
|
|
}
|
|
|
|
# ========================================
|
|
# SURVEY INSIGHTS MODULE DATA
|
|
# ========================================
|
|
surveys_completed_30d = surveys_qs.filter(completed_at__gte=last_30d)
|
|
total_surveys_30d = surveys_completed_30d.count()
|
|
|
|
# Calculate average satisfaction
|
|
avg_satisfaction = (
|
|
surveys_completed_30d.filter(total_score__isnull=False).aggregate(Avg("total_score"))["total_score__avg"]
|
|
or 0
|
|
)
|
|
|
|
# NPS-style calculation (promoters - detractors)
|
|
positive_count = surveys_completed_30d.filter(is_negative=False).count()
|
|
negative_count = surveys_completed_30d.filter(is_negative=True).count()
|
|
nps_score = 0
|
|
if total_surveys_30d > 0:
|
|
nps_score = round(((positive_count - negative_count) / total_surveys_30d) * 100)
|
|
|
|
# Response rate (completed vs sent in last 30 days)
|
|
surveys_sent_30d = surveys_qs.filter(sent_at__gte=last_30d).count()
|
|
response_rate = 0
|
|
if surveys_sent_30d > 0:
|
|
response_rate = round((total_surveys_30d / surveys_sent_30d) * 100, 1)
|
|
|
|
context["survey_module"] = {
|
|
"avg_satisfaction": round(avg_satisfaction, 1),
|
|
"nps_score": nps_score,
|
|
"response_rate": response_rate,
|
|
"total_completed": total_surveys_30d,
|
|
"positive_count": positive_count,
|
|
"negative_count": negative_count,
|
|
"neutral_count": total_surveys_30d - positive_count - negative_count,
|
|
"negative_24h": surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count(),
|
|
}
|
|
|
|
# ========================================
|
|
# PX ACTIONS MODULE DATA
|
|
# ========================================
|
|
actions_open = actions_qs.filter(status="open").count()
|
|
actions_in_progress = actions_qs.filter(status="in_progress").count()
|
|
actions_pending_approval = actions_qs.filter(status="pending_approval").count()
|
|
actions_closed_30d = actions_qs.filter(status="closed", closed_at__gte=last_30d).count()
|
|
|
|
# Time to close calculation
|
|
closed_actions = actions_qs.filter(status="closed", closed_at__isnull=False, created_at__gte=last_30d)
|
|
avg_time_to_close_hours = 0
|
|
if closed_actions.exists():
|
|
total_hours = sum((a.closed_at - a.created_at).total_seconds() / 3600 for a in closed_actions)
|
|
avg_time_to_close_hours = round(total_hours / closed_actions.count(), 1)
|
|
|
|
# Actions by source for breakdown
|
|
actions_by_source = list(
|
|
actions_qs.filter(status__in=["open", "in_progress"])
|
|
.values("source_type")
|
|
.annotate(count=Count("id"))
|
|
.order_by("-count")
|
|
)
|
|
|
|
context["actions_module"] = {
|
|
"open": actions_open,
|
|
"in_progress": actions_in_progress,
|
|
"pending_approval": actions_pending_approval,
|
|
"overdue": actions_qs.filter(is_overdue=True, status__in=["open", "in_progress"]).count(),
|
|
"escalated": actions_qs.filter(escalation_level__gt=0, status__in=["open", "in_progress"]).count(),
|
|
"closed_30d": actions_closed_30d,
|
|
"avg_time_to_close_hours": avg_time_to_close_hours,
|
|
"by_source": actions_by_source,
|
|
"new_today": actions_qs.filter(created_at__gte=last_24h).count(),
|
|
}
|
|
|
|
# ========================================
|
|
# INQUIRIES MODULE DATA
|
|
# ========================================
|
|
inquiries_open = inquiries_qs.filter(status="open").count()
|
|
inquiries_in_progress = inquiries_qs.filter(status="in_progress").count()
|
|
inquiries_resolved_30d = inquiries_qs.filter(status="resolved", updated_at__gte=last_30d).count()
|
|
|
|
context["inquiries_module"] = {
|
|
"open": inquiries_open,
|
|
"in_progress": inquiries_in_progress,
|
|
"total_active": inquiries_open + inquiries_in_progress,
|
|
"resolved_30d": inquiries_resolved_30d,
|
|
"new_24h": inquiries_qs.filter(created_at__gte=last_24h).count(),
|
|
}
|
|
|
|
# ========================================
|
|
# OBSERVATIONS MODULE DATA
|
|
# ========================================
|
|
observations_new = observations_qs.filter(status="new").count()
|
|
observations_in_progress = observations_qs.filter(status="in_progress").count()
|
|
observations_critical = observations_qs.filter(
|
|
severity="critical", status__in=["new", "triaged", "assigned", "in_progress"]
|
|
).count()
|
|
|
|
# Observations by category
|
|
observations_by_category = list(
|
|
observations_qs.filter(status__in=["new", "triaged", "assigned", "in_progress"])
|
|
.values("category__name_en")
|
|
.annotate(count=Count("id"))
|
|
.order_by("-count")[:5]
|
|
)
|
|
|
|
context["observations_module"] = {
|
|
"new": observations_new,
|
|
"in_progress": observations_in_progress,
|
|
"critical": observations_critical,
|
|
"total_active": observations_new + observations_in_progress,
|
|
"resolved_30d": observations_qs.filter(status="resolved", resolved_at__gte=last_30d).count(),
|
|
"by_category": observations_by_category,
|
|
}
|
|
|
|
# ========================================
|
|
# COMMUNICATION/CALL CENTER MODULE DATA
|
|
# ========================================
|
|
calls_7d = calls_qs.filter(call_started_at__gte=last_7d)
|
|
total_calls = calls_7d.count()
|
|
low_rating_calls = calls_7d.filter(is_low_rating=True).count()
|
|
|
|
context["calls_module"] = {
|
|
"total_7d": total_calls,
|
|
"low_ratings": low_rating_calls,
|
|
"satisfaction_rate": round(((total_calls - low_rating_calls) / total_calls * 100), 1)
|
|
if total_calls > 0
|
|
else 0,
|
|
}
|
|
|
|
# ========================================
|
|
# LEGACY STATS (for backward compatibility)
|
|
# ========================================
|
|
context["stats"] = {
|
|
"total_complaints": context["complaints_module"]["total_active"],
|
|
"avg_resolution_time": f"{avg_resolution_hours}h",
|
|
"satisfaction_score": round(avg_satisfaction, 0),
|
|
"active_actions": context["actions_module"]["open"] + context["actions_module"]["in_progress"],
|
|
"new_today": context["actions_module"]["new_today"],
|
|
}
|
|
|
|
# ========================================
|
|
# LATEST ITEMS FOR LIVE FEED
|
|
# ========================================
|
|
# Latest high severity complaints
|
|
context["latest_complaints"] = (
|
|
complaints_qs.filter(severity__in=["high", "critical"])
|
|
.select_related("patient", "hospital", "department")
|
|
.order_by("-created_at")[:5]
|
|
)
|
|
|
|
# Latest escalated actions
|
|
context["latest_actions"] = (
|
|
actions_qs.filter(escalation_level__gt=0)
|
|
.select_related("hospital", "assigned_to")
|
|
.order_by("-escalated_at")[:5]
|
|
)
|
|
|
|
# Latest inquiries
|
|
context["latest_inquiries"] = (
|
|
inquiries_qs.filter(status__in=["open", "in_progress"])
|
|
.select_related("patient", "hospital", "department")
|
|
.order_by("-created_at")[:5]
|
|
)
|
|
|
|
# Latest observations
|
|
context["latest_observations"] = (
|
|
observations_qs.filter(status__in=["new", "triaged", "assigned"])
|
|
.select_related("hospital", "category")
|
|
.order_by("-created_at")[:5]
|
|
)
|
|
|
|
# Latest integration events
|
|
context["latest_events"] = (
|
|
InboundEvent.objects.filter(status="processed").select_related().order_by("-processed_at")[:10]
|
|
)
|
|
|
|
# ========================================
|
|
# PHYSICIAN LEADERBOARD
|
|
# ========================================
|
|
current_month_ratings = PhysicianMonthlyRating.objects.filter(year=now.year, month=now.month).select_related(
|
|
"staff", "staff__hospital", "staff__department"
|
|
)
|
|
|
|
# Filter by user role
|
|
if user.is_hospital_admin() and user.hospital:
|
|
current_month_ratings = current_month_ratings.filter(staff__hospital=user.hospital)
|
|
elif user.is_department_manager() and user.department:
|
|
current_month_ratings = current_month_ratings.filter(staff__department=user.department)
|
|
|
|
# Top 5 staff this month
|
|
context["top_physicians"] = current_month_ratings.order_by("-average_rating")[:5]
|
|
|
|
# Staff stats
|
|
physician_stats = current_month_ratings.aggregate(
|
|
total_physicians=Count("id"), avg_rating=Avg("average_rating"), total_surveys=Count("total_surveys")
|
|
)
|
|
context["physician_stats"] = physician_stats
|
|
|
|
# ========================================
|
|
# CHART DATA
|
|
# ========================================
|
|
context["chart_data"] = {
|
|
"complaints_trend": json.dumps(self.get_complaints_trend(complaints_qs, last_30d)),
|
|
"complaints_by_severity": json.dumps(complaints_by_severity),
|
|
"survey_satisfaction": avg_satisfaction,
|
|
"nps_trend": json.dumps(self.get_nps_trend(surveys_qs, last_30d)),
|
|
"actions_funnel": json.dumps(
|
|
{
|
|
"open": actions_open,
|
|
"in_progress": actions_in_progress,
|
|
"pending_approval": actions_pending_approval,
|
|
"closed": actions_closed_30d,
|
|
}
|
|
),
|
|
}
|
|
|
|
# Add hospital context
|
|
context["current_hospital"] = self.request.tenant_hospital
|
|
context["is_px_admin"] = user.is_px_admin()
|
|
|
|
# Last updated timestamp
|
|
context["last_updated"] = now.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
return context
|
|
|
|
def get_complaints_trend(self, queryset, start_date):
|
|
"""Get complaints trend data for chart"""
|
|
# Group by day for last 30 days
|
|
data = []
|
|
for i in range(30):
|
|
date = start_date + timedelta(days=i)
|
|
count = queryset.filter(created_at__date=date.date()).count()
|
|
data.append({"date": date.strftime("%Y-%m-%d"), "count": count})
|
|
return data
|
|
|
|
def get_nps_trend(self, queryset, start_date):
|
|
"""Get NPS trend data for chart"""
|
|
data = []
|
|
for i in range(30):
|
|
date = start_date + timedelta(days=i)
|
|
day_surveys = queryset.filter(completed_at__date=date.date())
|
|
total = day_surveys.count()
|
|
if total > 0:
|
|
positive = day_surveys.filter(is_negative=False).count()
|
|
negative = day_surveys.filter(is_negative=True).count()
|
|
nps = round(((positive - negative) / total) * 100)
|
|
else:
|
|
nps = 0
|
|
data.append({"date": date.strftime("%Y-%m-%d"), "nps": nps})
|
|
return data
|
|
|
|
def get_survey_satisfaction(self, queryset, start_date):
|
|
"""Get survey satisfaction averages"""
|
|
return (
|
|
queryset.filter(completed_at__gte=start_date, total_score__isnull=False).aggregate(Avg("total_score"))[
|
|
"total_score__avg"
|
|
]
|
|
or 0
|
|
)
|
|
|
|
|
|
@login_required
|
|
def my_dashboard(request):
|
|
"""
|
|
My Dashboard - Personal view of all assigned items.
|
|
|
|
Shows:
|
|
- Summary cards with statistics
|
|
- Tabbed interface for 6 model types:
|
|
* Complaints
|
|
* Inquiries
|
|
* Observations
|
|
* PX Actions
|
|
* Tasks (QI Project Tasks)
|
|
* Feedback
|
|
- Date range filtering
|
|
- Search and filter controls
|
|
- Export functionality (CSV/Excel)
|
|
- Bulk actions support
|
|
- Charts showing trends
|
|
"""
|
|
# Redirect Source Users to their dashboard
|
|
if request.user.is_source_user():
|
|
return redirect("px_sources:source_user_dashboard")
|
|
|
|
user = request.user
|
|
|
|
# Get selected hospital for PX Admins (from middleware)
|
|
selected_hospital = getattr(request, "tenant_hospital", None)
|
|
|
|
# Get date range filter
|
|
date_range_days = int(request.GET.get("date_range", 30))
|
|
if date_range_days == -1: # All time
|
|
start_date = None
|
|
else:
|
|
start_date = timezone.now() - timedelta(days=date_range_days)
|
|
|
|
# Get active tab
|
|
active_tab = request.GET.get("tab", "complaints")
|
|
|
|
# Get search query
|
|
search_query = request.GET.get("search", "")
|
|
|
|
# Get status filter
|
|
status_filter = request.GET.get("status", "")
|
|
|
|
# Get priority/severity filter
|
|
priority_filter = request.GET.get("priority", "")
|
|
|
|
# Build querysets for all models
|
|
querysets = {}
|
|
|
|
# 1. Complaints
|
|
from apps.complaints.models import Complaint
|
|
|
|
complaints_qs = Complaint.objects.filter(assigned_to=user)
|
|
# Filter by selected hospital for PX Admins
|
|
if selected_hospital:
|
|
complaints_qs = complaints_qs.filter(hospital=selected_hospital)
|
|
if start_date:
|
|
complaints_qs = complaints_qs.filter(created_at__gte=start_date)
|
|
if search_query:
|
|
complaints_qs = complaints_qs.filter(Q(title__icontains=search_query) | Q(description__icontains=search_query))
|
|
if status_filter:
|
|
complaints_qs = complaints_qs.filter(status=status_filter)
|
|
if priority_filter:
|
|
complaints_qs = complaints_qs.filter(severity=priority_filter)
|
|
querysets["complaints"] = complaints_qs.select_related(
|
|
"patient", "hospital", "department", "source", "created_by"
|
|
).order_by("-created_at")
|
|
|
|
# 2. Inquiries
|
|
from apps.complaints.models import Inquiry
|
|
|
|
inquiries_qs = Inquiry.objects.filter(assigned_to=user)
|
|
# Filter by selected hospital for PX Admins
|
|
if selected_hospital:
|
|
inquiries_qs = inquiries_qs.filter(hospital=selected_hospital)
|
|
if start_date:
|
|
inquiries_qs = inquiries_qs.filter(created_at__gte=start_date)
|
|
if search_query:
|
|
inquiries_qs = inquiries_qs.filter(Q(subject__icontains=search_query) | Q(message__icontains=search_query))
|
|
if status_filter:
|
|
inquiries_qs = inquiries_qs.filter(status=status_filter)
|
|
querysets["inquiries"] = inquiries_qs.select_related("patient", "hospital", "department").order_by("-created_at")
|
|
|
|
# 3. Observations
|
|
from apps.observations.models... [truncated]
|
|
```
|
|
|
|
|
|
### Tool: Glob: '**/urls.py'
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"pattern": "**/urls.py"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Found 27 file(s) matching "**/urls.py" in the workspace directory, sorted by modification time (newest first):
|
|
---
|
|
/home/ismail/projects/HH/apps/projects/urls.py
|
|
/home/ismail/projects/HH/apps/complaints/urls.py
|
|
/home/ismail/projects/HH/apps/accounts/urls.py
|
|
/home/ismail/projects/HH/apps/ai_engine/urls.py
|
|
/home/ismail/projects/HH/apps/analytics/urls.py
|
|
/home/ismail/projects/HH/apps/appreciation/urls.py
|
|
/home/ismail/projects/HH/apps/callcenter/urls.py
|
|
/home/ismail/projects/HH/apps/core/urls.py
|
|
/home/ismail/projects/HH/apps/dashboard/urls.py
|
|
/home/ismail/projects/HH/apps/feedback/urls.py
|
|
/home/ismail/projects/HH/apps/integrations/urls.py
|
|
/home/ismail/projects/HH/apps/journeys/urls.py
|
|
/home/ismail/projects/HH/apps/notifications/urls.py
|
|
/home/ismail/projects/HH/apps/observations/urls.py
|
|
/home/ismail/projects/HH/apps/organizations/urls.py
|
|
/home/ismail/projects/HH/apps/physicians/urls.py
|
|
/home/ismail/projects/HH/apps/px_action_center/urls.py
|
|
/home/ismail/projects/HH/apps/px_sources/urls.py
|
|
/home/ismail/projects/HH/apps/rca/urls.py
|
|
/home/ismail/projects/HH/apps/references/urls.py
|
|
/home/ismail/projects/HH/apps/reports/urls.py
|
|
/home/ismail/projects/HH/apps/simulator/urls.py
|
|
/home/ismail/projects/HH/apps/social/urls.py
|
|
/home/ismail/projects/HH/apps/standards/urls.py
|
|
/home/ismail/projects/HH/apps/surveys/urls.py
|
|
/home/ismail/projects/HH/config/urls.py
|
|
/home/ismail/projects/HH/PX360/urls.py
|
|
```
|
|
|
|
|
|
### Tool: ReadFile: apps/ai_engine
|
|
**Status**: failed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/ai_engine"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/ai_engine`
|
|
|
|
**Output:**
|
|
```
|
|
Path is a directory, not a file: /home/ismail/projects/HH/apps/ai_engine
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me examine the AI engine app structure and views to understand current AI capabilities before designing the Executive Summary feature.
|
|
|
|
|
|
### Tool: ListFiles: apps/ai_engine
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"path": "/home/ismail/projects/HH/apps/ai_engine"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Listed 15 item(s) in /home/ismail/projects/HH/apps/ai_engine:
|
|
---
|
|
[DIR] __pycache__
|
|
[DIR] migrations
|
|
[DIR] templatetags
|
|
__init__.py
|
|
admin.py
|
|
apps.py
|
|
forms.py
|
|
models.py
|
|
serializers.py
|
|
services.py
|
|
signals.py
|
|
ui_views.py
|
|
urls.py
|
|
utils.py
|
|
views.py
|
|
```
|
|
|
|
|
|
### Tool: ReadFile: apps/ai_engine/views.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/ai_engine/views.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/ai_engine/views.py`
|
|
|
|
**Output:**
|
|
```python
|
|
"""
|
|
AI Engine API views
|
|
"""
|
|
import time
|
|
|
|
from django.db.models import Q
|
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
|
from rest_framework import status, viewsets
|
|
from rest_framework.decorators import action, api_view, permission_classes
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
|
|
from apps.accounts.permissions import IsPXAdmin, IsHospitalAdmin
|
|
|
|
from .models import SentimentResult
|
|
from .serializers import (
|
|
AnalyzeTextRequestSerializer,
|
|
AnalyzeTextResponseSerializer,
|
|
BatchAnalyzeRequestSerializer,
|
|
BatchAnalyzeResponseSerializer,
|
|
SentimentResultSerializer,
|
|
SentimentStatsSerializer,
|
|
)
|
|
from .services import AIEngineService
|
|
|
|
|
|
@extend_schema_view(
|
|
list=extend_schema(
|
|
summary="List sentiment results",
|
|
description="Get a list of all sentiment analysis results with filtering options"
|
|
),
|
|
retrieve=extend_schema(
|
|
summary="Get sentiment result",
|
|
description="Get details of a specific sentiment analysis result"
|
|
),
|
|
)
|
|
class SentimentResultViewSet(viewsets.ReadOnlyModelViewSet):
|
|
"""
|
|
Sentiment result viewset - Read-only API for sentiment results.
|
|
|
|
Provides:
|
|
- List all sentiment results with filters
|
|
- Retrieve specific sentiment result
|
|
- Statistics endpoint
|
|
"""
|
|
serializer_class = SentimentResultSerializer
|
|
permission_classes = [IsAuthenticated]
|
|
filterset_fields = ['sentiment', 'language', 'ai_service']
|
|
search_fields = ['text']
|
|
ordering_fields = ['created_at', 'sentiment_score', 'confidence']
|
|
ordering = ['-created_at']
|
|
|
|
def get_queryset(self):
|
|
"""Filter queryset based on user permissions"""
|
|
queryset = SentimentResult.objects.select_related('content_type').all()
|
|
|
|
# Apply filters from query params
|
|
sentiment = self.request.query_params.get('sentiment')
|
|
if sentiment:
|
|
queryset = queryset.filter(sentiment=sentiment)
|
|
|
|
language = self.request.query_params.get('language')
|
|
if language:
|
|
queryset = queryset.filter(language=language)
|
|
|
|
ai_service = self.request.query_params.get('ai_service')
|
|
if ai_service:
|
|
queryset = queryset.filter(ai_service=ai_service)
|
|
|
|
min_confidence = self.request.query_params.get('min_confidence')
|
|
if min_confidence:
|
|
queryset = queryset.filter(confidence__gte=min_confidence)
|
|
|
|
search = self.request.query_params.get('search')
|
|
if search:
|
|
queryset = queryset.filter(text__icontains=search)
|
|
|
|
return queryset
|
|
|
|
@extend_schema(
|
|
summary="Get sentiment statistics",
|
|
description="Get aggregated statistics for sentiment results",
|
|
responses={200: SentimentStatsSerializer}
|
|
)
|
|
@action(detail=False, methods=['get'])
|
|
def stats(self, request):
|
|
"""Get sentiment statistics"""
|
|
queryset = self.get_queryset()
|
|
stats = AIEngineService.get_sentiment_stats(queryset)
|
|
serializer = SentimentStatsSerializer(stats)
|
|
return Response(serializer.data)
|
|
|
|
|
|
@extend_schema(
|
|
summary="Analyze text",
|
|
description="Analyze text for sentiment, keywords, entities, and emotions",
|
|
request=AnalyzeTextRequestSerializer,
|
|
responses={200: AnalyzeTextResponseSerializer}
|
|
)
|
|
@api_view(['POST'])
|
|
@permission_classes([IsAuthenticated])
|
|
def analyze_text(request):
|
|
"""
|
|
Analyze text for sentiment.
|
|
|
|
POST /api/ai-engine/analyze/
|
|
|
|
Request body:
|
|
{
|
|
"text": "The service was excellent!",
|
|
"language": "en", // optional, auto-detected if not provided
|
|
"extract_keywords": true,
|
|
"extract_entities": true,
|
|
"detect_emotions": true
|
|
}
|
|
|
|
Response:
|
|
{
|
|
"text": "The service was excellent!",
|
|
"language": "en",
|
|
"sentiment": "positive",
|
|
"sentiment_score": 0.8,
|
|
"confidence": 0.9,
|
|
"keywords": ["excellent"],
|
|
"entities": [],
|
|
"emotions": {"joy": 0.6},
|
|
"ai_service": "stub",
|
|
"ai_model": "keyword_matching_v1",
|
|
"processing_time_ms": 5
|
|
}
|
|
"""
|
|
serializer = AnalyzeTextRequestSerializer(data=request.data)
|
|
|
|
if not serializer.is_valid():
|
|
return Response(
|
|
serializer.errors,
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
# Perform analysis
|
|
result = AIEngineService.sentiment.analyze_text(
|
|
text=serializer.validated_data['text'],
|
|
language=serializer.validated_data.get('language'),
|
|
extract_keywords=serializer.validated_data.get('extract_keywords', True),
|
|
extract_entities=serializer.validated_data.get('extract_entities', True),
|
|
detect_emotions=serializer.validated_data.get('detect_emotions', True),
|
|
)
|
|
|
|
response_serializer = AnalyzeTextResponseSerializer(result)
|
|
return Response(response_serializer.data)
|
|
|
|
|
|
@extend_schema(
|
|
summary="Analyze batch of texts",
|
|
description="Analyze multiple texts in a single request",
|
|
request=BatchAnalyzeRequestSerializer,
|
|
responses={200: BatchAnalyzeResponseSerializer}
|
|
)
|
|
@api_view(['POST'])
|
|
@permission_classes([IsAuthenticated])
|
|
def analyze_batch(request):
|
|
"""
|
|
Analyze multiple texts in batch.
|
|
|
|
POST /api/ai-engine/analyze-batch/
|
|
|
|
Request body:
|
|
{
|
|
"texts": [
|
|
"The service was excellent!",
|
|
"Very disappointed with the wait time.",
|
|
"Average experience overall."
|
|
],
|
|
"language": "en" // optional
|
|
}
|
|
|
|
Response:
|
|
{
|
|
"results": [
|
|
{
|
|
"text": "The service was excellent!",
|
|
"sentiment": "positive",
|
|
...
|
|
},
|
|
...
|
|
],
|
|
"total": 3,
|
|
"processing_time_ms": 15
|
|
}
|
|
"""
|
|
serializer = BatchAnalyzeRequestSerializer(data=request.data)
|
|
|
|
if not serializer.is_valid():
|
|
return Response(
|
|
serializer.errors,
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
start_time = time.time()
|
|
|
|
# Perform batch analysis
|
|
results = AIEngineService.sentiment.analyze_batch(
|
|
texts=serializer.validated_data['texts'],
|
|
language=serializer.validated_data.get('language'),
|
|
)
|
|
|
|
processing_time_ms = int((time.time() - start_time) * 1000)
|
|
|
|
response_data = {
|
|
'results': results,
|
|
'total': len(results),
|
|
'processing_time_ms': processing_time_ms,
|
|
}
|
|
|
|
response_serializer = BatchAnalyzeResponseSerializer(response_data)
|
|
return Response(response_serializer.data)
|
|
|
|
|
|
@extend_schema(
|
|
summary="Get sentiment for object",
|
|
description="Get sentiment analysis result for a specific object",
|
|
responses={200: SentimentResultSerializer}
|
|
)
|
|
@api_view(['GET'])
|
|
@permission_classes([IsAuthenticated])
|
|
def get_sentiment_for_object(request, content_type_id, object_id):
|
|
"""
|
|
Get sentiment result for a specific object.
|
|
|
|
GET /api/ai-engine/sentiment/{content_type_id}/{object_id}/
|
|
"""
|
|
from django.contrib.contenttypes.models import ContentType
|
|
|
|
try:
|
|
content_type = ContentType.objects.get(id=content_type_id)
|
|
except ContentType.DoesNotExist:
|
|
return Response(
|
|
{'error': 'Content type not found'},
|
|
status=status.HTTP_404_NOT_FOUND
|
|
)
|
|
|
|
sentiment_result = SentimentResult.objects.filter(
|
|
content_type=content_type,
|
|
object_id=object_id
|
|
).first()
|
|
|
|
if not sentiment_result:
|
|
return Response(
|
|
{'error': 'Sentiment result not found'},
|
|
status=status.HTTP_404_NOT_FOUND
|
|
)
|
|
|
|
serializer = SentimentResultSerializer(sentiment_result)
|
|
return Response(serializer.data)
|
|
|
|
```
|
|
|
|
|
|
### Tool: ReadFile: config/urls.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/config/urls.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/config/urls.py`
|
|
|
|
**Output:**
|
|
```python
|
|
"""
|
|
URL configuration for PX360 project.
|
|
"""
|
|
|
|
from django.conf import settings
|
|
from django.conf.urls.static import static
|
|
from django.contrib import admin
|
|
from django.urls import include, path
|
|
from django.conf.urls.i18n import i18n_patterns
|
|
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
|
from apps.organizations.views import api_patient_search, api_staff_search
|
|
|
|
urlpatterns = [
|
|
# Language switching
|
|
path("i18n/", include("django.conf.urls.i18n")),
|
|
# Admin
|
|
path("admin/", admin.site.urls),
|
|
# Dashboard
|
|
path("", include("apps.dashboard.urls")),
|
|
# Health check endpoint
|
|
path("health/", include("apps.core.urls", namespace="health")),
|
|
# Core pages (public submissions, hospital selection)
|
|
path("core/", include("apps.core.urls", namespace="core")),
|
|
# UI Pages
|
|
path("complaints/", include("apps.complaints.urls")),
|
|
path("inquiries/", include("apps.complaints.urls_inquiries")),
|
|
path("physicians/", include("apps.physicians.urls", namespace="physicians")),
|
|
path("feedback/", include("apps.feedback.urls")),
|
|
path("actions/", include("apps.px_action_center.urls")),
|
|
path("accounts/", include("apps.accounts.urls", namespace="accounts")),
|
|
path("journeys/", include("apps.journeys.urls")),
|
|
path("surveys/", include("apps.surveys.urls")),
|
|
path("social/", include("apps.social.urls")),
|
|
path("callcenter/", include("apps.callcenter.urls")),
|
|
path("analytics/", include("apps.analytics.urls")),
|
|
path("organizations/", include("apps.organizations.urls")),
|
|
path("projects/", include("apps.projects.urls")),
|
|
path("config/", include("apps.core.config_urls")),
|
|
path("ai-engine/", include("apps.ai_engine.urls")),
|
|
path("appreciation/", include("apps.appreciation.urls", namespace="appreciation")),
|
|
path("notifications/", include("apps.notifications.urls", namespace="notifications")),
|
|
path("observations/", include("apps.observations.urls")),
|
|
path("px-sources/", include("apps.px_sources.urls")),
|
|
path("references/", include("apps.references.urls", namespace="references")),
|
|
path("standards/", include("apps.standards.urls")),
|
|
path("reports/", include("apps.reports.urls", namespace="reports")),
|
|
path("rca/", include("apps.rca.urls", namespace="rca")),
|
|
# API endpoints
|
|
path("api/auth/", include("apps.accounts.urls", namespace="api_auth")),
|
|
path("api/physicians/", include("apps.physicians.urls", namespace="api_physicians")),
|
|
path("api/integrations/", include("apps.integrations.urls")),
|
|
path("api/notifications/", include("apps.notifications.urls", namespace="api_notifications")),
|
|
path("api/v1/appreciation/", include("apps.appreciation.urls", namespace="api_appreciation")),
|
|
path("api/simulator/", include("apps.simulator.urls", namespace="api_simulator")),
|
|
path("api/patients/search/", api_patient_search, name="api_patient_search"),
|
|
path("api/staffs/search/", api_staff_search, name="api_staff_search"),
|
|
# OpenAPI/Swagger documentation
|
|
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
|
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
|
|
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
|
|
]
|
|
|
|
# Serve media files in development
|
|
if settings.DEBUG:
|
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
|
|
|
# Customize admin site
|
|
admin.site.site_header = "PX360 Administration"
|
|
admin.site.site_title = "PX360 Admin Portal"
|
|
admin.site.index_title = "Welcome to PX360 Administration"
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me examine the AI engine services, models, and settings to understand the existing AI capabilities and LLM configuration available for building the Executive Summary feature.
|
|
|
|
|
|
### Tool: ReadFile: apps/ai_engine/services.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/ai_engine/services.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/ai_engine/services.py`
|
|
|
|
**Output:**
|
|
```python
|
|
"""
|
|
AI Engine services - Sentiment analysis and NLP
|
|
|
|
This module provides AI services for:
|
|
- Sentiment analysis (positive, neutral, negative)
|
|
- Keyword extraction
|
|
- Entity recognition
|
|
- Emotion detection
|
|
- Language detection
|
|
|
|
Currently uses a stub implementation that can be replaced with:
|
|
- OpenAI API
|
|
- Azure Cognitive Services
|
|
- AWS Comprehend
|
|
- Custom ML models
|
|
"""
|
|
import re
|
|
import time
|
|
from decimal import Decimal
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.db import transaction
|
|
|
|
from .models import SentimentResult
|
|
|
|
|
|
class SentimentAnalysisService:
|
|
"""
|
|
Sentiment analysis service with stub implementation.
|
|
|
|
This service provides realistic sentiment analysis without external API calls.
|
|
Replace the stub methods with real AI service calls when ready.
|
|
"""
|
|
|
|
# Positive keywords (English and Arabic)
|
|
POSITIVE_KEYWORDS = {
|
|
'en': [
|
|
'excellent', 'great', 'good', 'wonderful', 'amazing', 'fantastic',
|
|
'outstanding', 'superb', 'perfect', 'best', 'love', 'happy',
|
|
'satisfied', 'pleased', 'thank', 'appreciate', 'helpful', 'kind',
|
|
'professional', 'caring', 'friendly', 'clean', 'comfortable'
|
|
],
|
|
'ar': [
|
|
'ممتاز', 'رائع', 'جيد', 'جميل', 'مذهل', 'رائع',
|
|
'متميز', 'ممتاز', 'مثالي', 'أفضل', 'أحب', 'سعيد',
|
|
'راض', 'مسرور', 'شكر', 'أقدر', 'مفيد', 'لطيف',
|
|
'محترف', 'مهتم', 'ودود', 'نظيف', 'مريح'
|
|
]
|
|
}
|
|
|
|
# Negative keywords (English and Arabic)
|
|
NEGATIVE_KEYWORDS = {
|
|
'en': [
|
|
'bad', 'terrible', 'horrible', 'awful', 'poor', 'worst',
|
|
'disappointed', 'unhappy', 'unsatisfied', 'angry', 'frustrated',
|
|
'rude', 'unprofessional', 'dirty', 'uncomfortable', 'painful',
|
|
'long wait', 'delayed', 'ignored', 'neglected', 'complaint'
|
|
],
|
|
'ar': [
|
|
'سيء', 'فظيع', 'مروع', 'سيء', 'ضعيف', 'أسوأ',
|
|
'خائب', 'غير سعيد', 'غير راض', 'غاضب', 'محبط',
|
|
'وقح', 'غير محترف', 'قذر', 'غير مريح', 'مؤلم',
|
|
'انتظار طويل', 'متأخر', 'تجاهل', 'مهمل', 'شكوى'
|
|
]
|
|
}
|
|
|
|
# Emotion keywords
|
|
EMOTION_KEYWORDS = {
|
|
'joy': ['happy', 'joy', 'pleased', 'delighted', 'سعيد', 'فرح', 'مسرور'],
|
|
'anger': ['angry', 'furious', 'mad', 'غاضب', 'غضب', 'حنق'],
|
|
'sadness': ['sad', 'unhappy', 'disappointed', 'حزين', 'خائب', 'محبط'],
|
|
'fear': ['afraid', 'scared', 'worried', 'خائف', 'قلق', 'مذعور'],
|
|
'surprise': ['surprised', 'shocked', 'amazed', 'متفاجئ', 'مندهش', 'مذهول'],
|
|
}
|
|
|
|
@classmethod
|
|
def detect_language(cls, text: str) -> str:
|
|
"""
|
|
Detect language of text (English or Arabic).
|
|
|
|
Simple detection based on character ranges.
|
|
"""
|
|
# Count Arabic characters
|
|
arabic_chars = len(re.findall(r'[\u0600-\u06FF]', text))
|
|
# Count English characters
|
|
english_chars = len(re.findall(r'[a-zA-Z]', text))
|
|
|
|
if arabic_chars > english_chars:
|
|
return 'ar'
|
|
return 'en'
|
|
|
|
@classmethod
|
|
def extract_keywords(cls, text: str, language: str, max_keywords: int = 10) -> List[str]:
|
|
"""
|
|
Extract keywords from text.
|
|
|
|
Stub implementation: Returns words that appear in positive/negative keyword lists.
|
|
Replace with proper NLP keyword extraction (TF-IDF, RAKE, etc.)
|
|
"""
|
|
text_lower = text.lower()
|
|
keywords = []
|
|
|
|
# Check positive keywords
|
|
for keyword in cls.POSITIVE_KEYWORDS.get(language, []):
|
|
if keyword in text_lower:
|
|
keywords.append(keyword)
|
|
|
|
# Check negative keywords
|
|
for keyword in cls.NEGATIVE_KEYWORDS.get(language, []):
|
|
if keyword in text_lower:
|
|
keywords.append(keyword)
|
|
|
|
return keywords[:max_keywords]
|
|
|
|
@classmethod
|
|
def extract_entities(cls, text: str, language: str) -> List[Dict[str, str]]:
|
|
"""
|
|
Extract named entities from text.
|
|
|
|
Stub implementation: Returns basic pattern matching.
|
|
Replace with proper NER (spaCy, Stanford NER, etc.)
|
|
"""
|
|
entities = []
|
|
|
|
# Simple email detection
|
|
emails = re.findall(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', text)
|
|
for email in emails:
|
|
entities.append({'text': email, 'type': 'EMAIL'})
|
|
|
|
# Simple phone detection
|
|
phones = re.findall(r'\b\d{10,}\b', text)
|
|
for phone in phones:
|
|
entities.append({'text': phone, 'type': 'PHONE'})
|
|
|
|
return entities
|
|
|
|
@classmethod
|
|
def detect_emotions(cls, text: str) -> Dict[str, float]:
|
|
"""
|
|
Detect emotions in text.
|
|
|
|
Stub implementation: Returns emotion scores based on keyword matching.
|
|
Replace with proper emotion detection model.
|
|
"""
|
|
text_lower = text.lower()
|
|
emotions = {}
|
|
|
|
for emotion, keywords in cls.EMOTION_KEYWORDS.items():
|
|
score = 0.0
|
|
for keyword in keywords:
|
|
if keyword in text_lower:
|
|
score += 0.2
|
|
emotions[emotion] = min(score, 1.0)
|
|
|
|
return emotions
|
|
|
|
@classmethod
|
|
def calculate_sentiment_score(cls, text: str, language: str) -> Tuple[str, float, float]:
|
|
"""
|
|
Calculate sentiment score for text.
|
|
|
|
Returns:
|
|
Tuple of (sentiment, score, confidence)
|
|
- sentiment: 'positive', 'neutral', or 'negative'
|
|
- score: float from -1 (very negative) to 1 (very positive)
|
|
- confidence: float from 0 to 1
|
|
|
|
Stub implementation: Uses keyword matching.
|
|
Replace with ML model (BERT, RoBERTa, etc.)
|
|
"""
|
|
text_lower = text.lower()
|
|
|
|
# Count positive and negative keywords
|
|
positive_count = 0
|
|
negative_count = 0
|
|
|
|
for keyword in cls.POSITIVE_KEYWORDS.get(language, []):
|
|
positive_count += text_lower.count(keyword)
|
|
|
|
for keyword in cls.NEGATIVE_KEYWORDS.get(language, []):
|
|
negative_count += text_lower.count(keyword)
|
|
|
|
# Calculate score
|
|
total_keywords = positive_count + negative_count
|
|
|
|
if total_keywords == 0:
|
|
# No sentiment keywords found - neutral
|
|
return 'neutral', 0.0, 0.5
|
|
|
|
# Calculate sentiment score (-1 to 1)
|
|
score = (positive_count - negative_count) / max(total_keywords, 1)
|
|
|
|
# Determine sentiment category
|
|
if score > 0.2:
|
|
sentiment = 'positive'
|
|
elif score < -0.2:
|
|
sentiment = 'negative'
|
|
else:
|
|
sentiment = 'neutral'
|
|
|
|
# Calculate confidence (higher when more keywords found)
|
|
confidence = min(total_keywords / 10.0, 1.0)
|
|
confidence = max(confidence, 0.3) # Minimum confidence
|
|
|
|
return sentiment, score, confidence
|
|
|
|
@classmethod
|
|
def analyze_text(
|
|
cls,
|
|
text: str,
|
|
language: Optional[str] = None,
|
|
extract_keywords: bool = True,
|
|
extract_entities: bool = True,
|
|
detect_emotions: bool = True
|
|
) -> Dict:
|
|
"""
|
|
Perform complete sentiment analysis on text.
|
|
|
|
Args:
|
|
text: Text to analyze
|
|
language: Language code ('en' or 'ar'), auto-detected if None
|
|
extract_keywords: Whether to extract keywords
|
|
extract_entities: Whether to extract entities
|
|
detect_emotions: Whether to detect emotions
|
|
|
|
Returns:
|
|
Dictionary with analysis results
|
|
"""
|
|
start_time = time.time()
|
|
|
|
# Detect language if not provided
|
|
if language is None:
|
|
language = cls.detect_language(text)
|
|
|
|
# Calculate sentiment
|
|
sentiment, score, confidence = cls.calculate_sentiment_score(text, language)
|
|
|
|
# Extract additional features
|
|
keywords = []
|
|
if extract_keywords:
|
|
keywords = cls.extract_keywords(text, language)
|
|
|
|
entities = []
|
|
if extract_entities:
|
|
entities = cls.extract_entities(text, language)
|
|
|
|
emotions = {}
|
|
if detect_emotions:
|
|
emotions = cls.detect_emotions(text)
|
|
|
|
# Calculate processing time
|
|
processing_time_ms = int((time.time() - start_time) * 1000)
|
|
|
|
return {
|
|
'text': text,
|
|
'language': language,
|
|
'sentiment': sentiment,
|
|
'sentiment_score': score,
|
|
'confidence': confidence,
|
|
'keywords': keywords,
|
|
'entities': entities,
|
|
'emotions': emotions,
|
|
'ai_service': 'stub',
|
|
'ai_model': 'keyword_matching_v1',
|
|
'processing_time_ms': processing_time_ms,
|
|
}
|
|
|
|
@classmethod
|
|
@transaction.atomic
|
|
def analyze_and_save(
|
|
cls,
|
|
text: str,
|
|
content_object,
|
|
language: Optional[str] = None,
|
|
**kwargs
|
|
) -> SentimentResult:
|
|
"""
|
|
Analyze text and save result to database.
|
|
|
|
Args:
|
|
text: Text to analyze
|
|
content_object: Django model instance to link to
|
|
language: Language code ('en' or 'ar'), auto-detected if None
|
|
**kwargs: Additional arguments for analyze_text
|
|
|
|
Returns:
|
|
SentimentResult instance
|
|
"""
|
|
# Perform analysis
|
|
analysis = cls.analyze_text(text, language, **kwargs)
|
|
|
|
# Get content type
|
|
content_type = ContentType.objects.get_for_model(content_object)
|
|
|
|
# Create sentiment result
|
|
sentiment_result = SentimentResult.objects.create(
|
|
content_type=content_type,
|
|
object_id=content_object.id,
|
|
text=analysis['text'],
|
|
language=analysis['language'],
|
|
sentiment=analysis['sentiment'],
|
|
sentiment_score=Decimal(str(analysis['sentiment_score'])),
|
|
confidence=Decimal(str(analysis['confidence'])),
|
|
keywords=analysis['keywords'],
|
|
entities=analysis['entities'],
|
|
emotions=analysis['emotions'],
|
|
ai_service=analysis['ai_service'],
|
|
ai_model=analysis['ai_model'],
|
|
processing_time_ms=analysis['processing_time_ms'],
|
|
)
|
|
|
|
return sentiment_result
|
|
|
|
@classmethod
|
|
def analyze_batch(cls, texts: List[str], language: Optional[str] = None) -> List[Dict]:
|
|
"""
|
|
Analyze multiple texts in batch.
|
|
|
|
Args:
|
|
texts: List of texts to analyze
|
|
language: Language code ('en' or 'ar'), auto-detected if None
|
|
|
|
Returns:
|
|
List of analysis results
|
|
"""
|
|
results = []
|
|
for text in texts:
|
|
result = cls.analyze_text(text, language)
|
|
results.append(result)
|
|
return results
|
|
|
|
|
|
class AIEngineService:
|
|
"""
|
|
Main AI Engine service - facade for all AI capabilities.
|
|
"""
|
|
|
|
sentiment = SentimentAnalysisService
|
|
|
|
@classmethod
|
|
def get_sentiment_for_object(cls, content_object) -> Optional[SentimentResult]:
|
|
"""
|
|
Get the most recent sentiment result for an object.
|
|
"""
|
|
content_type = ContentType.objects.get_for_model(content_object)
|
|
return SentimentResult.objects.filter(
|
|
content_type=content_type,
|
|
object_id=content_object.id
|
|
).first()
|
|
|
|
@classmethod
|
|
def get_sentiment_stats(cls, queryset=None) -> Dict:
|
|
"""
|
|
Get sentiment statistics.
|
|
|
|
Args:
|
|
queryset: Optional SentimentResult queryset to filter
|
|
|
|
Returns:
|
|
Dictionary with statistics
|
|
"""
|
|
if queryset is None:
|
|
queryset = SentimentResult.objects.all()
|
|
|
|
total = queryset.count()
|
|
|
|
if total == 0:
|
|
return {
|
|
'total': 0,
|
|
'positive': 0,
|
|
'neutral': 0,
|
|
'negative': 0,
|
|
'positive_pct': 0,
|
|
'neutral_pct': 0,
|
|
'negative_pct': 0,
|
|
'avg_score': 0,
|
|
'avg_confidence': 0,
|
|
}
|
|
|
|
positive = queryset.filter(sentiment='positive').count()
|
|
neutral = queryset.filter(sentiment='neutral').count()
|
|
negative = queryset.filter(sentiment='negative').count()
|
|
|
|
# Calculate averages
|
|
from django.db.models import Avg
|
|
avg_score = queryset.aggregate(Avg('sentiment_score'))['sentiment_score__avg'] or 0
|
|
avg_confidence = queryset.aggregate(Avg('confidence'))['confidence__avg'] or 0
|
|
|
|
return {
|
|
'total': total,
|
|
'positive': positive,
|
|
'neutral': neutral,
|
|
'negative': negative,
|
|
'positive_pct': round((positive / total) * 100, 1),
|
|
'neutral_pct': round((neutral / total) * 100, 1),
|
|
'negative_pct': round((negative / total) * 100, 1),
|
|
'avg_score': float(avg_score),
|
|
'avg_confidence': float(avg_confidence),
|
|
}
|
|
|
|
```
|
|
|
|
|
|
### Tool: ReadFile: apps/ai_engine/models.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/ai_engine/models.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/ai_engine/models.py`
|
|
|
|
**Output:**
|
|
```python
|
|
"""
|
|
AI Engine models - AI sentiment analysis and NLP
|
|
|
|
This module implements AI capabilities:
|
|
- Sentiment analysis for text (complaints, comments, social posts)
|
|
- Generic sentiment result storage
|
|
- Stubbed AI service interface for future integration
|
|
"""
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.db import models
|
|
|
|
from apps.core.models import TimeStampedModel, UUIDModel
|
|
|
|
|
|
class SentimentResult(UUIDModel, TimeStampedModel):
|
|
"""
|
|
AI Sentiment analysis result - linked to any text content.
|
|
|
|
Uses generic foreign key to link to:
|
|
- Complaints
|
|
- Survey responses (text answers)
|
|
- Social media mentions
|
|
- Call center notes
|
|
"""
|
|
# Related object (generic foreign key)
|
|
content_type = models.ForeignKey(
|
|
ContentType,
|
|
on_delete=models.CASCADE
|
|
)
|
|
object_id = models.UUIDField()
|
|
content_object = GenericForeignKey('content_type', 'object_id')
|
|
|
|
# Text analyzed
|
|
text = models.TextField(help_text="Text that was analyzed")
|
|
language = models.CharField(
|
|
max_length=5,
|
|
choices=[('en', 'English'), ('ar', 'Arabic')],
|
|
default='en'
|
|
)
|
|
|
|
# Sentiment result
|
|
sentiment = models.CharField(
|
|
max_length=20,
|
|
choices=[
|
|
('positive', 'Positive'),
|
|
('neutral', 'Neutral'),
|
|
('negative', 'Negative'),
|
|
],
|
|
db_index=True
|
|
)
|
|
|
|
# Sentiment score (-1 to 1, where -1 is very negative, 1 is very positive)
|
|
sentiment_score = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=4,
|
|
help_text="Sentiment score from -1 (negative) to 1 (positive)"
|
|
)
|
|
|
|
# Confidence level (0 to 1)
|
|
confidence = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=4,
|
|
help_text="Confidence level of the sentiment analysis"
|
|
)
|
|
|
|
# AI service information
|
|
ai_service = models.CharField(
|
|
max_length=100,
|
|
default='stub',
|
|
help_text="AI service used (e.g., 'openai', 'azure', 'aws', 'stub')"
|
|
)
|
|
ai_model = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
help_text="Specific AI model used"
|
|
)
|
|
|
|
# Processing metadata
|
|
processing_time_ms = models.IntegerField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Time taken to analyze (milliseconds)"
|
|
)
|
|
|
|
# Additional analysis (optional)
|
|
keywords = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text="Extracted keywords"
|
|
)
|
|
entities = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text="Extracted entities (people, places, etc.)"
|
|
)
|
|
emotions = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text="Emotion scores (joy, anger, sadness, etc.)"
|
|
)
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['sentiment', '-created_at']),
|
|
models.Index(fields=['content_type', 'object_id']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.sentiment} ({self.sentiment_score}) - {self.text[:50]}"
|
|
|
|
```
|
|
|
|
|
|
### Tool: Grep: 'LITELLM|OPENROUTER|AI_MODEL|ai.*model' (filter: '**/settings*.py')
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"pattern": "LITELLM|OPENROUTER|AI_MODEL|ai.*model",
|
|
"glob": "**/settings*.py"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Found 31 matches for pattern "LITELLM|OPENROUTER|AI_MODEL|ai.*model" in the workspace directory (filter: "**/settings*.py"):
|
|
---
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:77: complaint_acknowledgment_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:81: complaint_acknowledgment_sms = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:85: complaint_acknowledgment_whatsapp = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:91: complaint_assigned_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:95: complaint_assigned_sms = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:99: complaint_assigned_whatsapp = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:105: complaint_status_changed_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:109: complaint_status_changed_sms = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:113: complaint_status_changed_whatsapp = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:119: complaint_resolved_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:123: complaint_resolved_sms = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:127: complaint_resolved_whatsapp = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:133: complaint_closed_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:137: complaint_closed_sms = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:141: complaint_closed_whatsapp = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:149: explanation_requested_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:163: explanation_reminder_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:177: explanation_overdue_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:191: explanation_received_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:213: survey_invitation_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:227: survey_reminder_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:241: survey_completed_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:253: action_assigned_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:267: action_due_soon_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:277: action_overdue_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:289: sla_reminder_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:299: sla_breach_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:315: onboarding_invitation_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:329: onboarding_reminder_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:343: onboarding_completion_email = models.BooleanField(
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py:375: retry_failed_notifications = models.BooleanField(
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
The grep search didn't find the LiteLLM/OpenRouter AI configuration, so I need to search for the actual settings file to understand how AI/LLM is configured in this project.
|
|
|
|
|
|
### Tool: Glob: '**/settings.py'
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"pattern": "**/settings.py"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Found 1 file(s) matching "**/settings.py" in the workspace directory, sorted by modification time (newest first):
|
|
---
|
|
/home/ismail/projects/HH/PX360/settings.py
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me search for the LiteLLM/OpenRouter AI configuration in the settings file to understand what AI capabilities are already set up.
|
|
|
|
|
|
### Tool: Grep: 'LITELLM|OPENROUTER|AI.*API|ai.*config' in path '/home/ismail/projects/HH/PX360/settings.py'
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"path": "/home/ismail/projects/HH/PX360/settings.py",
|
|
"limit": 50,
|
|
"pattern": "LITELLM|OPENROUTER|AI.*API|ai.*config"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
No matches found for pattern "LITELLM|OPENROUTER|AI.*API|ai.*config" in path "/home/ismail/projects/HH/PX360/settings.py".
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me search more broadly for any AI-related configuration in the settings file.
|
|
|
|
|
|
### Tool: Grep: 'AI|ai_' in path '/home/ismail/projects/HH/PX360/settings.py'
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"path": "/home/ismail/projects/HH/PX360/settings.py",
|
|
"limit": 30,
|
|
"pattern": "AI|ai_"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Found 1 match for pattern "AI|ai_" in path "/home/ismail/projects/HH/PX360/settings.py":
|
|
---
|
|
/home/ismail/projects/HH/PX360/settings.py:188:EMAIL_LOGO_URL = "http://127.0.0.1:8000/static/img/HH_R_H_Logo.png"
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Need to check requirements.txt to understand what AI/LLM packages are available in the project for building the Executive Summary AI features.
|
|
|
|
|
|
### Tool: ReadFile: requirements.txt
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/requirements.txt"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/requirements.txt`
|
|
|
|
**Output:**
|
|
```
|
|
aiohappyeyeballs==2.6.1
|
|
aiohttp==3.13.3
|
|
aiohttp-retry==2.9.1
|
|
aiosignal==1.4.0
|
|
amqp==5.3.1
|
|
annotated-types==0.7.0
|
|
anyio==4.12.0
|
|
asgiref==3.11.0
|
|
attrs==25.4.0
|
|
billiard==4.2.4
|
|
brotli==1.2.0
|
|
cachetools==6.2.4
|
|
celery==5.6.2
|
|
certifi==2026.1.4
|
|
cffi==2.0.0
|
|
charset-normalizer==3.4.4
|
|
click==8.3.1
|
|
click-didyoumean==0.3.1
|
|
click-plugins==1.1.1.2
|
|
click-repl==0.3.0
|
|
cron-descriptor==1.4.5
|
|
cssselect2==0.8.0
|
|
distro==1.9.0
|
|
django==6.0.1
|
|
django-celery-beat==2.9.0
|
|
django-environ==0.12.0
|
|
django-extensions==4.1
|
|
django-filter==25.1
|
|
django-stubs==5.2.8
|
|
django-stubs-ext==5.2.8
|
|
django-timezone-field==7.2.1
|
|
djangorestframework==3.16.1
|
|
djangorestframework-simplejwt==5.5.1
|
|
djangorestframework-stubs==3.16.6
|
|
drf-spectacular==0.29.0
|
|
et-xmlfile==2.0.0
|
|
fastuuid==0.14.0
|
|
filelock==3.20.2
|
|
fonttools==4.61.1
|
|
frozenlist==1.8.0
|
|
fsspec==2025.12.0
|
|
google-api-core==2.29.0
|
|
google-api-python-client==2.187.0
|
|
google-auth==2.41.1
|
|
google-auth-httplib2==0.3.0
|
|
google-auth-oauthlib==1.2.3
|
|
googleapis-common-protos==1.72.0
|
|
grpcio==1.67.1
|
|
gunicorn==23.0.0
|
|
h11==0.16.0
|
|
hf-xet==1.2.0
|
|
httpcore==1.0.9
|
|
httplib2==0.31.0
|
|
httpx==0.28.1
|
|
huggingface-hub==1.2.3
|
|
idna==3.11
|
|
importlib-metadata==8.7.1
|
|
inflection==0.5.1
|
|
jinja2==3.1.6
|
|
jiter==0.12.0
|
|
jsonschema==4.25.1
|
|
jsonschema-specifications==2025.9.1
|
|
kombu==5.6.2
|
|
litellm==1.80.11
|
|
markdown-it-py==4.0.0
|
|
markupsafe==3.0.3
|
|
mdurl==0.1.2
|
|
multidict==6.7.0
|
|
numpy==2.4.3
|
|
oauthlib==3.3.1
|
|
openai==2.14.0
|
|
openpyxl==3.1.5
|
|
packaging==25.0
|
|
pandas==3.0.1
|
|
pillow==12.1.0
|
|
pip==24.0
|
|
polib==1.2.0
|
|
prompt-toolkit==3.0.52
|
|
propcache==0.4.1
|
|
proto-plus==1.27.0
|
|
protobuf==6.33.3
|
|
psycopg2-binary==2.9.11
|
|
-e file:///home/ismail/projects/HH
|
|
pyasn1==0.6.1
|
|
pyasn1-modules==0.4.2
|
|
pycparser==2.23
|
|
pydantic==2.12.5
|
|
pydantic-core==2.41.5
|
|
pydyf==0.12.1
|
|
pygments==2.19.2
|
|
pyjwt==2.10.1
|
|
pyparsing==3.3.1
|
|
pyphen==0.17.2
|
|
python-crontab==3.3.0
|
|
python-dateutil==2.9.0.post0
|
|
python-dotenv==1.2.1
|
|
pytz==2025.2
|
|
pyyaml==6.0.3
|
|
redis==7.1.0
|
|
referencing==0.37.0
|
|
regex==2025.11.3
|
|
reportlab==4.4.7
|
|
requests==2.32.5
|
|
requests-oauthlib==2.0.0
|
|
rich==14.2.0
|
|
rpds-py==0.30.0
|
|
rsa==4.9.1
|
|
shellingham==1.5.4
|
|
six==1.17.0
|
|
sniffio==1.3.1
|
|
sqlparse==0.5.5
|
|
tiktoken==0.12.0
|
|
tinycss2==1.5.1
|
|
tinyhtml5==2.0.0
|
|
tokenizers==0.22.2
|
|
tqdm==4.67.1
|
|
tweepy==4.16.0
|
|
twilio==9.10.3
|
|
typer-slim==0.21.0
|
|
types-pyyaml==6.0.12.20250915
|
|
types-requests==2.32.4.20250913
|
|
typing-extensions==4.15.0
|
|
typing-inspection==0.4.2
|
|
tzdata==2025.3
|
|
tzlocal==5.3.1
|
|
ua-parser==1.0.1
|
|
ua-parser-builtins==202601
|
|
unidecode==1.4.0
|
|
uritemplate==4.2.0
|
|
urllib3==2.6.2
|
|
user-agents==2.2.0
|
|
vine==5.1.0
|
|
watchdog==6.0.0
|
|
wcwidth==0.2.14
|
|
weasyprint==67.0
|
|
webencodings==0.5.1
|
|
whitenoise==6.11.0
|
|
xlrd==2.0.2
|
|
yarl==1.22.0
|
|
zipp==3.23.0
|
|
zopfli==0.4.0
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
I should explore the dashboard templates to understand the existing UI structure and patterns for designing the Executive Summary page.
|
|
|
|
|
|
## Assistant
|
|
|
|
Perfect! I can see LiteLLM is installed. Now let me check the existing dashboard templates to understand the UI structure better.
|
|
|
|
|
|
### Tool: Glob: '**/dashboard/templates/**/*.html'
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"pattern": "**/dashboard/templates/**/*.html"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
No files found matching pattern "**/dashboard/templates/**/*.html" in the workspace directory
|
|
```
|
|
|
|
|
|
### Tool: ReadFile: apps/dashboard/urls.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/dashboard/urls.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/dashboard/urls.py`
|
|
|
|
**Output:**
|
|
```python
|
|
"""
|
|
Dashboard URLs
|
|
"""
|
|
|
|
from django.urls import path
|
|
|
|
from .views import (
|
|
CommandCenterView,
|
|
my_dashboard,
|
|
dashboard_bulk_action,
|
|
admin_evaluation,
|
|
admin_evaluation_chart_data,
|
|
staff_performance_detail,
|
|
staff_performance_trends,
|
|
department_benchmarks,
|
|
export_staff_performance,
|
|
performance_analytics_api,
|
|
command_center_api,
|
|
employee_evaluation,
|
|
employee_evaluation_data,
|
|
complaint_request_list,
|
|
complaint_request_export,
|
|
)
|
|
|
|
app_name = "dashboard"
|
|
|
|
urlpatterns = [
|
|
path("", CommandCenterView.as_view(), name="command-center"),
|
|
path("api/", command_center_api, name="command_center_api"),
|
|
path("my/", my_dashboard, name="my_dashboard"),
|
|
path("bulk-action/", dashboard_bulk_action, name="bulk_action"),
|
|
# Admin Evaluation
|
|
path("admin-evaluation/", admin_evaluation, name="admin_evaluation"),
|
|
path("admin-evaluation/chart-data/", admin_evaluation_chart_data, name="admin_evaluation_chart_data"),
|
|
# Employee Evaluation (PAD Department Weekly Dashboard)
|
|
path("employee-evaluation/", employee_evaluation, name="employee_evaluation"),
|
|
path("employee-evaluation/data/", employee_evaluation_data, name="employee_evaluation_data"),
|
|
# Enhanced Staff Performance
|
|
path("admin-evaluation/staff/<str:staff_id>/", staff_performance_detail, name="staff_performance_detail"),
|
|
path("admin-evaluation/staff/<str:staff_id>/trends/", staff_performance_trends, name="staff_performance_trends"),
|
|
path("admin-evaluation/benchmarks/", department_benchmarks, name="department_benchmarks"),
|
|
path("admin-evaluation/export/", export_staff_performance, name="export_staff_performance"),
|
|
path("admin-evaluation/analytics/", performance_analytics_api, name="performance_analytics_api"),
|
|
# Step 0 — Complaint Requests Report
|
|
path("complaint-requests/", complaint_request_list, name="complaint_request_list"),
|
|
path("complaint-requests/export/", complaint_request_export, name="complaint_request_export"),
|
|
]
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
I need to find the dashboard templates to understand the existing UI structure and patterns for building the Executive Summary page.
|
|
|
|
|
|
### Tool: Glob: '**/templates/dashboard/*.html'
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"pattern": "**/templates/dashboard/*.html"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Found 8 file(s) matching "**/templates/dashboard/*.html" in the workspace directory, sorted by modification time (newest first):
|
|
---
|
|
/home/ismail/projects/HH/templates/dashboard/analytics-template.html
|
|
/home/ismail/projects/HH/templates/dashboard/admin_evaluation.html
|
|
/home/ismail/projects/HH/templates/dashboard/command_center.html
|
|
/home/ismail/projects/HH/templates/dashboard/complaint_request_list.html
|
|
/home/ismail/projects/HH/templates/dashboard/department_benchmarks.html
|
|
/home/ismail/projects/HH/templates/dashboard/employee_evaluation.html
|
|
/home/ismail/projects/HH/templates/dashboard/my_dashboard.html
|
|
/home/ismail/projects/HH/templates/dashboard/staff_performance_detail.html
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I have enough information about the codebase architecture, AI capabilities, and user requirements to create a comprehensive implementation plan for the Executive Summary feature.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I have enough information to create a comprehensive plan. Let me present it to you.
|
|
|
|
## Executive Summary Feature Plan
|
|
|
|
Based on your requirements and the existing PX360 architecture, here's my comprehensive plan:
|
|
|
|
### **Overview**
|
|
Create an AI-powered Executive Summary system for top management with:
|
|
1. **Executive Dashboard** - Real-time high-level overview across all hospitals
|
|
2. **AI-Generated Narratives** - Automated weekly/monthly performance summaries
|
|
3. **Predictive Insights** - Early warning system and risk detection
|
|
4. **Recommendation Engine** - AI-suggested actions
|
|
5. **Natural Language Q&A** - Ask questions about your data
|
|
6. **PDF Report Generation** - Scheduled executive reports
|
|
7. **Mobile-Optimized View** - On-the-go access
|
|
|
|
---
|
|
|
|
### **Architecture**
|
|
|
|
#### **1. New Role: Executive (C-Suite/Top Management)**
|
|
- System-wide view across all hospitals
|
|
- Can drill down to individual hospitals/departments
|
|
- Read-only access to all metrics
|
|
- Special mobile-optimized interface
|
|
|
|
#### **2. New Django App: `apps/executive_summary/`**
|
|
```
|
|
Models:
|
|
- ExecutiveReport (scheduled AI-generated reports)
|
|
- PredictiveInsight (AI-detected patterns/risks)
|
|
- AIRecommendation (suggested actions)
|
|
- ExecutiveMetric (KPI snapshots for trending)
|
|
|
|
Views:
|
|
- ExecutiveDashboardView (main dashboard)
|
|
- ExecutiveReportAPIView (AI narrative generation)
|
|
- PredictiveInsightsView (risk analysis)
|
|
- ExecutiveQ&AView (natural language queries)
|
|
- ExecutivePDFReportView (PDF generation)
|
|
|
|
Services:
|
|
- ExecutiveSummaryService (data aggregation)
|
|
- AINarrativeService (LLM-powered summaries)
|
|
- PredictiveAnalyticsService (trend/risk detection)
|
|
- RecommendationService (action suggestions)
|
|
- ExecutiveQAService (natural language to queries)
|
|
|
|
Tasks (Celery):
|
|
- generate_weekly_executive_summary
|
|
- generate_monthly_executive_summary
|
|
- calculate_predictive_insights
|
|
- generate_recommendations
|
|
- send_executive_pdf_report
|
|
```
|
|
|
|
#### **3. AI-Powered Features**
|
|
|
|
**A. Automated Narratives** (using LiteLLM + Gemma)
|
|
- Weekly/Monthly natural language summaries
|
|
- Example: "This month, patient complaints decreased 12% across all hospitals. Hospital A showed significant improvement in resolution time (from 48h to 32h). However, we detected an emerging pattern of communication-related complaints in the Emergency department that requires attention."
|
|
- Bilingual (English/Arabic)
|
|
|
|
**B. Predictive Insights**
|
|
- Trend analysis with anomaly detection
|
|
- Risk scoring for hospitals/departments
|
|
- Early warning indicators:
|
|
- Rising complaint categories
|
|
- Declining satisfaction trends
|
|
- SLA breach predictions
|
|
- Staff performance degradation
|
|
|
|
**C. Recommendation Engine**
|
|
- AI-suggested actions based on:
|
|
- Historical resolution patterns
|
|
- Best practices from successful departments
|
|
- Resource allocation optimization
|
|
- Preventive measures for predicted issues
|
|
|
|
**D. Natural Language Q&A**
|
|
- "What was the complaint rate for Hospital A last month?"
|
|
- "Show me departments with declining satisfaction"
|
|
- "Which hospital has the best resolution time?"
|
|
- Converts natural language to Django ORM queries
|
|
|
|
#### **4. Dashboard UI Components**
|
|
|
|
**Executive Summary Page:**
|
|
```
|
|
┌─────────────────────────────────────────────┐
|
|
│ 📊 Executive Summary | All Hospitals │
|
|
│ [Date Range] [Hospital Filter] [Export PDF]│
|
|
├─────────────────────────────────────────────┤
|
|
│ 🤖 AI Executive Brief (Narrative Summary) │
|
|
│ "This week showed overall improvement..." │
|
|
├─────────────────────────────────────────────┤
|
|
│ ⚠️ Risk Alerts | 🔮 Predictions │
|
|
│ • Hospital B: Rising complaints (+23%) │
|
|
│ • Emergency Dept: SLA breach risk (85%) │
|
|
│ • Survey satisfaction trending down │
|
|
├─────────────────────────────────────────────┤
|
|
│ 📈 Key Metrics Cards │
|
|
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
|
│ │Compl.│ │Surveys│ │Actions│ │NPS │ │
|
|
│ │ 1,234│ │ 89% │ │ 456 │ │ +72 │ │
|
|
│ │ ↓8% │ │ ↑3% │ │ ↓12% │ │ ↑5 │ │
|
|
│ └──────┘ └──────┘ └──────┘ └──────┘ │
|
|
├─────────────────────────────────────────────┤
|
|
│ 📊 Charts │
|
|
│ • Multi-hospital comparison trends │
|
|
│ • Complaint category breakdown │
|
|
│ • Satisfaction heatmap by department │
|
|
│ • Resolution time distribution │
|
|
│ • NPS trend across hospitals │
|
|
├─────────────────────────────────────────────┤
|
|
│ 🏆 Hospital Leaderboard │
|
|
│ 1. Hospital A - 92% satisfaction │
|
|
│ 2. Hospital C - 87% satisfaction │
|
|
│ 3. Hospital B - 81% satisfaction ⚠️ │
|
|
├─────────────────────────────────────────────┤
|
|
│ 💡 AI Recommendations │
|
|
│ • Implement staff training in Emergency │
|
|
│ • Review communication protocols │
|
|
│ • Increase survey follow-up rate │
|
|
├─────────────────────────────────────────────┤
|
|
│ ❓ Ask AI (Q&A Input) │
|
|
│ [Type your question...] │
|
|
└─────────────────────────────────────────────┘
|
|
```
|
|
|
|
#### **5. PDF Report Generation**
|
|
- Weekly Executive Summary (auto-generated, emailed)
|
|
- Monthly Deep Analysis Report
|
|
- Custom date range reports
|
|
- Professional formatting with charts
|
|
- Bilingual support
|
|
|
|
#### **6. Implementation Steps**
|
|
|
|
**Phase 1: Foundation** (Week 1)
|
|
1. Create `Executive` role and permissions
|
|
2. Create `apps/executive_summary/` app structure
|
|
3. Build data aggregation service
|
|
4. Create basic dashboard view with metrics
|
|
|
|
**Phase 2: AI Integration** (Week 2)
|
|
5. Implement AI Narrative Service using LiteLLM
|
|
6. Build predictive analytics service
|
|
7. Create recommendation engine
|
|
8. Add AI brief generation to dashboard
|
|
|
|
**Phase 3: Advanced Features** (Week 3)
|
|
9. Implement Natural Language Q&A
|
|
10. Build predictive insights detection
|
|
11. Create hospital comparison views
|
|
12. Add risk scoring system
|
|
|
|
**Phase 4: Reports & Mobile** (Week 4)
|
|
13. Implement PDF report generation (WeasyPrint already installed)
|
|
14. Create Celery scheduled tasks for reports
|
|
15. Build mobile-optimized view
|
|
16. Add email delivery system
|
|
|
|
**Phase 5: Polish** (Week 5)
|
|
17. Add interactive charts (ApexCharts)
|
|
18. Implement real-time updates (HTMX)
|
|
19. Add Arabic translations
|
|
20. Testing and optimization
|
|
|
|
---
|
|
|
|
### **Technical Decisions**
|
|
|
|
1. **AI Model**: Use existing LiteLLM + Gemma setup for narratives
|
|
2. **Data Aggregation**: Pre-compute metrics daily for performance
|
|
3. **Caching**: Redis caching for dashboard data
|
|
4. **PDF**: WeasyPrint (already installed) for report generation
|
|
5. **Charts**: ApexCharts (already in use) for consistency
|
|
6. **Mobile**: Responsive TailwindCSS design
|
|
7. **Async**: Celery for AI generation and reports
|
|
8. **Security**: Role-based access, audit logging
|
|
|
|
---
|
|
|
|
### **Key Benefits**
|
|
|
|
✅ **Leverages existing AI infrastructure** (LiteLLM, Celery, Redis)
|
|
✅ **Consistent with current architecture** (Django patterns, HTMX, ApexCharts)
|
|
✅ **Bilingual** (English/Arabic) for Saudi healthcare context
|
|
✅ **Multi-tenant** (respects existing hospital filtering)
|
|
✅ **Scalable** (async processing, caching)
|
|
✅ **Actionable** (not just data, but insights and recommendations)
|
|
|
|
---
|
|
|
|
Would you like me to proceed with this implementation? I can start with Phase 1 (Foundation) and work through each phase systematically.
|
|
|
|
|
|
### Tool: ExitPlanMode: Plan:
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"plan": "## Executive Summary Implementation Plan\n\n### Overview\nBuild an AI-powered Executive Summary system for top management with real-time dashboards, automated narratives, predictive insights, recommendations, natural language Q&A, and PDF reports.\n\n### Phase 1: Foundation\n1. Create `Executive` role with system-wide access permissions\n2. Create new Django app `apps/executive_summary/`\n3. Build data aggregation service for cross-hospital metrics\n4. Create main Executive Dashboard view with KPI cards, charts, and filters\n\n### Phase 2: AI Integration\n5. Implement AI Narrative Service using existing LiteLLM infrastructure\n6. Build predictive analytics service (trend analysis, risk detection)\n7. Create recommendation engine based on historical patterns\n8. Integrate AI-generated briefs into dashboard\n\n### Phase 3: Advanced Features\n9. Natural Language Q&A interface (convert questions to data queries)\n10. Predictive insights detection and risk scoring\n11. Hospital comparison and leaderboard views\n12. Multi-hospital comparison charts\n\n### Phase 4: Reports & Mobile\n13. PDF report generation using WeasyPrint (weekly/monthly executive reports)\n14. Celery scheduled tasks for automated report generation and email delivery\n15. Mobile-optimized responsive view\n16. Email delivery system for scheduled reports\n\n### Phase 5: Polish\n17. Interactive ApexCharts visualizations\n18. HTMX for real-time updates\n19. Arabic translations and RTL support\n20. Performance optimization and testing\n\n### Technical Approach\n- Use existing LiteLLM + Gemma for AI narratives\n- Leverage Celery + Redis for async processing\n- Use WeasyPrint for PDF generation (already installed)\n- Follow existing Django patterns (CommandCenterView, HTMX, ApexCharts)\n- Maintain multi-tenancy and RBAC consistency\n- Bilingual support (English/Arabic)"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
User has approved your plan. You can now start coding. Start with updating your todo list if applicable.
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now that the plan is approved, I need to create a structured todo list to track all the implementation tasks organized by phase.
|
|
|
|
|
|
## Assistant
|
|
|
|
Excellent! Let me start implementing the Executive Summary feature. I'll create a comprehensive todo list and begin with Phase 1.
|
|
|
|
|
|
### Tool: TodoWrite
|
|
**Status**: completed
|
|
|
|
**Output:**
|
|
```
|
|
- [ ] Create Executive role and permissions
|
|
- [ ] Create executive_summary Django app structure (models, views, services, tasks)
|
|
- [ ] Build ExecutiveMetric and ExecutiveReport models
|
|
- [ ] Create ExecutiveSummaryService for data aggregation
|
|
- [ ] Build Executive Dashboard view with KPI cards and filters
|
|
- [ ] Create Executive Dashboard template with ApexCharts
|
|
- [ ] Add URL routing and sidebar navigation
|
|
- [ ] Implement AI Narrative Service using LiteLLM
|
|
- [ ] Build Predictive Analytics Service (trend analysis, risk detection)
|
|
- [ ] Create Recommendation Engine
|
|
- [ ] Integrate AI-generated briefs into dashboard
|
|
- [ ] Implement Natural Language Q&A interface
|
|
- [ ] Build Predictive Insights view and risk scoring
|
|
- [ ] Create Hospital comparison and leaderboard views
|
|
- [ ] Implement PDF report generation with WeasyPrint
|
|
- [ ] Create Celery scheduled tasks for automated reports
|
|
- [ ] Build mobile-optimized responsive view
|
|
- [ ] Add email delivery system for scheduled reports
|
|
- [ ] Add Arabic translations and RTL support
|
|
- [ ] Performance optimization and testing
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now that the plan is approved and todos are set up, I need to mark the first task as in_progress before starting the implementation.
|
|
|
|
|
|
### Tool: TodoWrite
|
|
**Status**: completed
|
|
|
|
**Output:**
|
|
```
|
|
- [-] Create Executive role and permissions
|
|
- [ ] Create executive_summary Django app structure (models, views, services, tasks)
|
|
- [ ] Build ExecutiveMetric and ExecutiveReport models
|
|
- [ ] Create ExecutiveSummaryService for data aggregation
|
|
- [ ] Build Executive Dashboard view with KPI cards and filters
|
|
- [ ] Create Executive Dashboard template with ApexCharts
|
|
- [ ] Add URL routing and sidebar navigation
|
|
- [ ] Implement AI Narrative Service using LiteLLM
|
|
- [ ] Build Predictive Analytics Service (trend analysis, risk detection)
|
|
- [ ] Create Recommendation Engine
|
|
- [ ] Integrate AI-generated briefs into dashboard
|
|
- [ ] Implement Natural Language Q&A interface
|
|
- [ ] Build Predictive Insights view and risk scoring
|
|
- [ ] Create Hospital comparison and leaderboard views
|
|
- [ ] Implement PDF report generation with WeasyPrint
|
|
- [ ] Create Celery scheduled tasks for automated reports
|
|
- [ ] Build mobile-optimized responsive view
|
|
- [ ] Add email delivery system for scheduled reports
|
|
- [ ] Add Arabic translations and RTL support
|
|
- [ ] Performance optimization and testing
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now that I'm starting work on the Executive role, I need to first read the existing accounts models to understand how roles are defined and how to add the new Executive role.
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me start by reading the existing role/permission structure to add the Executive role properly.
|
|
|
|
|
|
### Tool: ReadFile: apps/accounts/models.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/accounts/models.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/accounts/models.py`
|
|
|
|
**Output:**
|
|
```python
|
|
"""
|
|
Accounts models - Custom User model and roles
|
|
"""
|
|
|
|
import uuid
|
|
|
|
from django.contrib.auth.models import AbstractUser, Group, Permission, BaseUserManager
|
|
from django.db import models
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from apps.core.models import TimeStampedModel, UUIDModel
|
|
|
|
|
|
class UserManager(BaseUserManager):
|
|
"""
|
|
Custom user manager for email-based authentication.
|
|
"""
|
|
|
|
def create_user(self, email, password=None, **extra_fields):
|
|
"""
|
|
Create and save a regular user with the given email and password.
|
|
"""
|
|
if not email:
|
|
raise ValueError("The Email field must be set")
|
|
email = self.normalize_email(email)
|
|
user = self.model(email=email, **extra_fields)
|
|
user.set_password(password)
|
|
user.save(using=self._db)
|
|
return user
|
|
|
|
def create_superuser(self, email, password=None, **extra_fields):
|
|
"""
|
|
Create and save a superuser with the given email and password.
|
|
"""
|
|
extra_fields.setdefault("is_staff", True)
|
|
extra_fields.setdefault("is_superuser", True)
|
|
extra_fields.setdefault("is_active", True)
|
|
|
|
if extra_fields.get("is_staff") is not True:
|
|
raise ValueError("Superuser must have is_staff=True.")
|
|
if extra_fields.get("is_superuser") is not True:
|
|
raise ValueError("Superuser must have is_superuser=True.")
|
|
|
|
return self.create_user(email, password, **extra_fields)
|
|
|
|
|
|
class User(AbstractUser, TimeStampedModel):
|
|
"""
|
|
Custom User model extending Django's AbstractUser.
|
|
Uses UUID as primary key and adds additional fields for PX360.
|
|
"""
|
|
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
|
|
# Override email to make it unique and required
|
|
email = models.EmailField(unique=True)
|
|
|
|
# Override username to be optional and non-unique (for backward compatibility)
|
|
# Note: Using email as USERNAME_FIELD for authentication
|
|
username = models.CharField(max_length=150, blank=True, default="", unique=False)
|
|
|
|
# Use email as username field for authentication
|
|
USERNAME_FIELD = "email"
|
|
|
|
# Required fields when creating superuser
|
|
REQUIRED_FIELDS = ["first_name", "last_name"]
|
|
|
|
# Custom user manager
|
|
objects = UserManager()
|
|
|
|
# Additional fields
|
|
phone = models.CharField(max_length=20, blank=True)
|
|
employee_id = models.CharField(max_length=50, blank=True)
|
|
|
|
# Organization relationships
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital", on_delete=models.SET_NULL, null=True, blank=True, related_name="users"
|
|
)
|
|
department = models.ForeignKey(
|
|
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="users"
|
|
)
|
|
|
|
# Role - using Django's built-in Group for RBAC
|
|
# Groups will represent roles: PX Admin, Hospital Admin, Department Manager, etc.
|
|
|
|
# Profile
|
|
avatar = models.ImageField(upload_to="avatars/", null=True, blank=True)
|
|
bio = models.TextField(blank=True)
|
|
|
|
# Preferences
|
|
language = models.CharField(max_length=5, choices=[("en", "English"), ("ar", "Arabic")], default="en")
|
|
|
|
# Notification preferences
|
|
notification_email_enabled = models.BooleanField(default=True, help_text="Enable email notifications")
|
|
notification_sms_enabled = models.BooleanField(default=False, help_text="Enable SMS notifications")
|
|
preferred_notification_channel = models.CharField(
|
|
max_length=10,
|
|
choices=[("email", "Email"), ("sms", "SMS"), ("both", "Both")],
|
|
default="email",
|
|
help_text="Preferred notification channel for general notifications",
|
|
)
|
|
explanation_notification_channel = models.CharField(
|
|
max_length=10,
|
|
choices=[("email", "Email"), ("sms", "SMS"), ("both", "Both")],
|
|
default="email",
|
|
help_text="Preferred channel for explanation requests",
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
# Onboarding / Acknowledgement
|
|
is_provisional = models.BooleanField(default=False, help_text="User is in onboarding process")
|
|
invitation_token = models.CharField(
|
|
max_length=100, unique=True, null=True, blank=True, help_text="Token for account activation"
|
|
)
|
|
invitation_expires_at = models.DateTimeField(null=True, blank=True, help_text="When the invitation token expires")
|
|
acknowledgement_completed = models.BooleanField(
|
|
default=False, help_text="User has completed acknowledgement wizard"
|
|
)
|
|
acknowledgement_completed_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="When acknowledgement was completed"
|
|
)
|
|
current_wizard_step = models.IntegerField(default=0, help_text="Current step in onboarding wizard")
|
|
wizard_completed_steps = models.JSONField(default=list, blank=True, help_text="List of completed wizard step IDs")
|
|
|
|
class Meta:
|
|
ordering = ["-date_joined"]
|
|
indexes = [
|
|
models.Index(fields=["is_active", "-date_joined"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.get_full_name()} ({self.email})"
|
|
|
|
def get_role_names(self):
|
|
"""Get list of role names for this user"""
|
|
return list(self.groups.values_list("name", flat=True))
|
|
|
|
def has_role(self, role_name):
|
|
"""Check if user has a specific role"""
|
|
return self.groups.filter(name=role_name).exists()
|
|
|
|
def is_px_admin(self):
|
|
"""Check if user is PX Admin"""
|
|
return self.has_role("PX Admin")
|
|
|
|
def is_hospital_admin(self):
|
|
"""Check if user is Hospital Admin"""
|
|
return self.has_role("Hospital Admin")
|
|
|
|
def is_department_manager(self):
|
|
"""Check if user is Department Manager"""
|
|
return self.has_role("Department Manager")
|
|
|
|
def is_px_staff(self):
|
|
"""Check if user is PX Staff"""
|
|
return self.has_role("PX Staff")
|
|
|
|
def is_source_user(self):
|
|
"""Check if user is a PX Source User"""
|
|
return self.has_role("PX Source User")
|
|
|
|
def get_source_user_profile_active(self):
|
|
"""Get active source user profile if exists"""
|
|
if hasattr(self, "source_user_profile"):
|
|
profile = self.source_user_profile
|
|
if profile and profile.is_active:
|
|
return profile
|
|
return None
|
|
|
|
def needs_onboarding(self):
|
|
"""Check if user needs to complete onboarding"""
|
|
return self.is_provisional and not self.acknowledgement_completed
|
|
|
|
def get_onboarding_progress_percentage(self):
|
|
"""Get onboarding progress percentage"""
|
|
from .services import OnboardingService
|
|
|
|
return OnboardingService.get_user_progress_percentage(self)
|
|
|
|
|
|
class AcknowledgementCategory(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Categories/Departments for acknowledgements.
|
|
Admins can add, edit, or deactivate categories.
|
|
Replaces hardcoded department choices with database-driven categories.
|
|
"""
|
|
|
|
name_en = models.CharField(max_length=200)
|
|
name_ar = models.CharField(max_length=200, blank=True, help_text="Arabic name")
|
|
code = models.CharField(max_length=50, unique=True, help_text="Unique code (e.g., CLINICS, ADMISSIONS)")
|
|
description = models.TextField(blank=True, help_text="Category description")
|
|
|
|
# Display & ordering
|
|
icon = models.CharField(max_length=50, blank=True, help_text="Icon class (e.g., 'building', 'user')")
|
|
color = models.CharField(max_length=7, default="#007bbd", help_text="Hex color code")
|
|
order = models.IntegerField(default=0, help_text="Display order")
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True, db_index=True)
|
|
is_default = models.BooleanField(default=False, help_text="One of the 14 default categories")
|
|
|
|
class Meta:
|
|
ordering = ["order", "name_en"]
|
|
verbose_name_plural = "Acknowledgement Categories"
|
|
indexes = [
|
|
models.Index(fields=["is_active", "order"]),
|
|
models.Index(fields=["is_default"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.name_en
|
|
|
|
def get_localized_name(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.name_ar:
|
|
return self.name_ar
|
|
return self.name_en
|
|
|
|
def activate(self):
|
|
self.is_active = True
|
|
self.save(update_fields=["is_active"])
|
|
|
|
def deactivate(self):
|
|
self.is_active = False
|
|
self.save(update_fields=["is_active"])
|
|
|
|
|
|
class AcknowledgementContent(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Acknowledgement content sections.
|
|
Provides bilingual educational content for each acknowledgement category.
|
|
"""
|
|
|
|
# Link to category (replaces role field)
|
|
category = models.ForeignKey(
|
|
AcknowledgementCategory,
|
|
on_delete=models.CASCADE,
|
|
related_name="content_sections",
|
|
help_text="Category this content belongs to",
|
|
)
|
|
|
|
# Content section
|
|
code = models.CharField(max_length=100, unique=True, help_text="Unique code for this content section")
|
|
title_en = models.CharField(max_length=200)
|
|
title_ar = models.CharField(max_length=200, blank=True)
|
|
description_en = models.TextField()
|
|
description_ar = models.TextField(blank=True)
|
|
|
|
# Content details
|
|
content_en = models.TextField(blank=True)
|
|
content_ar = models.TextField(blank=True)
|
|
|
|
# Visual elements
|
|
icon = models.CharField(max_length=50, blank=True, help_text="Icon class (e.g., 'fa-user', 'fa-shield')")
|
|
color = models.CharField(max_length=7, blank=True, help_text="Hex color code (e.g., '#007bff')")
|
|
|
|
# Organization
|
|
order = models.IntegerField(default=0, help_text="Display order")
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["category", "order", "code"]
|
|
indexes = [
|
|
models.Index(fields=["category", "is_active", "order"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.category.name_en} - {self.title_en}"
|
|
|
|
def get_localized_title(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.title_ar:
|
|
return self.title_ar
|
|
return self.title_en
|
|
|
|
def get_localized_description(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.description_ar:
|
|
return self.description_ar
|
|
return self.description_en
|
|
|
|
def get_localized_content(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.content_ar:
|
|
return self.content_ar
|
|
return self.content_en
|
|
|
|
|
|
class AcknowledgementChecklistItem(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Checklist items that users must acknowledge.
|
|
Each item is linked to a category (department).
|
|
"""
|
|
|
|
# Link to category (replaces role field)
|
|
category = models.ForeignKey(
|
|
AcknowledgementCategory,
|
|
on_delete=models.PROTECT,
|
|
related_name="checklist_items",
|
|
help_text="Category/Department this acknowledgement belongs to",
|
|
)
|
|
|
|
# Linked content (optional)
|
|
content = models.ForeignKey(
|
|
AcknowledgementContent,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="checklist_items",
|
|
help_text="Related content section",
|
|
)
|
|
|
|
# Item details
|
|
code = models.CharField(max_length=100, unique=True, help_text="Unique code for this checklist item")
|
|
text_en = models.CharField(max_length=500)
|
|
text_ar = models.CharField(max_length=500, blank=True)
|
|
description_en = models.TextField(blank=True)
|
|
description_ar = models.TextField(blank=True)
|
|
|
|
# Configuration
|
|
is_required = models.BooleanField(default=True, help_text="Item must be acknowledged")
|
|
order = models.IntegerField(default=0, help_text="Display order in checklist")
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["category", "order", "code"]
|
|
indexes = [
|
|
models.Index(fields=["category", "is_active", "order"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.category.name_en} - {self.text_en}"
|
|
|
|
def get_localized_text(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.text_ar:
|
|
return self.text_ar
|
|
return self.text_en
|
|
|
|
def get_localized_description(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.description_ar:
|
|
return self.description_ar
|
|
return self.description_en
|
|
|
|
|
|
class UserAcknowledgement(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Records of user acknowledgements.
|
|
Tracks when each user acknowledges specific items with digital signatures.
|
|
"""
|
|
|
|
# User
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="acknowledgements")
|
|
|
|
# Checklist item
|
|
checklist_item = models.ForeignKey(
|
|
AcknowledgementChecklistItem, on_delete=models.CASCADE, related_name="user_acknowledgements"
|
|
)
|
|
|
|
# Acknowledgement details
|
|
is_acknowledged = models.BooleanField(default=True)
|
|
acknowledged_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
# Digital signature
|
|
signature = models.TextField(blank=True, help_text="Digital signature data (base64 encoded)")
|
|
signature_ip = models.GenericIPAddressField(null=True, blank=True, help_text="IP address when signed")
|
|
signature_user_agent = models.TextField(blank=True, help_text="User agent when signed")
|
|
|
|
# PDF document
|
|
pdf_file = models.FileField(
|
|
upload_to="acknowledgements/pdfs/", null=True, blank=True, help_text="PDF document of signed acknowledgement"
|
|
)
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True, help_text="Additional metadata")
|
|
|
|
class Meta:
|
|
ordering = ["-acknowledged_at"]
|
|
unique_together = [["user", "checklist_item"]]
|
|
indexes = [
|
|
models.Index(fields=["user", "-acknowledged_at"]),
|
|
models.Index(fields=["checklist_item", "-acknowledged_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user.email} - {self.checklist_item.text_en}"
|
|
|
|
|
|
class UserProvisionalLog(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Audit trail for provisional user lifecycle.
|
|
Tracks all key events in the onboarding process.
|
|
"""
|
|
|
|
EVENT_TYPES = [
|
|
("created", _("User Created")),
|
|
("invitation_sent", _("Invitation Sent")),
|
|
("invitation_resent", _("Invitation Resent")),
|
|
("wizard_started", _("Wizard Started")),
|
|
("step_completed", _("Wizard Step Completed")),
|
|
("wizard_completed", _("Wizard Completed")),
|
|
("user_activated", _("User Activated")),
|
|
("invitation_expired", _("Invitation Expired")),
|
|
]
|
|
|
|
# User
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="provisional_logs")
|
|
|
|
# Event details
|
|
event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True)
|
|
description = models.TextField()
|
|
|
|
# Context
|
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
user_agent = models.TextField(blank=True)
|
|
|
|
# Additional data
|
|
metadata = models.JSONField(default=dict, blank=True, help_text="Additional event data")
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["user", "-created_at"]),
|
|
models.Index(fields=["event_type", "-created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user.email} - {self.get_event_type_display()} - {self.created_at}"
|
|
|
|
|
|
class Role(models.Model):
|
|
"""
|
|
Role model for managing predefined roles and their permissions.
|
|
This is a helper model - actual role assignment uses Django Groups.
|
|
"""
|
|
|
|
ROLE_CHOICES = [
|
|
("px_admin", _("PX Admin")),
|
|
("hospital_admin", _("Hospital Admin")),
|
|
("department_manager", _("Department Manager")),
|
|
("px_staff", _("PX Staff")),
|
|
("physician", _("Physician")),
|
|
("nurse", _("Nurse")),
|
|
("staff", _("Staff")),
|
|
("viewer", _("Viewer")),
|
|
]
|
|
|
|
name = models.CharField(max_length=50, unique=True, choices=ROLE_CHOICES)
|
|
display_name = models.CharField(max_length=100)
|
|
description = models.TextField(blank=True)
|
|
|
|
# Link to Django Group
|
|
group = models.OneToOneField(Group, on_delete=models.CASCADE, related_name="role_config")
|
|
|
|
# Permissions
|
|
permissions = models.ManyToManyField(Permission, blank=True)
|
|
|
|
# Hierarchy level (for escalation logic)
|
|
level = models.IntegerField(default=0, help_text="Higher number = higher authority")
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
ordering = ["-level", "name"]
|
|
|
|
def __str__(self):
|
|
return self.display_name
|
|
|
|
|
|
# Import version models to ensure they are registered with Django
|
|
from .version_models import ContentVersion, ChecklistItemVersion, ContentChangeLog
|
|
|
|
|
|
# ============================================================================
|
|
# SIMPLE ACKNOWLEDGEMENT MODELS
|
|
# ============================================================================
|
|
|
|
|
|
class SimpleAcknowledgement(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Simple acknowledgement item that employees must sign.
|
|
"""
|
|
|
|
title = models.CharField(max_length=200, help_text="Acknowledgement title")
|
|
description = models.TextField(blank=True, help_text="Detailed description")
|
|
pdf_document = models.FileField(
|
|
upload_to="acknowledgements/documents/", null=True, blank=True, help_text="PDF document for employees to review"
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True, help_text="Show in employee checklist")
|
|
is_required = models.BooleanField(default=True, help_text="Must be signed by all employees")
|
|
|
|
# Display order
|
|
order = models.IntegerField(default=0)
|
|
|
|
class Meta:
|
|
ordering = ["order", "title"]
|
|
verbose_name = "Acknowledgement"
|
|
verbose_name_plural = "Acknowledgements"
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
@property
|
|
def signed_count(self):
|
|
"""Number of employees who have signed this acknowledgement."""
|
|
return self.employee_signatures.filter(is_signed=True).count()
|
|
|
|
|
|
class EmployeeAcknowledgement(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Records which employees have signed which acknowledgements.
|
|
"""
|
|
|
|
employee = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.CASCADE,
|
|
related_name="employee_acknowledgements",
|
|
help_text="Employee who signed",
|
|
)
|
|
acknowledgement = models.ForeignKey(
|
|
SimpleAcknowledgement,
|
|
on_delete=models.CASCADE,
|
|
related_name="employee_signatures",
|
|
help_text="Acknowledgement that was signed",
|
|
)
|
|
|
|
# Sent tracking
|
|
sent_at = models.DateTimeField(null=True, blank=True, help_text="When acknowledgement was sent to employee")
|
|
sent_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="sent_acknowledgements",
|
|
help_text="Admin who sent this acknowledgement",
|
|
)
|
|
|
|
# Signature details
|
|
is_signed = models.BooleanField(default=False)
|
|
signed_at = models.DateTimeField(null=True, blank=True)
|
|
signature_name = models.CharField(max_length=200, blank=True, help_text="Name used when signing")
|
|
signature_employee_id = models.CharField(max_length=100, blank=True, help_text="Employee ID when signing")
|
|
|
|
# Signed PDF
|
|
signed_pdf = models.FileField(
|
|
upload_to="acknowledgements/signed/%Y/%m/", null=True, blank=True, help_text="PDF with employee signature"
|
|
)
|
|
|
|
# IP and user agent for audit
|
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
user_agent = models.TextField(blank=True)
|
|
|
|
# Notes
|
|
notes = models.TextField(blank=True, help_text="Admin notes about this acknowledgement")
|
|
|
|
class Meta:
|
|
ordering = ["-sent_at", "-signed_at"]
|
|
unique_together = [["employee", "acknowledgement"]]
|
|
verbose_name = "Employee Acknowledgement"
|
|
verbose_name_plural = "Employee Acknowledgements"
|
|
|
|
def __str__(self):
|
|
if self.is_signed:
|
|
return f"✓ {self.employee.get_full_name()} - {self.acknowledgement.title}"
|
|
elif self.sent_at:
|
|
return f"→ {self.employee.get_full_name()} - {self.acknowledgement.title} (Sent)"
|
|
return f"○ {self.employee.get_full_name()} - {self.acknowledgement.title}"
|
|
|
|
```
|
|
|
|
|
|
### Tool: ReadFile: apps/accounts/permissions.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/accounts/permissions.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/accounts/permissions.py`
|
|
|
|
**Output:**
|
|
```python
|
|
"""
|
|
Accounts permissions - RBAC permission classes
|
|
"""
|
|
from rest_framework import permissions
|
|
|
|
|
|
class IsPXAdmin(permissions.BasePermission):
|
|
"""
|
|
Permission class to check if user is PX Admin.
|
|
PX Admins have full system access.
|
|
"""
|
|
message = "You must be a PX Admin to perform this action."
|
|
|
|
def has_permission(self, request, view):
|
|
return request.user and request.user.is_authenticated and request.user.is_px_admin()
|
|
|
|
|
|
class IsHospitalAdmin(permissions.BasePermission):
|
|
"""
|
|
Permission class to check if user is Hospital Admin.
|
|
Hospital Admins have access to their hospital's data.
|
|
"""
|
|
message = "You must be a Hospital Admin to perform this action."
|
|
|
|
def has_permission(self, request, view):
|
|
return request.user and request.user.is_authenticated and request.user.is_hospital_admin()
|
|
|
|
|
|
class IsDepartmentManager(permissions.BasePermission):
|
|
"""
|
|
Permission class to check if user is Department Manager.
|
|
Department Managers have access to their department's data.
|
|
"""
|
|
message = "You must be a Department Manager to perform this action."
|
|
|
|
def has_permission(self, request, view):
|
|
return request.user and request.user.is_authenticated and request.user.is_department_manager()
|
|
|
|
|
|
class IsPXAdminOrHospitalAdmin(permissions.BasePermission):
|
|
"""
|
|
Permission class for PX Admin or Hospital Admin.
|
|
"""
|
|
message = "You must be a PX Admin or Hospital Admin to perform this action."
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
return request.user.is_px_admin() or request.user.is_hospital_admin()
|
|
|
|
|
|
class IsPXAdminOrReadOnly(permissions.BasePermission):
|
|
"""
|
|
Permission class that allows PX Admins full access,
|
|
but only read access for others.
|
|
"""
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# Read permissions for any authenticated user
|
|
if request.method in permissions.SAFE_METHODS:
|
|
return True
|
|
|
|
# Write permissions only for PX Admins
|
|
return request.user.is_px_admin()
|
|
|
|
|
|
class IsOwnerOrPXAdmin(permissions.BasePermission):
|
|
"""
|
|
Permission class that allows users to access their own data,
|
|
or PX Admins to access any data.
|
|
"""
|
|
|
|
def has_object_permission(self, request, view, obj):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# PX Admins can access anything
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
# Users can access their own data
|
|
if hasattr(obj, 'user'):
|
|
return obj.user == request.user
|
|
|
|
return obj == request.user
|
|
|
|
|
|
class HasRolePermission(permissions.BasePermission):
|
|
"""
|
|
Permission class that checks if user has specific role.
|
|
Usage: Set required_roles on the view.
|
|
"""
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
required_roles = getattr(view, 'required_roles', [])
|
|
if not required_roles:
|
|
return True
|
|
|
|
user_roles = request.user.get_role_names()
|
|
return any(role in user_roles for role in required_roles)
|
|
|
|
|
|
class CanAccessHospitalData(permissions.BasePermission):
|
|
"""
|
|
Permission class that checks if user can access hospital data.
|
|
- PX Admins can access all hospitals
|
|
- Hospital Admins can access their own hospital
|
|
- Department Managers can access their hospital
|
|
"""
|
|
|
|
def has_object_permission(self, request, view, obj):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# PX Admins can access all
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
# Get hospital from object
|
|
hospital = None
|
|
if hasattr(obj, 'hospital'):
|
|
hospital = obj.hospital
|
|
elif obj.__class__.__name__ == 'Hospital':
|
|
hospital = obj
|
|
|
|
if not hospital:
|
|
return False
|
|
|
|
# Check if user belongs to this hospital
|
|
return request.user.hospital == hospital
|
|
|
|
|
|
class CanAccessDepartmentData(permissions.BasePermission):
|
|
"""
|
|
Permission class that checks if user can access department data.
|
|
- PX Admins can access all departments
|
|
- Hospital Admins can access departments in their hospital
|
|
- Department Managers can access their own department
|
|
"""
|
|
|
|
def has_object_permission(self, request, view, obj):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# PX Admins can access all
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
# Get department from object
|
|
department = None
|
|
if hasattr(obj, 'department'):
|
|
department = obj.department
|
|
elif obj.__class__.__name__ == 'Department':
|
|
department = obj
|
|
|
|
if not department:
|
|
return False
|
|
|
|
# Hospital Admins can access departments in their hospital
|
|
if request.user.is_hospital_admin() and request.user.hospital == department.hospital:
|
|
return True
|
|
|
|
# Department Managers can access their own department
|
|
if request.user.is_department_manager() and request.user.department == department:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
class IsSourceUser(permissions.BasePermission):
|
|
"""
|
|
Permission class to check if user is a PX Source User.
|
|
Source Users can only access data from their assigned source.
|
|
"""
|
|
message = "You must be a Source User to perform this action."
|
|
|
|
def has_permission(self, request, view):
|
|
return (
|
|
request.user and
|
|
request.user.is_authenticated and
|
|
request.user.is_source_user()
|
|
)
|
|
|
|
|
|
class IsSourceUserOrAdmin(permissions.BasePermission):
|
|
"""
|
|
Permission class for Source Users or Admins.
|
|
Allows access to:
|
|
- PX Admins
|
|
- Hospital Admins
|
|
- Department Managers
|
|
- Active Source Users
|
|
"""
|
|
message = "You must be a Source User or Admin to perform this action."
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# Admins have access
|
|
if (request.user.is_px_admin() or
|
|
request.user.is_hospital_admin() or
|
|
request.user.is_department_manager()):
|
|
return True
|
|
|
|
# Source Users have access
|
|
return request.user.is_source_user()
|
|
|
|
|
|
class IsSourceUserOwnData(permissions.BasePermission):
|
|
"""
|
|
Object-level permission to ensure source users can only access their own data.
|
|
"""
|
|
message = "You can only access data you created."
|
|
|
|
def has_object_permission(self, request, view, obj):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# Admins can access everything
|
|
if (request.user.is_px_admin() or
|
|
request.user.is_hospital_admin() or
|
|
request.user.is_department_manager()):
|
|
return True
|
|
|
|
# Source Users can only access their own created data
|
|
if request.user.is_source_user():
|
|
return getattr(obj, 'created_by', None) == request.user
|
|
|
|
return False
|
|
|
|
|
|
# ==================== Onboarding Permissions ====================
|
|
|
|
class IsProvisionalUser(permissions.BasePermission):
|
|
"""
|
|
Permission class to check if user is provisional (in onboarding).
|
|
"""
|
|
message = "You must be a provisional user to access this resource."
|
|
|
|
def has_permission(self, request, view):
|
|
return (
|
|
request.user and
|
|
request.user.is_authenticated and
|
|
request.user.is_provisional and
|
|
not request.user.acknowledgement_completed
|
|
)
|
|
|
|
|
|
class CanViewOnboarding(permissions.BasePermission):
|
|
"""
|
|
Permission class for viewing onboarding content.
|
|
- Provisional users can view their own onboarding
|
|
- PX Admins can view all onboarding data
|
|
"""
|
|
message = "You do not have permission to view onboarding data."
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# Provisional users can view their own
|
|
if request.user.is_provisional:
|
|
return True
|
|
|
|
# PX Admins can view all
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
class CanManageOnboarding(permissions.BasePermission):
|
|
"""
|
|
Permission class for managing onboarding.
|
|
- PX Admins have full access
|
|
- Hospital Admins can manage onboarding for their hospital
|
|
"""
|
|
message = "You must be a PX Admin or Hospital Admin to manage onboarding."
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# PX Admins have full access
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
# Hospital Admins can manage their hospital
|
|
if request.user.is_hospital_admin():
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
class IsOnboardingOwnerOrAdmin(permissions.BasePermission):
|
|
"""
|
|
Permission class for onboarding object access.
|
|
- Users can access their own onboarding data
|
|
- PX Admins can access all
|
|
"""
|
|
|
|
def has_object_permission(self, request, view, obj):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# PX Admins can access all
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
# Users can access their own data
|
|
if hasattr(obj, 'user'):
|
|
return obj.user == request.user
|
|
|
|
return obj == request.user
|
|
|
|
|
|
class CanManageAcknowledgementContent(permissions.BasePermission):
|
|
"""
|
|
Permission class for managing acknowledgement content and checklist items.
|
|
Only PX Admins can manage these.
|
|
"""
|
|
message = "Only PX Admins can manage acknowledgement content."
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# PX Admins can manage content
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
# Read-only for others
|
|
if request.method in permissions.SAFE_METHODS:
|
|
return True
|
|
|
|
return False
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I understand the role structure - I need to add 'Executive' to the ROLE_CHOICES in the Role model to create the new executive role.
|
|
|
|
|
|
## Assistant
|
|
|
|
Perfect! Now I'll add the Executive role. Let me update the Role model and add the helper method to User:
|
|
|
|
|
|
### Tool: Edit: apps/accounts/models.py: class Role(models.Model):... => class Role(models.Model):...
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/accounts/models.py",
|
|
"new_string": "class Role(models.Model):\n \"\"\"\n Role model for managing predefined roles and their permissions.\n This is a helper model - actual role assignment uses Django Groups.\n \"\"\"\n\n ROLE_CHOICES = [\n (\"px_admin\", _(\"PX Admin\")),\n (\"hospital_admin\", _(\"Hospital Admin\")),\n (\"department_manager\", _(\"Department Manager\")),\n (\"px_staff\", _(\"PX Staff\")),\n (\"physician\", _(\"Physician\")),\n (\"nurse\", _(\"Nurse\")),\n (\"staff\", _(\"Staff\")),\n (\"viewer\", _(\"Viewer\")),\n (\"executive\", _(\"Executive\")),\n ]",
|
|
"old_string": "class Role(models.Model):\n \"\"\"\n Role model for managing predefined roles and their permissions.\n This is a helper model - actual role assignment uses Django Groups.\n \"\"\"\n\n ROLE_CHOICES = [\n (\"px_admin\", _(\"PX Admin\")),\n (\"hospital_admin\", _(\"Hospital Admin\")),\n (\"department_manager\", _(\"Department Manager\")),\n (\"px_staff\", _(\"PX Staff\")),\n (\"physician\", _(\"Physician\")),\n (\"nurse\", _(\"Nurse\")),\n (\"staff\", _(\"Staff\")),\n (\"viewer\", _(\"Viewer\")),\n ]"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/accounts/models.py`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `models.py`:*
|
|
```diff
|
|
"""
|
|
Accounts models - Custom User model and roles
|
|
"""
|
|
|
|
import uuid
|
|
|
|
from django.contrib.auth.models import AbstractUser, Group, Permission, BaseUserManager
|
|
from django.db import models
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from apps.core.models import TimeStampedModel, UUIDModel
|
|
|
|
|
|
class UserManager(BaseUserManager):
|
|
"""
|
|
Custom user manager for email-based authentication.
|
|
"""
|
|
|
|
def create_user(self, email, password=None, **extra_fields):
|
|
"""
|
|
Create and save a regular user with the given email and password.
|
|
"""
|
|
if not email:
|
|
raise ValueError("The Email field must be set")
|
|
email = self.normalize_email(email)
|
|
user = self.model(email=email, **extra_fields)
|
|
user.set_password(password)
|
|
user.save(using=self._db)
|
|
return user
|
|
|
|
def create_superuser(self, email, password=None, **extra_fields):
|
|
"""
|
|
Create and save a superuser with the given email and password.
|
|
"""
|
|
extra_fields.setdefault("is_staff", True)
|
|
extra_fields.setdefault("is_superuser", True)
|
|
extra_fields.setdefault("is_active", True)
|
|
|
|
if extra_fields.get("is_staff") is not True:
|
|
raise ValueError("Superuser must have is_staff=True.")
|
|
if extra_fields.get("is_superuser") is not True:
|
|
raise ValueError("Superuser must have is_superuser=True.")
|
|
|
|
return self.create_user(email, password, **extra_fields)
|
|
|
|
|
|
class User(AbstractUser, TimeStampedModel):
|
|
"""
|
|
Custom User model extending Django's AbstractUser.
|
|
Uses UUID as primary key and adds additional fields for PX360.
|
|
"""
|
|
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
|
|
# Override email to make it unique and required
|
|
email = models.EmailField(unique=True)
|
|
|
|
# Override username to be optional and non-unique (for backward compatibility)
|
|
# Note: Using email as USERNAME_FIELD for authentication
|
|
username = models.CharField(max_length=150, blank=True, default="", unique=False)
|
|
|
|
# Use email as username field for authentication
|
|
USERNAME_FIELD = "email"
|
|
|
|
# Required fields when creating superuser
|
|
REQUIRED_FIELDS = ["first_name", "last_name"]
|
|
|
|
# Custom user manager
|
|
objects = UserManager()
|
|
|
|
# Additional fields
|
|
phone = models.CharField(max_length=20, blank=True)
|
|
employee_id = models.CharField(max_length=50, blank=True)
|
|
|
|
# Organization relationships
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital", on_delete=models.SET_NULL, null=True, blank=True, related_name="users"
|
|
)
|
|
department = models.ForeignKey(
|
|
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="users"
|
|
)
|
|
|
|
# Role - using Django's built-in Group for RBAC
|
|
# Groups will represent roles: PX Admin, Hospital Admin, Department Manager, etc.
|
|
|
|
# Profile
|
|
avatar = models.ImageField(upload_to="avatars/", null=True, blank=True)
|
|
bio = models.TextField(blank=True)
|
|
|
|
# Preferences
|
|
language = models.CharField(max_length=5, choices=[("en", "English"), ("ar", "Arabic")], default="en")
|
|
|
|
# Notification preferences
|
|
notification_email_enabled = models.BooleanField(default=True, help_text="Enable email notifications")
|
|
notification_sms_enabled = models.BooleanField(default=False, help_text="Enable SMS notifications")
|
|
preferred_notification_channel = models.CharField(
|
|
max_length=10,
|
|
choices=[("email", "Email"), ("sms", "SMS"), ("both", "Both")],
|
|
default="email",
|
|
help_text="Preferred notification channel for general notifications",
|
|
)
|
|
explanation_notification_channel = models.CharField(
|
|
max_length=10,
|
|
choices=[("email", "Email"), ("sms", "SMS"), ("both", "Both")],
|
|
default="email",
|
|
help_text="Preferred channel for explanation requests",
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
# Onboarding / Acknowledgement
|
|
is_provisional = models.BooleanField(default=False, help_text="User is in onboarding process")
|
|
invitation_token = models.CharField(
|
|
max_length=100, unique=True, null=True, blank=True, help_text="Token for account activation"
|
|
)
|
|
invitation_expires_at = models.DateTimeField(null=True, blank=True, help_text="When the invitation token expires")
|
|
acknowledgement_completed = models.BooleanField(
|
|
default=False, help_text="User has completed acknowledgement wizard"
|
|
)
|
|
acknowledgement_completed_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="When acknowledgement was completed"
|
|
)
|
|
current_wizard_step = models.IntegerField(default=0, help_text="Current step in onboarding wizard")
|
|
wizard_completed_steps = models.JSONField(default=list, blank=True, help_text="List of completed wizard step IDs")
|
|
|
|
class Meta:
|
|
ordering = ["-date_joined"]
|
|
indexes = [
|
|
models.Index(fields=["is_active", "-date_joined"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.get_full_name()} ({self.email})"
|
|
|
|
def get_role_names(self):
|
|
"""Get list of role names for this user"""
|
|
return list(self.groups.values_list("name", flat=True))
|
|
|
|
def has_role(self, role_name):
|
|
"""Check if user has a specific role"""
|
|
return self.groups.filter(name=role_name).exists()
|
|
|
|
def is_px_admin(self):
|
|
"""Check if user is PX Admin"""
|
|
return self.has_role("PX Admin")
|
|
|
|
def is_hospital_admin(self):
|
|
"""Check if user is Hospital Admin"""
|
|
return self.has_role("Hospital Admin")
|
|
|
|
def is_department_manager(self):
|
|
"""Check if user is Department Manager"""
|
|
return self.has_role("Department Manager")
|
|
|
|
def is_px_staff(self):
|
|
"""Check if user is PX Staff"""
|
|
return self.has_role("PX Staff")
|
|
|
|
def is_source_user(self):
|
|
"""Check if user is a PX Source User"""
|
|
return self.has_role("PX Source User")
|
|
|
|
def get_source_user_profile_active(self):
|
|
"""Get active source user profile if exists"""
|
|
if hasattr(self, "source_user_profile"):
|
|
profile = self.source_user_profile
|
|
if profile and profile.is_active:
|
|
return profile
|
|
return None
|
|
|
|
def needs_onboarding(self):
|
|
"""Check if user needs to complete onboarding"""
|
|
return self.is_provisional and not self.acknowledgement_completed
|
|
|
|
def get_onboarding_progress_percentage(self):
|
|
"""Get onboarding progress percentage"""
|
|
from .services import OnboardingService
|
|
|
|
return OnboardingService.get_user_progress_percentage(self)
|
|
|
|
|
|
class AcknowledgementCategory(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Categories/Departments for acknowledgements.
|
|
Admins can add, edit, or deactivate categories.
|
|
Replaces hardcoded department choices with database-driven categories.
|
|
"""
|
|
|
|
name_en = models.CharField(max_length=200)
|
|
name_ar = models.CharField(max_length=200, blank=True, help_text="Arabic name")
|
|
code = models.CharField(max_length=50, unique=True, help_text="Unique code (e.g., CLINICS, ADMISSIONS)")
|
|
description = models.TextField(blank=True, help_text="Category description")
|
|
|
|
# Display & ordering
|
|
icon = models.CharField(max_length=50, blank=True, help_text="Icon class (e.g., 'building', 'user')")
|
|
color = models.CharField(max_length=7, default="#007bbd", help_text="Hex color code")
|
|
order = models.IntegerField(default=0, help_text="Display order")
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True, db_index=True)
|
|
is_default = models.BooleanField(default=False, help_text="One of the 14 default categories")
|
|
|
|
class Meta:
|
|
ordering = ["order", "name_en"]
|
|
verbose_name_plural = "Acknowledgement Categories"
|
|
indexes = [
|
|
models.Index(fields=["is_active", "order"]),
|
|
models.Index(fields=["is_default"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.name_en
|
|
|
|
def get_localized_name(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.name_ar:
|
|
return self.name_ar
|
|
return self.name_en
|
|
|
|
def activate(self):
|
|
self.is_active = True
|
|
self.save(update_fields=["is_active"])
|
|
|
|
def deactivate(self):
|
|
self.is_active = False
|
|
self.save(update_fields=["is_active"])
|
|
|
|
|
|
class AcknowledgementContent(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Acknowledgement content sections.
|
|
Provides bilingual educational content for each acknowledgement category.
|
|
"""
|
|
|
|
# Link to category (replaces role field)
|
|
category = models.ForeignKey(
|
|
AcknowledgementCategory,
|
|
on_delete=models.CASCADE,
|
|
related_name="content_sections",
|
|
help_text="Category this content belongs to",
|
|
)
|
|
|
|
# Content section
|
|
code = models.CharField(max_length=100, unique=True, help_text="Unique code for this content section")
|
|
title_en = models.CharField(max_length=200)
|
|
title_ar = models.CharField(max_length=200, blank=True)
|
|
description_en = models.TextField()
|
|
description_ar = models.TextField(blank=True)
|
|
|
|
# Content details
|
|
content_en = models.TextField(blank=True)
|
|
content_ar = models.TextField(blank=True)
|
|
|
|
# Visual elements
|
|
icon = models.CharField(max_length=50, blank=True, help_text="Icon class (e.g., 'fa-user', 'fa-shield')")
|
|
color = models.CharField(max_length=7, blank=True, help_text="Hex color code (e.g., '#007bff')")
|
|
|
|
# Organization
|
|
order = models.IntegerField(default=0, help_text="Display order")
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["category", "order", "code"]
|
|
indexes = [
|
|
models.Index(fields=["category", "is_active", "order"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.category.name_en} - {self.title_en}"
|
|
|
|
def get_localized_title(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.title_ar:
|
|
return self.title_ar
|
|
return self.title_en
|
|
|
|
def get_localized_description(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.description_ar:
|
|
return self.description_ar
|
|
return self.description_en
|
|
|
|
def get_localized_content(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.content_ar:
|
|
return self.content_ar
|
|
return self.content_en
|
|
|
|
|
|
class AcknowledgementChecklistItem(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Checklist items that users must acknowledge.
|
|
Each item is linked to a category (department).
|
|
"""
|
|
|
|
# Link to category (replaces role field)
|
|
category = models.ForeignKey(
|
|
AcknowledgementCategory,
|
|
on_delete=models.PROTECT,
|
|
related_name="checklist_items",
|
|
help_text="Category/Department this acknowledgement belongs to",
|
|
)
|
|
|
|
# Linked content (optional)
|
|
content = models.ForeignKey(
|
|
AcknowledgementContent,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="checklist_items",
|
|
help_text="Related content section",
|
|
)
|
|
|
|
# Item details
|
|
code = models.CharField(max_length=100, unique=True, help_text="Unique code for this checklist item")
|
|
text_en = models.CharField(max_length=500)
|
|
text_ar = models.CharField(max_length=500, blank=True)
|
|
description_en = models.TextField(blank=True)
|
|
description_ar = models.TextField(blank=True)
|
|
|
|
# Configuration
|
|
is_required = models.BooleanField(default=True, help_text="Item must be acknowledged")
|
|
order = models.IntegerField(default=0, help_text="Display order in checklist")
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["category", "order", "code"]
|
|
indexes = [
|
|
models.Index(fields=["category", "is_active", "order"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.category.name_en} - {self.text_en}"
|
|
|
|
def get_localized_text(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.text_ar:
|
|
return self.text_ar
|
|
return self.text_en
|
|
|
|
def get_localized_description(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.description_ar:
|
|
return self.description_ar
|
|
return self.description_en
|
|
|
|
|
|
class UserAcknowledgement(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Records of user acknowledgements.
|
|
Tracks when each user acknowledges specific items with digital signatures.
|
|
"""
|
|
|
|
# User
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="acknowledgements")
|
|
|
|
# Checklist item
|
|
checklist_item = models.ForeignKey(
|
|
AcknowledgementChecklistItem, on_delete=models.CASCADE, related_name="user_acknowledgements"
|
|
)
|
|
|
|
# Acknowledgement details
|
|
is_acknowledged = models.BooleanField(default=True)
|
|
acknowledged_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
# Digital signature
|
|
signature = models.TextField(blank=True, help_text="Digital signature data (base64 encoded)")
|
|
signature_ip = models.GenericIPAddressField(null=True, blank=True, help_text="IP address when signed")
|
|
signature_user_agent = models.TextField(blank=True, help_text="User agent when signed")
|
|
|
|
# PDF document
|
|
pdf_file = models.FileField(
|
|
upload_to="acknowledgements/pdfs/", null=True, blank=True, help_text="PDF document of signed acknowledgement"
|
|
)
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True, help_text="Additional metadata")
|
|
|
|
class Meta:
|
|
ordering = ["-acknowledged_at"]
|
|
unique_together = [["user", "checklist_item"]]
|
|
indexes = [
|
|
models.Index(fields=["user", "-acknowledged_at"]),
|
|
models.Index(fields=["checklist_item", "-acknowledged_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user.email} - {self.checklist_item.text_en}"
|
|
|
|
|
|
class UserProvisionalLog(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Audit trail for provisional user lifecycle.
|
|
Tracks all key events in the onboarding process.
|
|
"""
|
|
|
|
EVENT_TYPES = [
|
|
("created", _("User Created")),
|
|
("invitation_sent", _("Invitation Sent")),
|
|
("invitation_resent", _("Invitation Resent")),
|
|
("wizard_started", _("Wizard Started")),
|
|
("step_completed", _("Wizard Step Completed")),
|
|
("wizard_completed", _("Wizard Completed")),
|
|
("user_activated", _("User Activated")),
|
|
("invitation_expired", _("Invitation Expired")),
|
|
]
|
|
|
|
# User
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="provisional_logs")
|
|
|
|
# Event details
|
|
event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True)
|
|
description = models.TextField()
|
|
|
|
# Context
|
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
user_agent = models.TextField(blank=True)
|
|
|
|
# Additional data
|
|
metadata = models.JSONField(default=dict, blank=True, help_text="Additional event data")
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["user", "-created_at"]),
|
|
models.Index(fields=["event_type", "-created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user.email} - {self.get_event_type_display()} - {self.created_at}"
|
|
|
|
|
|
class Role(models.Model):
|
|
"""
|
|
Role model for managing predefined roles and their permissions.
|
|
This is a helper model - actual role assignment uses Django Groups.
|
|
"""
|
|
|
|
ROLE_CHOICES = [
|
|
("px_admin", _("PX Admin")),
|
|
("hospital_admin", _("Hospital Admin")),
|
|
("department_manager", _("Department Manager")),
|
|
("px_staff", _("PX Staff")),
|
|
("physician", _("Physician")),
|
|
("nurse", _("Nurse")),
|
|
("staff", _("Staff")),
|
|
("viewer", _("Viewer")),
|
|
("executive", _("Executive")),
|
|
]
|
|
|
|
name = models.CharField(max_length=50, unique=True, choices=ROLE_CHOICES)
|
|
display_name = models.CharField(max_length=100)
|
|
description = models.TextField(blank=True)
|
|
|
|
# Link to Django Group
|
|
group = models.OneToOneField(Group, on_delete=models.CASCADE, related_name="role_config")
|
|
|
|
# Permissions
|
|
permissions = models.ManyToManyField(Permission, blank=True)
|
|
|
|
# Hierarchy level (for escalation logic)
|
|
level = models.IntegerField(default=0, help_text="Higher number = higher authority")
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
ordering = ["-level", "name"]
|
|
|
|
def __str__(self):
|
|
return self.display_name
|
|
|
|
|
|
# Import version models to ensure they are registered with Django
|
|
from .version_models import ContentVersion, ChecklistItemVersion, ContentChangeLog
|
|
|
|
|
|
# ============================================================================
|
|
# SIMPLE ACKNOWLEDGEMENT MODELS
|
|
# ============================================================================
|
|
|
|
|
|
class SimpleAcknowledgement(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Simple acknowledgement item that employees must sign.
|
|
"""
|
|
|
|
title = models.CharField(max_length=200, help_text="Acknowledgement title")
|
|
description = models.TextField(blank=True, help_text="Detailed description")
|
|
pdf_document = models.FileField(
|
|
upload_to="acknowledgements/documents/", null=True, blank=True, help_text="PDF document for employees to review"
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True, help_text="Show in employee checklist")
|
|
is_required = models.BooleanField(default=True, help_text="Must be signed by all employees")
|
|
|
|
# Display order
|
|
order = models.IntegerField(default=0)
|
|
|
|
class Meta:
|
|
ordering = ["order", "title"]
|
|
verbose_name = "Acknowledgement"
|
|
verbose_name_plural = "Acknowledgements"
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
@property
|
|
def signed_count(self):
|
|
"""Number of employees who have signed this acknowledgement."""
|
|
return self.employee_signatures.filter(is_signed=True).count()
|
|
|
|
|
|
class EmployeeAcknowledgement(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Records which employees have signed which acknowledgements.
|
|
"""
|
|
|
|
employee = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.CASCADE,
|
|
related_name="employee_acknowledgements",
|
|
help_text="Employee who signed",
|
|
)
|
|
acknowledgement = models.ForeignKey(
|
|
SimpleAcknowledgement,
|
|
on_delete=models.CASCADE,
|
|
related_name="employee_signatures",
|
|
help_text="Acknowledgement that was signed",
|
|
)
|
|
|
|
# Sent tracking
|
|
sent_at = models.DateTimeField(null=True, blank=True, help_text="When acknowledgement was sent to employee")
|
|
sent_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="sent_acknowledgements",
|
|
help_text="Admin who sent this acknowledgement",
|
|
)
|
|
|
|
# Signature details
|
|
is_signed = models.BooleanField(default=False)
|
|
signed_at = models.DateTimeField(null=True, blank=True)
|
|
signature_name = models.CharField(max_length=200, blank=True, help_text="Name used when signing")
|
|
signature_employee_id = models.CharField(max_length=100, blank=True, help_text="Employee ID when signing")
|
|
|
|
# Signed PDF
|
|
signed_pdf = models.FileField(
|
|
upload_to="acknowledgements/signed/%Y/%m/", null=True, blank=True, help_text="PDF with employee signature"
|
|
)
|
|
|
|
# IP and user agent for audit
|
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
user_agent = models.TextField(blank=True)
|
|
|
|
# Notes
|
|
notes = models.TextField(blank=True, help_text="Admin notes about this acknowledgement")
|
|
|
|
class Meta:
|
|
ordering = ["-sent_at", "-signed_at"]
|
|
unique_together = [["employee", "acknowledgement"]]
|
|
verbose_name = "Employee Acknowledgement"
|
|
verbose_name_plural = "Employee Acknowledgements"
|
|
|
|
def __str__(self):
|
|
if self.is_signed:
|
|
return f"✓ {self.employee.get_full_name()} - {self.acknowledgement.title}"
|
|
elif self.sent_at:
|
|
return f"→ {self.employee.get_full_name()} - {self.acknowledgement.title} (Sent)"
|
|
return f"○ {self.employee.get_full_name()} - {self.acknowledgement.title}"
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to add the `is_executive()` helper method to the User model to match the pattern of other role checking methods.
|
|
|
|
|
|
### Tool: Edit: apps/accounts/models.py: def is_source_user(self):... => def is_source_user(self):...
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/accounts/models.py",
|
|
"new_string": " def is_source_user(self):\n \"\"\"Check if user is a PX Source User\"\"\"\n return self.has_role(\"PX Source User\")\n\n def is_executive(self):\n \"\"\"Check if user is Executive (C-Suite/Top Management)\"\"\"\n return self.has_role(\"Executive\")",
|
|
"old_string": " def is_source_user(self):\n \"\"\"Check if user is a PX Source User\"\"\"\n return self.has_role(\"PX Source User\")"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/accounts/models.py`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `models.py`:*
|
|
```diff
|
|
"""
|
|
Accounts models - Custom User model and roles
|
|
"""
|
|
|
|
import uuid
|
|
|
|
from django.contrib.auth.models import AbstractUser, Group, Permission, BaseUserManager
|
|
from django.db import models
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from apps.core.models import TimeStampedModel, UUIDModel
|
|
|
|
|
|
class UserManager(BaseUserManager):
|
|
"""
|
|
Custom user manager for email-based authentication.
|
|
"""
|
|
|
|
def create_user(self, email, password=None, **extra_fields):
|
|
"""
|
|
Create and save a regular user with the given email and password.
|
|
"""
|
|
if not email:
|
|
raise ValueError("The Email field must be set")
|
|
email = self.normalize_email(email)
|
|
user = self.model(email=email, **extra_fields)
|
|
user.set_password(password)
|
|
user.save(using=self._db)
|
|
return user
|
|
|
|
def create_superuser(self, email, password=None, **extra_fields):
|
|
"""
|
|
Create and save a superuser with the given email and password.
|
|
"""
|
|
extra_fields.setdefault("is_staff", True)
|
|
extra_fields.setdefault("is_superuser", True)
|
|
extra_fields.setdefault("is_active", True)
|
|
|
|
if extra_fields.get("is_staff") is not True:
|
|
raise ValueError("Superuser must have is_staff=True.")
|
|
if extra_fields.get("is_superuser") is not True:
|
|
raise ValueError("Superuser must have is_superuser=True.")
|
|
|
|
return self.create_user(email, password, **extra_fields)
|
|
|
|
|
|
class User(AbstractUser, TimeStampedModel):
|
|
"""
|
|
Custom User model extending Django's AbstractUser.
|
|
Uses UUID as primary key and adds additional fields for PX360.
|
|
"""
|
|
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
|
|
# Override email to make it unique and required
|
|
email = models.EmailField(unique=True)
|
|
|
|
# Override username to be optional and non-unique (for backward compatibility)
|
|
# Note: Using email as USERNAME_FIELD for authentication
|
|
username = models.CharField(max_length=150, blank=True, default="", unique=False)
|
|
|
|
# Use email as username field for authentication
|
|
USERNAME_FIELD = "email"
|
|
|
|
# Required fields when creating superuser
|
|
REQUIRED_FIELDS = ["first_name", "last_name"]
|
|
|
|
# Custom user manager
|
|
objects = UserManager()
|
|
|
|
# Additional fields
|
|
phone = models.CharField(max_length=20, blank=True)
|
|
employee_id = models.CharField(max_length=50, blank=True)
|
|
|
|
# Organization relationships
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital", on_delete=models.SET_NULL, null=True, blank=True, related_name="users"
|
|
)
|
|
department = models.ForeignKey(
|
|
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="users"
|
|
)
|
|
|
|
# Role - using Django's built-in Group for RBAC
|
|
# Groups will represent roles: PX Admin, Hospital Admin, Department Manager, etc.
|
|
|
|
# Profile
|
|
avatar = models.ImageField(upload_to="avatars/", null=True, blank=True)
|
|
bio = models.TextField(blank=True)
|
|
|
|
# Preferences
|
|
language = models.CharField(max_length=5, choices=[("en", "English"), ("ar", "Arabic")], default="en")
|
|
|
|
# Notification preferences
|
|
notification_email_enabled = models.BooleanField(default=True, help_text="Enable email notifications")
|
|
notification_sms_enabled = models.BooleanField(default=False, help_text="Enable SMS notifications")
|
|
preferred_notification_channel = models.CharField(
|
|
max_length=10,
|
|
choices=[("email", "Email"), ("sms", "SMS"), ("both", "Both")],
|
|
default="email",
|
|
help_text="Preferred notification channel for general notifications",
|
|
)
|
|
explanation_notification_channel = models.CharField(
|
|
max_length=10,
|
|
choices=[("email", "Email"), ("sms", "SMS"), ("both", "Both")],
|
|
default="email",
|
|
help_text="Preferred channel for explanation requests",
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
# Onboarding / Acknowledgement
|
|
is_provisional = models.BooleanField(default=False, help_text="User is in onboarding process")
|
|
invitation_token = models.CharField(
|
|
max_length=100, unique=True, null=True, blank=True, help_text="Token for account activation"
|
|
)
|
|
invitation_expires_at = models.DateTimeField(null=True, blank=True, help_text="When the invitation token expires")
|
|
acknowledgement_completed = models.BooleanField(
|
|
default=False, help_text="User has completed acknowledgement wizard"
|
|
)
|
|
acknowledgement_completed_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="When acknowledgement was completed"
|
|
)
|
|
current_wizard_step = models.IntegerField(default=0, help_text="Current step in onboarding wizard")
|
|
wizard_completed_steps = models.JSONField(default=list, blank=True, help_text="List of completed wizard step IDs")
|
|
|
|
class Meta:
|
|
ordering = ["-date_joined"]
|
|
indexes = [
|
|
models.Index(fields=["is_active", "-date_joined"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.get_full_name()} ({self.email})"
|
|
|
|
def get_role_names(self):
|
|
"""Get list of role names for this user"""
|
|
return list(self.groups.values_list("name", flat=True))
|
|
|
|
def has_role(self, role_name):
|
|
"""Check if user has a specific role"""
|
|
return self.groups.filter(name=role_name).exists()
|
|
|
|
def is_px_admin(self):
|
|
"""Check if user is PX Admin"""
|
|
return self.has_role("PX Admin")
|
|
|
|
def is_hospital_admin(self):
|
|
"""Check if user is Hospital Admin"""
|
|
return self.has_role("Hospital Admin")
|
|
|
|
def is_department_manager(self):
|
|
"""Check if user is Department Manager"""
|
|
return self.has_role("Department Manager")
|
|
|
|
def is_px_staff(self):
|
|
"""Check if user is PX Staff"""
|
|
return self.has_role("PX Staff")
|
|
|
|
def is_source_user(self):
|
|
"""Check if user is a PX Source User"""
|
|
return self.has_role("PX Source User")
|
|
|
|
def is_executive(self):
|
|
"""Check if user is Executive (C-Suite/Top Management)"""
|
|
return self.has_role("Executive")
|
|
|
|
def get_source_user_profile_active(self):
|
|
"""Get active source user profile if exists"""
|
|
if hasattr(self, "source_user_profile"):
|
|
profile = self.source_user_profile
|
|
if profile and profile.is_active:
|
|
return profile
|
|
return None
|
|
|
|
def needs_onboarding(self):
|
|
"""Check if user needs to complete onboarding"""
|
|
return self.is_provisional and not self.acknowledgement_completed
|
|
|
|
def get_onboarding_progress_percentage(self):
|
|
"""Get onboarding progress percentage"""
|
|
from .services import OnboardingService
|
|
|
|
return OnboardingService.get_user_progress_percentage(self)
|
|
|
|
|
|
class AcknowledgementCategory(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Categories/Departments for acknowledgements.
|
|
Admins can add, edit, or deactivate categories.
|
|
Replaces hardcoded department choices with database-driven categories.
|
|
"""
|
|
|
|
name_en = models.CharField(max_length=200)
|
|
name_ar = models.CharField(max_length=200, blank=True, help_text="Arabic name")
|
|
code = models.CharField(max_length=50, unique=True, help_text="Unique code (e.g., CLINICS, ADMISSIONS)")
|
|
description = models.TextField(blank=True, help_text="Category description")
|
|
|
|
# Display & ordering
|
|
icon = models.CharField(max_length=50, blank=True, help_text="Icon class (e.g., 'building', 'user')")
|
|
color = models.CharField(max_length=7, default="#007bbd", help_text="Hex color code")
|
|
order = models.IntegerField(default=0, help_text="Display order")
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True, db_index=True)
|
|
is_default = models.BooleanField(default=False, help_text="One of the 14 default categories")
|
|
|
|
class Meta:
|
|
ordering = ["order", "name_en"]
|
|
verbose_name_plural = "Acknowledgement Categories"
|
|
indexes = [
|
|
models.Index(fields=["is_active", "order"]),
|
|
models.Index(fields=["is_default"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.name_en
|
|
|
|
def get_localized_name(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.name_ar:
|
|
return self.name_ar
|
|
return self.name_en
|
|
|
|
def activate(self):
|
|
self.is_active = True
|
|
self.save(update_fields=["is_active"])
|
|
|
|
def deactivate(self):
|
|
self.is_active = False
|
|
self.save(update_fields=["is_active"])
|
|
|
|
|
|
class AcknowledgementContent(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Acknowledgement content sections.
|
|
Provides bilingual educational content for each acknowledgement category.
|
|
"""
|
|
|
|
# Link to category (replaces role field)
|
|
category = models.ForeignKey(
|
|
AcknowledgementCategory,
|
|
on_delete=models.CASCADE,
|
|
related_name="content_sections",
|
|
help_text="Category this content belongs to",
|
|
)
|
|
|
|
# Content section
|
|
code = models.CharField(max_length=100, unique=True, help_text="Unique code for this content section")
|
|
title_en = models.CharField(max_length=200)
|
|
title_ar = models.CharField(max_length=200, blank=True)
|
|
description_en = models.TextField()
|
|
description_ar = models.TextField(blank=True)
|
|
|
|
# Content details
|
|
content_en = models.TextField(blank=True)
|
|
content_ar = models.TextField(blank=True)
|
|
|
|
# Visual elements
|
|
icon = models.CharField(max_length=50, blank=True, help_text="Icon class (e.g., 'fa-user', 'fa-shield')")
|
|
color = models.CharField(max_length=7, blank=True, help_text="Hex color code (e.g., '#007bff')")
|
|
|
|
# Organization
|
|
order = models.IntegerField(default=0, help_text="Display order")
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["category", "order", "code"]
|
|
indexes = [
|
|
models.Index(fields=["category", "is_active", "order"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.category.name_en} - {self.title_en}"
|
|
|
|
def get_localized_title(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.title_ar:
|
|
return self.title_ar
|
|
return self.title_en
|
|
|
|
def get_localized_description(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.description_ar:
|
|
return self.description_ar
|
|
return self.description_en
|
|
|
|
def get_localized_content(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.content_ar:
|
|
return self.content_ar
|
|
return self.content_en
|
|
|
|
|
|
class AcknowledgementChecklistItem(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Checklist items that users must acknowledge.
|
|
Each item is linked to a category (department).
|
|
"""
|
|
|
|
# Link to category (replaces role field)
|
|
category = models.ForeignKey(
|
|
AcknowledgementCategory,
|
|
on_delete=models.PROTECT,
|
|
related_name="checklist_items",
|
|
help_text="Category/Department this acknowledgement belongs to",
|
|
)
|
|
|
|
# Linked content (optional)
|
|
content = models.ForeignKey(
|
|
AcknowledgementContent,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="checklist_items",
|
|
help_text="Related content section",
|
|
)
|
|
|
|
# Item details
|
|
code = models.CharField(max_length=100, unique=True, help_text="Unique code for this checklist item")
|
|
text_en = models.CharField(max_length=500)
|
|
text_ar = models.CharField(max_length=500, blank=True)
|
|
description_en = models.TextField(blank=True)
|
|
description_ar = models.TextField(blank=True)
|
|
|
|
# Configuration
|
|
is_required = models.BooleanField(default=True, help_text="Item must be acknowledged")
|
|
order = models.IntegerField(default=0, help_text="Display order in checklist")
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ["category", "order", "code"]
|
|
indexes = [
|
|
models.Index(fields=["category", "is_active", "order"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.category.name_en} - {self.text_en}"
|
|
|
|
def get_localized_text(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.text_ar:
|
|
return self.text_ar
|
|
return self.text_en
|
|
|
|
def get_localized_description(self):
|
|
from django.utils.translation import get_language
|
|
|
|
if get_language() == "ar" and self.description_ar:
|
|
return self.description_ar
|
|
return self.description_en
|
|
|
|
|
|
class UserAcknowledgement(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Records of user acknowledgements.
|
|
Tracks when each user acknowledges specific items with digital signatures.
|
|
"""
|
|
|
|
# User
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="acknowledgements")
|
|
|
|
# Checklist item
|
|
checklist_item = models.ForeignKey(
|
|
AcknowledgementChecklistItem, on_delete=models.CASCADE, related_name="user_acknowledgements"
|
|
)
|
|
|
|
# Acknowledgement details
|
|
is_acknowledged = models.BooleanField(default=True)
|
|
acknowledged_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
# Digital signature
|
|
signature = models.TextField(blank=True, help_text="Digital signature data (base64 encoded)")
|
|
signature_ip = models.GenericIPAddressField(null=True, blank=True, help_text="IP address when signed")
|
|
signature_user_agent = models.TextField(blank=True, help_text="User agent when signed")
|
|
|
|
# PDF document
|
|
pdf_file = models.FileField(
|
|
upload_to="acknowledgements/pdfs/", null=True, blank=True, help_text="PDF document of signed acknowledgement"
|
|
)
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True, help_text="Additional metadata")
|
|
|
|
class Meta:
|
|
ordering = ["-acknowledged_at"]
|
|
unique_together = [["user", "checklist_item"]]
|
|
indexes = [
|
|
models.Index(fields=["user", "-acknowledged_at"]),
|
|
models.Index(fields=["checklist_item", "-acknowledged_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user.email} - {self.checklist_item.text_en}"
|
|
|
|
|
|
class UserProvisionalLog(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Audit trail for provisional user lifecycle.
|
|
Tracks all key events in the onboarding process.
|
|
"""
|
|
|
|
EVENT_TYPES = [
|
|
("created", _("User Created")),
|
|
("invitation_sent", _("Invitation Sent")),
|
|
("invitation_resent", _("Invitation Resent")),
|
|
("wizard_started", _("Wizard Started")),
|
|
("step_completed", _("Wizard Step Completed")),
|
|
("wizard_completed", _("Wizard Completed")),
|
|
("user_activated", _("User Activated")),
|
|
("invitation_expired", _("Invitation Expired")),
|
|
]
|
|
|
|
# User
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="provisional_logs")
|
|
|
|
# Event details
|
|
event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True)
|
|
description = models.TextField()
|
|
|
|
# Context
|
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
user_agent = models.TextField(blank=True)
|
|
|
|
# Additional data
|
|
metadata = models.JSONField(default=dict, blank=True, help_text="Additional event data")
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["user", "-created_at"]),
|
|
models.Index(fields=["event_type", "-created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user.email} - {self.get_event_type_display()} - {self.created_at}"
|
|
|
|
|
|
class Role(models.Model):
|
|
"""
|
|
Role model for managing predefined roles and their permissions.
|
|
This is a helper model - actual role assignment uses Django Groups.
|
|
"""
|
|
|
|
ROLE_CHOICES = [
|
|
("px_admin", _("PX Admin")),
|
|
("hospital_admin", _("Hospital Admin")),
|
|
("department_manager", _("Department Manager")),
|
|
("px_staff", _("PX Staff")),
|
|
("physician", _("Physician")),
|
|
("nurse", _("Nurse")),
|
|
("staff", _("Staff")),
|
|
("viewer", _("Viewer")),
|
|
("executive", _("Executive")),
|
|
]
|
|
|
|
name = models.CharField(max_length=50, unique=True, choices=ROLE_CHOICES)
|
|
display_name = models.CharField(max_length=100)
|
|
description = models.TextField(blank=True)
|
|
|
|
# Link to Django Group
|
|
group = models.OneToOneField(Group, on_delete=models.CASCADE, related_name="role_config")
|
|
|
|
# Permissions
|
|
permissions = models.ManyToManyField(Permission, blank=True)
|
|
|
|
# Hierarchy level (for escalation logic)
|
|
level = models.IntegerField(default=0, help_text="Higher number = higher authority")
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
ordering = ["-level", "name"]
|
|
|
|
def __str__(self):
|
|
return self.display_name
|
|
|
|
|
|
# Import version models to ensure they are registered with Django
|
|
from .version_models import ContentVersion, ChecklistItemVersion, ContentChangeLog
|
|
|
|
|
|
# ============================================================================
|
|
# SIMPLE ACKNOWLEDGEMENT MODELS
|
|
# ============================================================================
|
|
|
|
|
|
class SimpleAcknowledgement(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Simple acknowledgement item that employees must sign.
|
|
"""
|
|
|
|
title = models.CharField(max_length=200, help_text="Acknowledgement title")
|
|
description = models.TextField(blank=True, help_text="Detailed description")
|
|
pdf_document = models.FileField(
|
|
upload_to="acknowledgements/documents/", null=True, blank=True, help_text="PDF document for employees to review"
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True, help_text="Show in employee checklist")
|
|
is_required = models.BooleanField(default=True, help_text="Must be signed by all employees")
|
|
|
|
# Display order
|
|
order = models.IntegerField(default=0)
|
|
|
|
class Meta:
|
|
ordering = ["order", "title"]
|
|
verbose_name = "Acknowledgement"
|
|
verbose_name_plural = "Acknowledgements"
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
@property
|
|
def signed_count(self):
|
|
"""Number of employees who have signed this acknowledgement."""
|
|
return self.employee_signatures.filter(is_signed=True).count()
|
|
|
|
|
|
class EmployeeAcknowledgement(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Records which employees have signed which acknowledgements.
|
|
"""
|
|
|
|
employee = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.CASCADE,
|
|
related_name="employee_acknowledgements",
|
|
help_text="Employee who signed",
|
|
)
|
|
acknowledgement = models.ForeignKey(
|
|
SimpleAcknowledgement,
|
|
on_delete=models.CASCADE,
|
|
related_name="employee_signatures",
|
|
help_text="Acknowledgement that was signed",
|
|
)
|
|
|
|
# Sent tracking
|
|
sent_at = models.DateTimeField(null=True, blank=True, help_text="When acknowledgement was sent to employee")
|
|
sent_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="sent_acknowledgements",
|
|
help_text="Admin who sent this acknowledgement",
|
|
)
|
|
|
|
# Signature details
|
|
is_signed = models.BooleanField(default=False)
|
|
signed_at = models.DateTimeField(null=True, blank=True)
|
|
signature_name = models.CharField(max_length=200, blank=True, help_text="Name used when signing")
|
|
signature_employee_id = models.CharField(max_length=100, blank=True, help_text="Employee ID when signing")
|
|
|
|
# Signed PDF
|
|
signed_pdf = models.FileField(
|
|
upload_to="acknowledgements/signed/%Y/%m/", null=True, blank=True, help_text="PDF with employee signature"
|
|
)
|
|
|
|
# IP and user agent for audit
|
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
user_agent = models.TextField(blank=True)
|
|
|
|
# Notes
|
|
notes = models.TextField(blank=True, help_text="Admin notes about this acknowledgement")
|
|
|
|
class Meta:
|
|
ordering = ["-sent_at", "-signed_at"]
|
|
unique_together = [["employee", "acknowledgement"]]
|
|
verbose_name = "Employee Acknowledgement"
|
|
verbose_name_plural = "Employee Acknowledgements"
|
|
|
|
def __str__(self):
|
|
if self.is_signed:
|
|
return f"✓ {self.employee.get_full_name()} - {self.acknowledgement.title}"
|
|
elif self.sent_at:
|
|
return f"→ {self.employee.get_full_name()} - {self.acknowledgement.title} (Sent)"
|
|
return f"○ {self.employee.get_full_name()} - {self.acknowledgement.title}"
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to add the corresponding DRF permission class for Executive role in the permissions file to complete the role setup.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me add the Executive permission class:
|
|
|
|
|
|
### Tool: Edit: apps/accounts/permissions.py: class IsDepartmentManager(perm... => class IsDepartmentManager(perm...
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/accounts/permissions.py",
|
|
"new_string": "class IsDepartmentManager(permissions.BasePermission):\n \"\"\"\n Permission class to check if user is Department Manager.\n Department Managers have access to their department's data.\n \"\"\"\n message = \"You must be a Department Manager to perform this action.\"\n\n def has_permission(self, request, view):\n return request.user and request.user.is_authenticated and request.user.is_department_manager()\n\n\nclass IsExecutive(permissions.BasePermission):\n \"\"\"\n Permission class to check if user is Executive (C-Suite/Top Management).\n Executives have system-wide read-only access with analytics capabilities.\n \"\"\"\n message = \"You must be an Executive to perform this action.\"\n\n def has_permission(self, request, view):\n return request.user and request.user.is_authenticated and request.user.is_executive()",
|
|
"old_string": "class IsDepartmentManager(permissions.BasePermission):\n \"\"\"\n Permission class to check if user is Department Manager.\n Department Managers have access to their department's data.\n \"\"\"\n message = \"You must be a Department Manager to perform this action.\"\n\n def has_permission(self, request, view):\n return request.user and request.user.is_authenticated and request.user.is_department_manager()"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/accounts/permissions.py`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `permissions.py`:*
|
|
```diff
|
|
"""
|
|
Accounts permissions - RBAC permission classes
|
|
"""
|
|
from rest_framework import permissions
|
|
|
|
|
|
class IsPXAdmin(permissions.BasePermission):
|
|
"""
|
|
Permission class to check if user is PX Admin.
|
|
PX Admins have full system access.
|
|
"""
|
|
message = "You must be a PX Admin to perform this action."
|
|
|
|
def has_permission(self, request, view):
|
|
return request.user and request.user.is_authenticated and request.user.is_px_admin()
|
|
|
|
|
|
class IsHospitalAdmin(permissions.BasePermission):
|
|
"""
|
|
Permission class to check if user is Hospital Admin.
|
|
Hospital Admins have access to their hospital's data.
|
|
"""
|
|
message = "You must be a Hospital Admin to perform this action."
|
|
|
|
def has_permission(self, request, view):
|
|
return request.user and request.user.is_authenticated and request.user.is_hospital_admin()
|
|
|
|
|
|
class IsDepartmentManager(permissions.BasePermission):
|
|
"""
|
|
Permission class to check if user is Department Manager.
|
|
Department Managers have access to their department's data.
|
|
"""
|
|
message = "You must be a Department Manager to perform this action."
|
|
|
|
def has_permission(self, request, view):
|
|
return request.user and request.user.is_authenticated and request.user.is_department_manager()
|
|
|
|
|
|
class IsExecutive(permissions.BasePermission):
|
|
"""
|
|
Permission class to check if user is Executive (C-Suite/Top Management).
|
|
Executives have system-wide read-only access with analytics capabilities.
|
|
"""
|
|
message = "You must be an Executive to perform this action."
|
|
|
|
def has_permission(self, request, view):
|
|
return request.user and request.user.is_authenticated and request.user.is_executive()
|
|
|
|
|
|
class IsPXAdminOrHospitalAdmin(permissions.BasePermission):
|
|
"""
|
|
Permission class for PX Admin or Hospital Admin.
|
|
"""
|
|
message = "You must be a PX Admin or Hospital Admin to perform this action."
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
return request.user.is_px_admin() or request.user.is_hospital_admin()
|
|
|
|
|
|
class IsPXAdminOrReadOnly(permissions.BasePermission):
|
|
"""
|
|
Permission class that allows PX Admins full access,
|
|
but only read access for others.
|
|
"""
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# Read permissions for any authenticated user
|
|
if request.method in permissions.SAFE_METHODS:
|
|
return True
|
|
|
|
# Write permissions only for PX Admins
|
|
return request.user.is_px_admin()
|
|
|
|
|
|
class IsOwnerOrPXAdmin(permissions.BasePermission):
|
|
"""
|
|
Permission class that allows users to access their own data,
|
|
or PX Admins to access any data.
|
|
"""
|
|
|
|
def has_object_permission(self, request, view, obj):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# PX Admins can access anything
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
# Users can access their own data
|
|
if hasattr(obj, 'user'):
|
|
return obj.user == request.user
|
|
|
|
return obj == request.user
|
|
|
|
|
|
class HasRolePermission(permissions.BasePermission):
|
|
"""
|
|
Permission class that checks if user has specific role.
|
|
Usage: Set required_roles on the view.
|
|
"""
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
required_roles = getattr(view, 'required_roles', [])
|
|
if not required_roles:
|
|
return True
|
|
|
|
user_roles = request.user.get_role_names()
|
|
return any(role in user_roles for role in required_roles)
|
|
|
|
|
|
class CanAccessHospitalData(permissions.BasePermission):
|
|
"""
|
|
Permission class that checks if user can access hospital data.
|
|
- PX Admins can access all hospitals
|
|
- Hospital Admins can access their own hospital
|
|
- Department Managers can access their hospital
|
|
"""
|
|
|
|
def has_object_permission(self, request, view, obj):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# PX Admins can access all
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
# Get hospital from object
|
|
hospital = None
|
|
if hasattr(obj, 'hospital'):
|
|
hospital = obj.hospital
|
|
elif obj.__class__.__name__ == 'Hospital':
|
|
hospital = obj
|
|
|
|
if not hospital:
|
|
return False
|
|
|
|
# Check if user belongs to this hospital
|
|
return request.user.hospital == hospital
|
|
|
|
|
|
class CanAccessDepartmentData(permissions.BasePermission):
|
|
"""
|
|
Permission class that checks if user can access department data.
|
|
- PX Admins can access all departments
|
|
- Hospital Admins can access departments in their hospital
|
|
- Department Managers can access their own department
|
|
"""
|
|
|
|
def has_object_permission(self, request, view, obj):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# PX Admins can access all
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
# Get department from object
|
|
department = None
|
|
if hasattr(obj, 'department'):
|
|
department = obj.department
|
|
elif obj.__class__.__name__ == 'Department':
|
|
department = obj
|
|
|
|
if not department:
|
|
return False
|
|
|
|
# Hospital Admins can access departments in their hospital
|
|
if request.user.is_hospital_admin() and request.user.hospital == department.hospital:
|
|
return True
|
|
|
|
# Department Managers can access their own department
|
|
if request.user.is_department_manager() and request.user.department == department:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
class IsSourceUser(permissions.BasePermission):
|
|
"""
|
|
Permission class to check if user is a PX Source User.
|
|
Source Users can only access data from their assigned source.
|
|
"""
|
|
message = "You must be a Source User to perform this action."
|
|
|
|
def has_permission(self, request, view):
|
|
return (
|
|
request.user and
|
|
request.user.is_authenticated and
|
|
request.user.is_source_user()
|
|
)
|
|
|
|
|
|
class IsSourceUserOrAdmin(permissions.BasePermission):
|
|
"""
|
|
Permission class for Source Users or Admins.
|
|
Allows access to:
|
|
- PX Admins
|
|
- Hospital Admins
|
|
- Department Managers
|
|
- Active Source Users
|
|
"""
|
|
message = "You must be a Source User or Admin to perform this action."
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# Admins have access
|
|
if (request.user.is_px_admin() or
|
|
request.user.is_hospital_admin() or
|
|
request.user.is_department_manager()):
|
|
return True
|
|
|
|
# Source Users have access
|
|
return request.user.is_source_user()
|
|
|
|
|
|
class IsSourceUserOwnData(permissions.BasePermission):
|
|
"""
|
|
Object-level permission to ensure source users can only access their own data.
|
|
"""
|
|
message = "You can only access data you created."
|
|
|
|
def has_object_permission(self, request, view, obj):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# Admins can access everything
|
|
if (request.user.is_px_admin() or
|
|
request.user.is_hospital_admin() or
|
|
request.user.is_department_manager()):
|
|
return True
|
|
|
|
# Source Users can only access their own created data
|
|
if request.user.is_source_user():
|
|
return getattr(obj, 'created_by', None) == request.user
|
|
|
|
return False
|
|
|
|
|
|
# ==================== Onboarding Permissions ====================
|
|
|
|
class IsProvisionalUser(permissions.BasePermission):
|
|
"""
|
|
Permission class to check if user is provisional (in onboarding).
|
|
"""
|
|
message = "You must be a provisional user to access this resource."
|
|
|
|
def has_permission(self, request, view):
|
|
return (
|
|
request.user and
|
|
request.user.is_authenticated and
|
|
request.user.is_provisional and
|
|
not request.user.acknowledgement_completed
|
|
)
|
|
|
|
|
|
class CanViewOnboarding(permissions.BasePermission):
|
|
"""
|
|
Permission class for viewing onboarding content.
|
|
- Provisional users can view their own onboarding
|
|
- PX Admins can view all onboarding data
|
|
"""
|
|
message = "You do not have permission to view onboarding data."
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# Provisional users can view their own
|
|
if request.user.is_provisional:
|
|
return True
|
|
|
|
# PX Admins can view all
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
class CanManageOnboarding(permissions.BasePermission):
|
|
"""
|
|
Permission class for managing onboarding.
|
|
- PX Admins have full access
|
|
- Hospital Admins can manage onboarding for their hospital
|
|
"""
|
|
message = "You must be a PX Admin or Hospital Admin to manage onboarding."
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# PX Admins have full access
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
# Hospital Admins can manage their hospital
|
|
if request.user.is_hospital_admin():
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
class IsOnboardingOwnerOrAdmin(permissions.BasePermission):
|
|
"""
|
|
Permission class for onboarding object access.
|
|
- Users can access their own onboarding data
|
|
- PX Admins can access all
|
|
"""
|
|
|
|
def has_object_permission(self, request, view, obj):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# PX Admins can access all
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
# Users can access their own data
|
|
if hasattr(obj, 'user'):
|
|
return obj.user == request.user
|
|
|
|
return obj == request.user
|
|
|
|
|
|
class CanManageAcknowledgementContent(permissions.BasePermission):
|
|
"""
|
|
Permission class for managing acknowledgement content and checklist items.
|
|
Only PX Admins can manage these.
|
|
"""
|
|
message = "Only PX Admins can manage acknowledgement content."
|
|
|
|
def has_permission(self, request, view):
|
|
if not (request.user and request.user.is_authenticated):
|
|
return False
|
|
|
|
# PX Admins can manage content
|
|
if request.user.is_px_admin():
|
|
return True
|
|
|
|
# Read-only for others
|
|
if request.method in permissions.SAFE_METHODS:
|
|
return True
|
|
|
|
return False
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to update the sidebar navigation to include the Executive Summary menu item visible only to executives, so let me read the sidebar template first.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me update the sidebar to add Executive Summary navigation (visible to Executives and PX Admins):
|
|
|
|
|
|
### Tool: ReadFile: templates/layouts/partials/sidebar.html (first 100 lines)
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/templates/layouts/partials/sidebar.html",
|
|
"limit": 100
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/templates/layouts/partials/sidebar.html`
|
|
|
|
**Output:**
|
|
```html
|
|
Showing lines 1-100 of 784 total lines.
|
|
|
|
---
|
|
|
|
{% load i18n %}
|
|
{% load hospital_filters %}
|
|
{% load static %}
|
|
|
|
<style>
|
|
.sidebar-icon-only {
|
|
width: 5rem;
|
|
transition: width 0.3s ease;
|
|
}
|
|
.sidebar-icon-only:hover {
|
|
width: 16rem;
|
|
}
|
|
.sidebar-icon-only .sidebar-text {
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
width: 0;
|
|
overflow: hidden;
|
|
transition: opacity 0.2s ease;
|
|
white-space: nowrap;
|
|
}
|
|
.sidebar-icon-only:hover .sidebar-text {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
width: auto;
|
|
}
|
|
.sidebar-icon-only .sidebar-logo-text {
|
|
opacity: 0;
|
|
width: 0;
|
|
overflow: hidden;
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
.sidebar-icon-only:hover .sidebar-logo-text {
|
|
opacity: 1;
|
|
width: auto;
|
|
}
|
|
.sidebar-icon-only .sidebar-logo-extended {
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
.sidebar-icon-only:hover .sidebar-logo-extended {
|
|
opacity: 1;
|
|
}
|
|
.sidebar-icon-only:hover .sidebar-logo-collapsed {
|
|
display: none;
|
|
}
|
|
.sidebar-icon-only .submenu {
|
|
display: none;
|
|
}
|
|
.sidebar-icon-only:hover .submenu {
|
|
display: block;
|
|
}
|
|
.nav-item-active {
|
|
background-color: rgba(255,255,255,0.1);
|
|
border-left: 3px solid #fff;
|
|
}
|
|
.nav-item-sublink-active {
|
|
background-color: rgba(255,255,255,0.15);
|
|
}
|
|
.submenu {
|
|
max-height: 0;
|
|
overflow: hidden;
|
|
transition: max-height 0.3s ease;
|
|
}
|
|
.submenu.open {
|
|
max-height: 500px;
|
|
}
|
|
.submenu a {
|
|
padding-left: 3.5rem;
|
|
font-size: 0.75rem;
|
|
}
|
|
[dir="rtl"] .submenu a {
|
|
padding-left: 0.5rem;
|
|
padding-right: 3.5rem;
|
|
}
|
|
[dir="rtl"] .nav-item-active {
|
|
border-left: none;
|
|
border-right: 3px solid #fff;
|
|
}
|
|
[dir="rtl"] #sidebar button span.sidebar-text.flex-1,
|
|
[dir="rtl"] #sidebar .sidebar-text.text-left {
|
|
text-align: right;
|
|
}
|
|
</style>
|
|
|
|
<aside id="sidebar" class="sidebar-icon-only bg-navy text-white flex flex-col shadow-2xl z-20 fixed left-0 top-0 h-screen overflow-y-auto overflow-x-hidden">
|
|
{% csrf_token %}
|
|
<script>window.sidebarCsrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value;</script>
|
|
<!-- Brand -->
|
|
<div class="p-4 flex items-center gap-3 border-b border-white/10">
|
|
<!-- Collapsed logo (icon only) -->
|
|
<div class="sidebar-logo-collapsed w-10 h-10 flex-shrink-0">
|
|
<img src="{% static 'img/HH_R_ICON.png' %}" alt="Al Hammadi" class="w-full h-full object-contain">
|
|
</div>
|
|
<!-- Extended logo (full logo with text) - shown on hover -->
|
|
<div class="sidebar-logo-extended flex-shrink-0">
|
|
<img src="{% static 'img/HH_R_H_Logo.png' %}" alt="Al Hammadi" class="h-10 w-auto object-contain">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Navigation -->
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
I need to continue reading the sidebar file to find where the navigation items are defined so I can add the Executive Summary link.
|
|
|
|
|
|
### Tool: ReadFile: templates/layouts/partials/sidebar.html (lines 101-200)
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/templates/layouts/partials/sidebar.html",
|
|
"limit": 100,
|
|
"offset": 100
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/templates/layouts/partials/sidebar.html:100`
|
|
|
|
**Output:**
|
|
```html
|
|
Showing lines 101-200 of 784 total lines.
|
|
|
|
---
|
|
|
|
<nav class="flex-1 px-2 py-4 space-y-1">
|
|
<!-- Dashboard -->
|
|
<a href="{% url 'analytics:dashboard' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if request.resolver_match.url_name == 'dashboard' and 'analytics' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="layout-dashboard" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Dashboard" %}</span>
|
|
</a>
|
|
<a href="{% url 'dashboard:command-center' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if request.resolver_match.url_name == 'command-center' %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="command" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Command Center" %}</span>
|
|
</a>
|
|
|
|
<!-- Performance (Admin only - NOT for Source Users) -->
|
|
{% if user.is_px_admin or user.is_hospital_admin %}
|
|
{% if not user.source_user_profile %}
|
|
<div class="performance-nav-container">
|
|
<button onclick="togglePerformanceMenu(event)"
|
|
class="w-full flex items-center gap-3 p-3 rounded-lg transition {% if 'admin-evaluation' in request.path or 'employee-evaluation' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="bar-chart-2" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap flex-1">{% trans "Performance" %}</span>
|
|
<i data-lucide="chevron-down" class="sidebar-text w-4 h-4 flex-shrink-0 transform transition-transform duration-200 {% if 'admin-evaluation' in request.path or 'employee-evaluation' in request.path %}rotate-180{% endif %}" id="performance-chevron"></i>
|
|
</button>
|
|
<div class="submenu {% if 'admin-evaluation' in request.path or 'employee-evaluation' in request.path %}open{% endif %} space-y-1 mt-1" id="performance-submenu">
|
|
<a href="{% url 'dashboard:admin_evaluation' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'admin-evaluation' in request.path and 'employee' not in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="shield-check" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Admin Evaluation" %}</span>
|
|
</a>
|
|
<a href="{% url 'dashboard:employee_evaluation' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'employee-evaluation' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="users" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Employee Evaluation" %}</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
<!-- Source User Dashboard (Source Users only) -->
|
|
{% if user.source_user_profile %}
|
|
<a href="{% url 'px_sources:source_user_dashboard' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'source_user_dashboard' in request.resolver_match.url_name %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="radio" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "My Source Dashboard" %}</span>
|
|
</a>
|
|
{% endif %}
|
|
|
|
<!-- Complaints -->
|
|
<a href="{% url 'complaints:complaint_list' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'complaints' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="file-text" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Complaints" %}</span>
|
|
{% if complaint_count|default:0 > 0 %}
|
|
<span class="sidebar-text ml-auto bg-white/20 text-white text-xs px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center">{{ complaint_count }}</span>
|
|
{% endif %}
|
|
</a>
|
|
|
|
<!-- Inquiries -->
|
|
<a href="{% url 'inquiries:inquiry_list' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if '/inquiries' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="help-circle" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Inquiries" %}</span>
|
|
</a>
|
|
|
|
<!-- Observations -->
|
|
<a href="{% url 'observations:observation_list' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'observations' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="eye" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Observations" %}</span>
|
|
</a>
|
|
|
|
<!-- QI Projects - Hidden for Source Users -->
|
|
{% if not user.source_user_profile %}
|
|
<div class="qi-nav-container">
|
|
<button onclick="toggleQIMenu(event)"
|
|
class="w-full flex items-center gap-3 p-3 rounded-lg transition {% if 'projects' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="target" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap flex-1">{% trans "QI Projects" %}</span>
|
|
<i data-lucide="chevron-down" class="sidebar-text w-4 h-4 flex-shrink-0 transform transition-transform duration-200 {% if 'projects' in request.path %}rotate-180{% endif %}" id="qi-chevron"></i>
|
|
</button>
|
|
<div class="submenu {% if 'projects' in request.path %}open{% endif %} space-y-1 mt-1" id="qi-submenu">
|
|
<a href="{% url 'projects:project_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'project_list' %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="list" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "All Projects" %}</span>
|
|
</a>
|
|
{% if user.is_px_admin or user.is_hospital_admin or user.is_department_manager %}
|
|
<a href="{% url 'projects:project_create' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'create' in request.path and 'projects' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="plus-circle" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Create Project" %}</span>
|
|
</a>
|
|
{% endif %}
|
|
{% if user.is_px_admin or user.is_hospital_admin %}
|
|
<a href="{% url 'projects:template_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'template' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="copy" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Templates" %}</span>
|
|
</a>
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
I need to find the exact location in the sidebar where the Command Center link is to add the Executive Summary navigation link right after it.
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me find a good place to insert the Executive Summary link - right after Command Center:
|
|
|
|
|
|
### Tool: Grep: 'Command Center' in path '/home/ismail/projects/HH/templates/layouts/partials/sidebar.html'
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"path": "/home/ismail/projects/HH/templates/layouts/partials/sidebar.html",
|
|
"limit": 5,
|
|
"pattern": "Command Center"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Found 1 match for pattern "Command Center" in path "/home/ismail/projects/HH/templates/layouts/partials/sidebar.html":
|
|
---
|
|
/home/ismail/projects/HH/templates/layouts/partials/sidebar.html:111: <span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Command Center" %}</span>
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I'll add the Executive Summary navigation link to the sidebar, visible to Executives and PX Admins, right after the Command Center link.
|
|
|
|
|
|
### Tool: Edit: templates/layouts/partials/sidebar.html: <a href="{% url 'dashb... => <a href="{% url 'dashb...
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/templates/layouts/partials/sidebar.html",
|
|
"new_string": " <a href=\"{% url 'dashboard:command-center' %}\"\n class=\"flex items-center gap-3 p-3 rounded-lg transition {% if request.resolver_match.url_name == 'command-center' %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}\">\n <i data-lucide=\"command\" class=\"w-5 h-5 flex-shrink-0\"></i>\n <span class=\"sidebar-text text-sm font-semibold whitespace-nowrap\">{% trans \"Command Center\" %}</span>\n </a>\n\n <!-- Executive Summary (Executive & PX Admin only) -->\n {% if user.is_executive or user.is_px_admin %}\n <a href=\"{% url 'executive:executive_dashboard' %}\"\n class=\"flex items-center gap-3 p-3 rounded-lg transition {% if 'executive' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}\">\n <i data-lucide=\"briefcase\" class=\"w-5 h-5 flex-shrink-0\"></i>\n <span class=\"sidebar-text text-sm font-semibold whitespace-nowrap\">{% trans \"Executive Summary\" %}</span>\n </a>\n {% endif %}",
|
|
"old_string": " <a href=\"{% url 'dashboard:command-center' %}\"\n class=\"flex items-center gap-3 p-3 rounded-lg transition {% if request.resolver_match.url_name == 'command-center' %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}\">\n <i data-lucide=\"command\" class=\"w-5 h-5 flex-shrink-0\"></i>\n <span class=\"sidebar-text text-sm font-semibold whitespace-nowrap\">{% trans \"Command Center\" %}</span>\n </a>"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/templates/layouts/partials/sidebar.html`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `sidebar.html`:*
|
|
```diff
|
|
{% load i18n %}
|
|
{% load hospital_filters %}
|
|
{% load static %}
|
|
|
|
<style>
|
|
.sidebar-icon-only {
|
|
width: 5rem;
|
|
transition: width 0.3s ease;
|
|
}
|
|
.sidebar-icon-only:hover {
|
|
width: 16rem;
|
|
}
|
|
.sidebar-icon-only .sidebar-text {
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
width: 0;
|
|
overflow: hidden;
|
|
transition: opacity 0.2s ease;
|
|
white-space: nowrap;
|
|
}
|
|
.sidebar-icon-only:hover .sidebar-text {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
width: auto;
|
|
}
|
|
.sidebar-icon-only .sidebar-logo-text {
|
|
opacity: 0;
|
|
width: 0;
|
|
overflow: hidden;
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
.sidebar-icon-only:hover .sidebar-logo-text {
|
|
opacity: 1;
|
|
width: auto;
|
|
}
|
|
.sidebar-icon-only .sidebar-logo-extended {
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
.sidebar-icon-only:hover .sidebar-logo-extended {
|
|
opacity: 1;
|
|
}
|
|
.sidebar-icon-only:hover .sidebar-logo-collapsed {
|
|
display: none;
|
|
}
|
|
.sidebar-icon-only .submenu {
|
|
display: none;
|
|
}
|
|
.sidebar-icon-only:hover .submenu {
|
|
display: block;
|
|
}
|
|
.nav-item-active {
|
|
background-color: rgba(255,255,255,0.1);
|
|
border-left: 3px solid #fff;
|
|
}
|
|
.nav-item-sublink-active {
|
|
background-color: rgba(255,255,255,0.15);
|
|
}
|
|
.submenu {
|
|
max-height: 0;
|
|
overflow: hidden;
|
|
transition: max-height 0.3s ease;
|
|
}
|
|
.submenu.open {
|
|
max-height: 500px;
|
|
}
|
|
.submenu a {
|
|
padding-left: 3.5rem;
|
|
font-size: 0.75rem;
|
|
}
|
|
[dir="rtl"] .submenu a {
|
|
padding-left: 0.5rem;
|
|
padding-right: 3.5rem;
|
|
}
|
|
[dir="rtl"] .nav-item-active {
|
|
border-left: none;
|
|
border-right: 3px solid #fff;
|
|
}
|
|
[dir="rtl"] #sidebar button span.sidebar-text.flex-1,
|
|
[dir="rtl"] #sidebar .sidebar-text.text-left {
|
|
text-align: right;
|
|
}
|
|
</style>
|
|
|
|
<aside id="sidebar" class="sidebar-icon-only bg-navy text-white flex flex-col shadow-2xl z-20 fixed left-0 top-0 h-screen overflow-y-auto overflow-x-hidden">
|
|
{% csrf_token %}
|
|
<script>window.sidebarCsrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value;</script>
|
|
<!-- Brand -->
|
|
<div class="p-4 flex items-center gap-3 border-b border-white/10">
|
|
<!-- Collapsed logo (icon only) -->
|
|
<div class="sidebar-logo-collapsed w-10 h-10 flex-shrink-0">
|
|
<img src="{% static 'img/HH_R_ICON.png' %}" alt="Al Hammadi" class="w-full h-full object-contain">
|
|
</div>
|
|
<!-- Extended logo (full logo with text) - shown on hover -->
|
|
<div class="sidebar-logo-extended flex-shrink-0">
|
|
<img src="{% static 'img/HH_R_H_Logo.png' %}" alt="Al Hammadi" class="h-10 w-auto object-contain">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Navigation -->
|
|
<nav class="flex-1 px-2 py-4 space-y-1">
|
|
<!-- Dashboard -->
|
|
<a href="{% url 'analytics:dashboard' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if request.resolver_match.url_name == 'dashboard' and 'analytics' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="layout-dashboard" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Dashboard" %}</span>
|
|
</a>
|
|
<a href="{% url 'dashboard:command-center' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if request.resolver_match.url_name == 'command-center' %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="command" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Command Center" %}</span>
|
|
</a>
|
|
|
|
<!-- Executive Summary (Executive & PX Admin only) -->
|
|
{% if user.is_executive or user.is_px_admin %}
|
|
<a href="{% url 'executive:executive_dashboard' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'executive' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="briefcase" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Executive Summary" %}</span>
|
|
</a>
|
|
{% endif %}
|
|
|
|
<!-- Performance (Admin only - NOT for Source Users) -->
|
|
{% if user.is_px_admin or user.is_hospital_admin %}
|
|
{% if not user.source_user_profile %}
|
|
<div class="performance-nav-container">
|
|
<button onclick="togglePerformanceMenu(event)"
|
|
class="w-full flex items-center gap-3 p-3 rounded-lg transition {% if 'admin-evaluation' in request.path or 'employee-evaluation' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="bar-chart-2" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap flex-1">{% trans "Performance" %}</span>
|
|
<i data-lucide="chevron-down" class="sidebar-text w-4 h-4 flex-shrink-0 transform transition-transform duration-200 {% if 'admin-evaluation' in request.path or 'employee-evaluation' in request.path %}rotate-180{% endif %}" id="performance-chevron"></i>
|
|
</button>
|
|
<div class="submenu {% if 'admin-evaluation' in request.path or 'employee-evaluation' in request.path %}open{% endif %} space-y-1 mt-1" id="performance-submenu">
|
|
<a href="{% url 'dashboard:admin_evaluation' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'admin-evaluation' in request.path and 'employee' not in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="shield-check" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Admin Evaluation" %}</span>
|
|
</a>
|
|
<a href="{% url 'dashboard:employee_evaluation' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'employee-evaluation' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="users" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Employee Evaluation" %}</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
<!-- Source User Dashboard (Source Users only) -->
|
|
{% if user.source_user_profile %}
|
|
<a href="{% url 'px_sources:source_user_dashboard' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'source_user_dashboard' in request.resolver_match.url_name %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="radio" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "My Source Dashboard" %}</span>
|
|
</a>
|
|
{% endif %}
|
|
|
|
<!-- Complaints -->
|
|
<a href="{% url 'complaints:complaint_list' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'complaints' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="file-text" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Complaints" %}</span>
|
|
{% if complaint_count|default:0 > 0 %}
|
|
<span class="sidebar-text ml-auto bg-white/20 text-white text-xs px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center">{{ complaint_count }}</span>
|
|
{% endif %}
|
|
</a>
|
|
|
|
<!-- Inquiries -->
|
|
<a href="{% url 'inquiries:inquiry_list' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if '/inquiries' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="help-circle" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Inquiries" %}</span>
|
|
</a>
|
|
|
|
<!-- Observations -->
|
|
<a href="{% url 'observations:observation_list' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'observations' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="eye" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Observations" %}</span>
|
|
</a>
|
|
|
|
<!-- QI Projects - Hidden for Source Users -->
|
|
{% if not user.source_user_profile %}
|
|
<div class="qi-nav-container">
|
|
<button onclick="toggleQIMenu(event)"
|
|
class="w-full flex items-center gap-3 p-3 rounded-lg transition {% if 'projects' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="target" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap flex-1">{% trans "QI Projects" %}</span>
|
|
<i data-lucide="chevron-down" class="sidebar-text w-4 h-4 flex-shrink-0 transform transition-transform duration-200 {% if 'projects' in request.path %}rotate-180{% endif %}" id="qi-chevron"></i>
|
|
</button>
|
|
<div class="submenu {% if 'projects' in request.path %}open{% endif %} space-y-1 mt-1" id="qi-submenu">
|
|
<a href="{% url 'projects:project_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'project_list' %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="list" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "All Projects" %}</span>
|
|
</a>
|
|
{% if user.is_px_admin or user.is_hospital_admin or user.is_department_manager %}
|
|
<a href="{% url 'projects:project_create' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'create' in request.path and 'projects' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="plus-circle" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Create Project" %}</span>
|
|
</a>
|
|
{% endif %}
|
|
{% if user.is_px_admin or user.is_hospital_admin %}
|
|
<a href="{% url 'projects:template_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'template' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="copy" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Templates" %}</span>
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Feedback -->
|
|
{% comment %} <a href="{% url 'feedback:feedback_list' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'feedback' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="message-square-heart" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Feedback" %}</span>
|
|
</a>
|
|
|
|
<!-- Appreciation -->
|
|
<a href="{% url 'appreciation:appreciation_list' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'appreciation' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="heart" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Appreciation" %}</span>
|
|
</a> {% endcomment %}
|
|
|
|
<!-- Surveys (with submenu) - Hidden for Source Users -->
|
|
{% if not user.source_user_profile %}
|
|
{% url 'surveys:instance_list' as survey_instances_url %}
|
|
{% url 'surveys:template_list' as survey_templates_url %}
|
|
{% url 'surveys:template_create' as survey_create_url %}
|
|
{% url 'surveys:manual_send' as survey_send_url %}
|
|
{% url 'surveys:analytics_reports' as survey_reports_url %}
|
|
{% url 'integrations:survey-mapping-settings' as survey_mapping_url %}
|
|
<div class="survey-nav-container">
|
|
<button onclick="toggleSurveyMenu(event)"
|
|
class="w-full flex items-center gap-3 p-3 rounded-lg transition {% if 'surveys' in request.path or 'survey-mapping' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="clipboard-list" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap flex-1">{% trans "Surveys" %}</span>
|
|
<i data-lucide="chevron-down" class="sidebar-text w-4 h-4 flex-shrink-0 transform transition-transform duration-200 {% if 'surveys' in request.path or 'survey-mapping' in request.path %}rotate-180{% endif %}" id="survey-chevron"></i>
|
|
</button>
|
|
<div class="submenu {% if 'surveys' in request.path or 'survey-mapping' in request.path %}open{% endif %} space-y-1 mt-1" id="survey-submenu">
|
|
<a href="{{ survey_instances_url }}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'surveys' in request.path and 'templates' not in request.path and 'send' not in request.path and 'reports' not in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="list" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Survey List" %}</span>
|
|
</a>
|
|
<a href="{{ survey_templates_url }}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'templates' in request.path and 'questions' not in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="files" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Templates" %}</span>
|
|
</a>
|
|
<a href="{{ survey_create_url }}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'templates/create' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="plus-circle" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Create Survey" %}</span>
|
|
</a>
|
|
<a href="{{ survey_send_url }}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if '/send' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="send" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Manual Send" %}</span>
|
|
</a>
|
|
<a href="{% url 'surveys:his_patient_import' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'his-import' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="upload" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "HIS Import" %}</span>
|
|
</a>
|
|
{% comment %} <a href="{% url 'surveys:bulk_job_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'bulk-jobs' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="layers" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Bulk Jobs" %}</span>
|
|
</a> {% endcomment %}
|
|
{% comment %} <a href="{{ survey_reports_url }}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if '/reports' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="bar-chart-2" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Reports" %}</span>
|
|
</a> {% endcomment %}
|
|
<a href="{{ survey_mapping_url }}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'survey-mapping' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="link" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Mapping" %}</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Staff (with submenu) - Hidden for Source Users -->
|
|
{% if not user.source_user_profile %}
|
|
<div class="staff-nav-container">
|
|
<button onclick="toggleStaffMenu(event)"
|
|
class="w-full flex items-center gap-3 p-3 rounded-lg transition {% if 'staff' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="users" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap flex-1">{% trans "Staff" %}</span>
|
|
<i data-lucide="chevron-down" class="sidebar-text w-4 h-4 flex-shrink-0 transform transition-transform duration-200 {% if 'staff' in request.path %}rotate-180{% endif %}" id="staff-chevron"></i>
|
|
</button>
|
|
<div class="submenu {% if 'staff' in request.path %}open{% endif %} space-y-1 mt-1" id="staff-submenu">
|
|
<a href="{% url 'organizations:staff_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'staff_list' %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="list" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "All Staff" %}</span>
|
|
</a>
|
|
<a href="{% url 'organizations:staff_hierarchy_d3' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'hierarchy' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="git-branch" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Hierarchy" %}</span>
|
|
</a>
|
|
{% comment %} <a href="{% url 'organizations:section_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'section' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="layers" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Sections" %}</span>
|
|
</a> {% endcomment %}
|
|
{% comment %} <a href="{% url 'organizations:subsection_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'subsection' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="layout-grid" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Subsections" %}</span>
|
|
</a> {% endcomment %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Patients -->
|
|
<a href="{% url 'organizations:patient_list' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'patients' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="heart-pulse" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Patients" %}</span>
|
|
</a>
|
|
|
|
<!-- Social Media - PX Admin only -->
|
|
{% if user.is_px_admin %}
|
|
<a href="{% url 'social:dashboard' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'social' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="share-2" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Social Media" %}</span>
|
|
</a>
|
|
{% endif %}
|
|
|
|
<!-- Physicians (with submenu) - Hidden for Source Users -->
|
|
{% if not user.source_user_profile %}
|
|
<div class="physicians-nav-container">
|
|
<button onclick="togglePhysiciansMenu(event)"
|
|
class="w-full flex items-center gap-3 p-3 rounded-lg transition {% if 'physicians' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="stethoscope" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap flex-1">{% trans "Physicians" %}</span>
|
|
<i data-lucide="chevron-down" class="sidebar-text w-4 h-4 flex-shrink-0 transform transition-transform duration-200 {% if 'physicians' in request.path %}rotate-180{% endif %}" id="physicians-chevron"></i>
|
|
</button>
|
|
<div class="submenu {% if 'physicians' in request.path %}open{% endif %} space-y-1 mt-1" id="physicians-submenu">
|
|
<a href="{% url 'physicians:physician_ratings_dashboard' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'physician_ratings_dashboard' %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="layout-dashboard" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Dashboard" %}</span>
|
|
</a>
|
|
<a href="{% url 'physicians:physician_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'physician_list' %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="users" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "All Physicians" %}</span>
|
|
</a>
|
|
<a href="{% url 'physicians:leaderboard' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'leaderboard' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="trophy" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Leaderboard" %}</span>
|
|
</a>
|
|
{% if user.is_px_admin or user.is_hospital_admin %}
|
|
<a href="{% url 'physicians:doctor_rating_fetch' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'doctor_rating_fetch' %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="cloud-download" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Fetch Ratings" %}</span>
|
|
</a>
|
|
<a href="{% url 'physicians:doctor_rating_import' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'import' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="upload" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Import Ratings" %}</span>
|
|
</a>
|
|
<a href="{% url 'physicians:individual_ratings_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'individual' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="star" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Individual Ratings" %}</span>
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- PX Sources (with submenu) - Admin view only -->
|
|
{% if not user.source_user_profile %}
|
|
<div class="px-sources-nav-container">
|
|
<button onclick="togglePXSourcesMenu(event)"
|
|
class="w-full flex items-center gap-3 p-3 rounded-lg transition {% if 'px-sources' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="radio" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap flex-1">{% trans "PX Sources" %}</span>
|
|
<i data-lucide="chevron-down" class="sidebar-text w-4 h-4 flex-shrink-0 transform transition-transform duration-200 {% if 'px-sources' in request.path %}rotate-180{% endif %}" id="px-sources-chevron"></i>
|
|
</button>
|
|
<div class="submenu {% if 'px-sources' in request.path %}open{% endif %} space-y-1 mt-1" id="px-sources-submenu">
|
|
<a href="{% url 'px_sources:source_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'source_list' %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="list" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "All Sources" %}</span>
|
|
</a>
|
|
{% comment %} <a href="{% url 'px_sources:source_user_dashboard' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'dashboard' in request.path and 'px-sources' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="layout-dashboard" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Dashboard" %}</span>
|
|
</a>
|
|
<a href="{% url 'px_sources:source_user_complaint_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'complaints' in request.path and 'px-sources' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="file-text" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Source Complaints" %}</span>
|
|
</a>
|
|
<a href="{% url 'px_sources:source_user_inquiry_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'inquiries' in request.path and 'px-sources' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="help-circle" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Source Inquiries" %}</span>
|
|
</a> {% endcomment %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Reports (Admin only - NOT for Source Users) -->
|
|
{% if user.is_px_admin or user.is_hospital_admin %}
|
|
{% if not user.source_user_profile %}
|
|
<div class="reports-nav-container">
|
|
<button onclick="toggleReportsMenu(event)"
|
|
class="w-full flex items-center gap-3 p-3 rounded-lg transition {% if '/reports/' in request.path or 'kpi-reports' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="file-bar-chart" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap flex-1">{% trans "Reports" %}</span>
|
|
<i data-lucide="chevron-down" class="sidebar-text w-4 h-4 flex-shrink-0 transform transition-transform duration-200 {% if '/reports/' in request.path or 'kpi-reports' in request.path %}rotate-180{% endif %}" id="reports-chevron"></i>
|
|
</button>
|
|
<div class="submenu {% if '/reports/' in request.path or 'kpi-reports' in request.path %}open{% endif %} space-y-1 mt-1" id="reports-submenu">
|
|
<a href="{% url 'reports:builder' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'builder' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="plus-circle" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Create Report" %}</span>
|
|
</a>
|
|
<a href="{% url 'reports:saved_reports' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'saved' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="folder-open" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Saved Reports" %}</span>
|
|
</a>
|
|
<a href="{% url 'analytics:kpi_report_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'kpi-reports' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="target" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "KPI Reports" %}</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
<!-- Send SMS (Admin only - NOT for Source Users) -->
|
|
{% if user.is_px_admin or user.is_hospital_admin %}
|
|
{% if not user.source_user_profile %}
|
|
<a href="{% url 'notifications:send_sms_direct' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'send-sms' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="message-square" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "Send SMS" %}</span>
|
|
</a>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
<!-- System Config (Admin only - NOT for Source Users) -->
|
|
{% if user.is_px_admin or user.is_hospital_admin %}
|
|
{% if not user.source_user_profile %}
|
|
<a href="{% url 'config:dashboard' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'config' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="settings" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap">{% trans "System Config" %}</span>
|
|
</a>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
<!-- Acknowledgements (All Employees - NOT for Source Users) -->
|
|
{% if user.is_authenticated and not user.source_user_profile %}
|
|
<a href="{% url 'accounts:simple_acknowledgements:employee_list' %}"
|
|
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'simple-acknowledgements' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="clipboard-signature" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap flex-1">{% trans "Acknowledgements" %}</span>
|
|
{% if pending_acknowledgements_count > 0 %}
|
|
<span class="sidebar-text bg-red-500 text-white text-xs font-bold px-2 py-0.5 rounded-full min-w-[1.5rem] text-center">
|
|
{{ pending_acknowledgements_count }}
|
|
</span>
|
|
{% endif %}
|
|
</a>
|
|
{% endif %}
|
|
|
|
<!-- References (All Users - NOT for Source Users) -->
|
|
{% if not user.source_user_profile %}
|
|
<div class="references-nav-container">
|
|
<button onclick="toggleReferencesMenu(event)"
|
|
class="w-full flex items-center gap-3 p-3 rounded-lg transition {% if 'references' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="folder-open" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap flex-1">{% trans "References" %}</span>
|
|
<i data-lucide="chevron-down" class="sidebar-text w-4 h-4 flex-shrink-0 transform transition-transform duration-200 {% if 'references' in request.path %}rotate-180{% endif %}" id="references-chevron"></i>
|
|
</button>
|
|
<div class="submenu {% if 'references' in request.path %}open{% endif %} space-y-1 mt-1" id="references-submenu">
|
|
<a href="{% url 'references:dashboard' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'dashboard' %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="layout-dashboard" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Dashboard" %}</span>
|
|
</a>
|
|
<a href="{% url 'references:search' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'search' %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="search" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Search Documents" %}</span>
|
|
</a>
|
|
<a href="{% url 'references:document_create' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'document_create' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="upload" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Upload Document" %}</span>
|
|
</a>
|
|
<a href="{% url 'references:folder_create' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'folder_create' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="folder-plus" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "New Folder" %}</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Standards (Admin only - NOT for Source Users) -->
|
|
{% if user.is_px_admin or user.is_hospital_admin %}
|
|
{% if not user.source_user_profile %}
|
|
<div class="standards-nav-container">
|
|
<button onclick="toggleStandardsMenu(event)"
|
|
class="w-full flex items-center gap-3 p-3 rounded-lg transition {% if 'standards' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="clipboard-check" class="w-5 h-5 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-sm font-semibold whitespace-nowrap flex-1">{% trans "Standards" %}</span>
|
|
<i data-lucide="chevron-down" class="sidebar-text w-4 h-4 flex-shrink-0 transform transition-transform duration-200 {% if 'standards' in request.path %}rotate-180{% endif %}" id="standards-chevron"></i>
|
|
</button>
|
|
<div class="submenu {% if 'standards' in request.path %}open{% endif %} space-y-1 mt-1" id="standards-submenu">
|
|
<a href="{% url 'standards:dashboard' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'dashboard' and 'standards' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="layout-dashboard" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Dashboard" %}</span>
|
|
</a>
|
|
<a href="{% url 'standards:search' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if request.resolver_match.url_name == 'search' %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="search" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Search Standards" %}</span>
|
|
</a>
|
|
<a href="{% url 'standards:source_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'sources' in request.path and 'standards' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="folder" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Sources" %}</span>
|
|
</a>
|
|
<a href="{% url 'standards:category_list' %}"
|
|
class="flex items-center gap-2 p-2 rounded-lg transition {% if 'categories' in request.path and 'standards' in request.path %}nav-item-sublink-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
|
|
<i data-lucide="tags" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text whitespace-nowrap">{% trans "Categories" %}</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endif %}
|
|
</nav>
|
|
|
|
<!-- Hospital Switcher (PX Admin only) -->
|
|
{% if is_px_admin and hospitals_list %}
|
|
<div class="px-3 py-2 border-t border-white/10">
|
|
<div class="mb-2 px-2">
|
|
<p class="text-[10px] font-semibold text-white/50 uppercase tracking-wider">{% trans "Hospital" %}</p>
|
|
</div>
|
|
<div class="relative">
|
|
<select
|
|
id="hospitalSwitcher"
|
|
onchange="switchHospital(this.value)"
|
|
class="w-full bg-white/10 border border-white/20 rounded-lg py-2 px-3 pr-8 text-xs font-semibold text-white appearance-none cursor-pointer hover:bg-white/15 transition focus:outline-none focus:ring-2 focus:ring-blue/50"
|
|
>
|
|
{% for hospital in hospitals_list %}
|
|
<option
|
|
value="{{ hospital.id }}"
|
|
{% if current_hospital and current_hospital.id == hospital.id %}selected{% endif %}
|
|
>
|
|
{{ hospital.name }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
<div class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none">
|
|
<i data-lucide="chevron-down" class="w-3.5 h-3.5 text-white/50"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Language Switcher -->
|
|
<div class="px-3 py-2">
|
|
<div class="flex items-center gap-2">
|
|
{% get_available_languages as LANGUAGES %}
|
|
{% get_current_language as LANGUAGE_CODE %}
|
|
{% for lang_code, lang_name in LANGUAGES %}
|
|
<a href="{% url 'core:set_language' %}?language={{ lang_code }}"
|
|
class="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg border transition-all duration-200 font-semibold text-xs
|
|
{% if LANGUAGE_CODE == lang_code %}
|
|
border-white/40 bg-white/20 text-white
|
|
{% else %}
|
|
border-white/15 text-white/60 hover:bg-white/10 hover:text-white hover:border-white/30
|
|
{% endif %}">
|
|
<span class="text-sm">
|
|
{% if lang_code == 'en' %}🇬🇧{% elif lang_code == 'ar' %}🇸🇦{% endif %}
|
|
</span>
|
|
<span class="sidebar-text">{{ lang_name }}</span>
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- User Profile & Logout -->
|
|
<div class="p-3 border-t border-white/10">
|
|
<div class="flex items-center gap-3 p-2 rounded-lg hover:bg-white/10 transition cursor-pointer" onclick="toggleUserMenu()">
|
|
<div class="w-8 h-8 rounded-full bg-blue border-2 border-white/20 flex items-center justify-center font-bold text-xs flex-shrink-0">
|
|
{{ user.first_name|first|default:"U" }}{{ user.last_name|first|default:"" }}
|
|
</div>
|
|
<div class="sidebar-text flex-1 overflow-hidden min-w-0">
|
|
<p class="text-xs font-bold truncate">{{ user.get_full_name|default:user.username }}</p>
|
|
<p class="text-[10px] text-white/50 truncate">{{ user.get_role_display|default:"User" }}</p>
|
|
</div>
|
|
<i data-lucide="log-out" class="w-4 h-4 text-white/40 hover:text-white cursor-pointer flex-shrink-0 sidebar-text" onclick="event.stopPropagation(); logout()"></i>
|
|
</div>
|
|
|
|
<!-- User Dropdown Menu (hidden by default) -->
|
|
<div id="userMenu" class="hidden mt-2 space-y-1">
|
|
<a href="{% url 'accounts:settings' %}" class="flex items-center gap-3 p-2 rounded-lg opacity-70 hover:opacity-100 hover:bg-white/10 transition">
|
|
<i data-lucide="user" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-xs font-semibold whitespace-nowrap">{% trans "Profile" %}</span>
|
|
</a>
|
|
<form method="post" action="{% url 'accounts:logout' %}" class="m-0">
|
|
{% csrf_token %}
|
|
<button type="submit" class="w-full flex items-center gap-3 p-2 rounded-lg opacity-70 hover:opacity-100 hover:bg-white/10 transition text-left">
|
|
<i data-lucide="log-out" class="w-4 h-4 flex-shrink-0"></i>
|
|
<span class="sidebar-text text-xs font-semibold whitespace-nowrap">{% trans "Logout" %}</span>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<script>
|
|
function toggleUserMenu() {
|
|
const menu = document.getElementById('userMenu');
|
|
menu.classList.toggle('hidden');
|
|
}
|
|
|
|
function switchHospital(hospitalId) {
|
|
if (!hospitalId) return;
|
|
|
|
// Show loading state
|
|
const select = document.getElementById('hospitalSwitcher');
|
|
select.disabled = true;
|
|
|
|
// Get CSRF token from global variable (not from cookie due to httpOnly)
|
|
const csrfToken = window.sidebarCsrfToken || getCookie('csrftoken');
|
|
|
|
// Create form data
|
|
const formData = new FormData();
|
|
formData.append('hospital_id', hospitalId);
|
|
formData.append('csrfmiddlewaretoken', csrfToken);
|
|
|
|
// Make AJAX request
|
|
fetch('{% url "core:switch_hospital" %}', {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
}
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
// Reload page to apply new hospital context
|
|
window.location.reload();
|
|
} else {
|
|
alert('{% trans "Failed to switch hospital: " %}' + (data.error || '{% trans "Unknown error" %}'));
|
|
select.disabled = false;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error switching hospital:', error);
|
|
alert('{% trans "An error occurred while switching hospitals" %}');
|
|
select.disabled = false;
|
|
});
|
|
}
|
|
|
|
function getCookie(name) {
|
|
let cookieValue = null;
|
|
if (document.cookie && document.cookie !== '') {
|
|
const cookies = document.cookie.split(';');
|
|
for (let i = 0; i < cookies.length; i++) {
|
|
const cookie = cookies[i].trim();
|
|
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return cookieValue;
|
|
}
|
|
|
|
function toggleSurveyMenu(event) {
|
|
event.preventDefault();
|
|
const submenu = document.getElementById('survey-submenu');
|
|
const chevron = document.getElementById('survey-chevron');
|
|
submenu.classList.toggle('open');
|
|
chevron.classList.toggle('rotate-180');
|
|
}
|
|
|
|
function togglePhysiciansMenu(event) {
|
|
event.preventDefault();
|
|
const submenu = document.getElementById('physicians-submenu');
|
|
const chevron = document.getElementById('physicians-chevron');
|
|
submenu.classList.toggle('open');
|
|
chevron.classList.toggle('rotate-180');
|
|
}
|
|
|
|
function toggleStaffMenu(event) {
|
|
event.preventDefault();
|
|
const submenu = document.getElementById('staff-submenu');
|
|
const chevron = document.getElementById('staff-chevron');
|
|
submenu.classList.toggle('open');
|
|
chevron.classList.toggle('rotate-180');
|
|
}
|
|
|
|
function togglePXSourcesMenu(event) {
|
|
event.preventDefault();
|
|
const submenu = document.getElementById('px-sources-submenu');
|
|
const chevron = document.getElementById('px-sources-chevron');
|
|
submenu.classList.toggle('open');
|
|
chevron.classList.toggle('rotate-180');
|
|
}
|
|
|
|
function toggleAcknowledgementsMenu(event) {
|
|
event.preventDefault();
|
|
const submenu = document.getElementById('acknowledgements-submenu');
|
|
const chevron = document.getElementById('acknowledgements-chevron');
|
|
submenu.classList.toggle('open');
|
|
chevron.classList.toggle('rotate-180');
|
|
}
|
|
|
|
function toggleReportsMenu(event) {
|
|
event.preventDefault();
|
|
const submenu = document.getElementById('reports-submenu');
|
|
const chevron = document.getElementById('reports-chevron');
|
|
submenu.classList.toggle('open');
|
|
chevron.classList.toggle('rotate-180');
|
|
}
|
|
|
|
function toggleQIMenu(event) {
|
|
event.preventDefault();
|
|
const submenu = document.getElementById('qi-submenu');
|
|
const chevron = document.getElementById('qi-chevron');
|
|
submenu.classList.toggle('open');
|
|
chevron.classList.toggle('rotate-180');
|
|
}
|
|
|
|
function toggleReferencesMenu(event) {
|
|
event.preventDefault();
|
|
const submenu = document.getElementById('references-submenu');
|
|
const chevron = document.getElementById('references-chevron');
|
|
submenu.classList.toggle('open');
|
|
chevron.classList.toggle('rotate-180');
|
|
}
|
|
|
|
function toggleStandardsMenu(event) {
|
|
event.preventDefault();
|
|
const submenu = document.getElementById('standards-submenu');
|
|
const chevron = document.getElementById('standards-chevron');
|
|
submenu.classList.toggle('open');
|
|
chevron.classList.toggle('rotate-180');
|
|
}
|
|
|
|
function togglePerformanceMenu(event) {
|
|
event.preventDefault();
|
|
const submenu = document.getElementById('performance-submenu');
|
|
const chevron = document.getElementById('performance-chevron');
|
|
submenu.classList.toggle('open');
|
|
chevron.classList.toggle('rotate-180');
|
|
}
|
|
|
|
function logout() {
|
|
if (confirm('{% trans "Are you sure you want to logout?" %}')) {
|
|
document.querySelector('form[action="{% url 'accounts:logout' %}"]').submit();
|
|
}
|
|
}
|
|
|
|
// Close user menu when clicking outside
|
|
document.addEventListener('click', function(e) {
|
|
const userMenu = document.getElementById('userMenu');
|
|
const userProfile = e.target.closest('.cursor-pointer');
|
|
if (userMenu && !userMenu.contains(e.target) && !userProfile) {
|
|
userMenu.classList.add('hidden');
|
|
}
|
|
});
|
|
</script>
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
The Executive role and sidebar navigation have been added, so I should update the todo list to mark task 1 as completed and move to task 2 (creating the executive_summary Django app structure).
|
|
|
|
|
|
## Assistant
|
|
|
|
Perfect! Now let me mark task 1 as complete and move to task 2 - creating the executive_summary app structure:
|
|
|
|
|
|
### Tool: TodoWrite
|
|
**Status**: completed
|
|
|
|
**Output:**
|
|
```
|
|
- [x] Create Executive role and permissions
|
|
- [-] Create executive_summary Django app structure (models, views, services, tasks)
|
|
- [ ] Build ExecutiveMetric and ExecutiveReport models
|
|
- [ ] Create ExecutiveSummaryService for data aggregation
|
|
- [ ] Build Executive Dashboard view with KPI cards and filters
|
|
- [ ] Create Executive Dashboard template with ApexCharts
|
|
- [ ] Add URL routing and sidebar navigation
|
|
- [ ] Implement AI Narrative Service using LiteLLM
|
|
- [ ] Build Predictive Analytics Service (trend analysis, risk detection)
|
|
- [ ] Create Recommendation Engine
|
|
- [ ] Integrate AI-generated briefs into dashboard
|
|
- [ ] Implement Natural Language Q&A interface
|
|
- [ ] Build Predictive Insights view and risk scoring
|
|
- [ ] Create Hospital comparison and leaderboard views
|
|
- [ ] Implement PDF report generation with WeasyPrint
|
|
- [ ] Create Celery scheduled tasks for automated reports
|
|
- [ ] Build mobile-optimized responsive view
|
|
- [ ] Add email delivery system for scheduled reports
|
|
- [ ] Add Arabic translations and RTL support
|
|
- [ ] Performance optimization and testing
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to create the executive_summary Django app structure by running the startapp command.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me create the executive_summary Django app. First, I'll create the app structure:
|
|
|
|
|
|
### Tool: Shell: cd /home/ismail/projects/HH && python manage.py startapp executive_summary apps/executive_summary (Create executive_summary Django app)
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"command": "cd /home/ismail/projects/HH && python manage.py startapp executive_summary apps/executive_summary",
|
|
"description": "Create executive_summary Django app"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Command: cd /home/ismail/projects/HH && python manage.py startapp executive_summary apps/executive_summary
|
|
Directory: (root)
|
|
Output: bash: line 1: python: command not found
|
|
Error: (none)
|
|
Exit Code: 127
|
|
Signal: 0
|
|
Process Group PGID: 273022
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
The python command failed, so I'll check if uv is available since the project has a .python-version file indicating it might use uv for Python management.
|
|
|
|
|
|
### Tool: Shell: cd /home/ismail/projects/HH && uv run python manage.py startapp executive_summary apps/executive_summary (Create executive_summary Django app with uv)
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"command": "cd /home/ismail/projects/HH && uv run python manage.py startapp executive_summary apps/executive_summary",
|
|
"description": "Create executive_summary Django app with uv"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Command: cd /home/ismail/projects/HH && uv run python manage.py startapp executive_summary apps/executive_summary
|
|
Directory: (root)
|
|
Output: (empty)
|
|
Error: (none)
|
|
Exit Code: 0
|
|
Signal: 0
|
|
Process Group PGID: 273269
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to register the new executive_summary app in Django settings to make it available to the project.
|
|
|
|
|
|
## Assistant
|
|
|
|
Great! Now let me register the app in settings and create the comprehensive models and services. Let me first check the INSTALLED_APPS:
|
|
|
|
|
|
### Tool: Grep: 'INSTALLED_APPS' in path '/home/ismail/projects/HH/PX360/settings.py'
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"path": "/home/ismail/projects/HH/PX360/settings.py",
|
|
"limit": 50,
|
|
"pattern": "INSTALLED_APPS"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Found 1 match for pattern "INSTALLED_APPS" in path "/home/ismail/projects/HH/PX360/settings.py":
|
|
---
|
|
/home/ismail/projects/HH/PX360/settings.py:36:INSTALLED_APPS = [
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to read the INSTALLED_APPS section in settings.py to see the existing app registrations and find where to add the new executive_summary app.
|
|
|
|
|
|
### Tool: ReadFile: PX360/settings.py (lines 36-115)
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/PX360/settings.py",
|
|
"limit": 80,
|
|
"offset": 35
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/PX360/settings.py:35`
|
|
|
|
**Output:**
|
|
```python
|
|
Showing lines 36-115 of 189 total lines.
|
|
|
|
---
|
|
|
|
INSTALLED_APPS = [
|
|
"django.contrib.admin",
|
|
"django.contrib.auth",
|
|
"django.contrib.contenttypes",
|
|
"django.contrib.sessions",
|
|
"django.contrib.messages",
|
|
"django.contrib.staticfiles",
|
|
# Apps
|
|
"apps.core",
|
|
"apps.accounts",
|
|
"apps.dashboard",
|
|
"apps.social",
|
|
"django_celery_beat",
|
|
]
|
|
|
|
MIDDLEWARE = [
|
|
"django.middleware.security.SecurityMiddleware",
|
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
"django.middleware.common.CommonMiddleware",
|
|
"django.middleware.csrf.CsrfViewMiddleware",
|
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
"django.contrib.messages.middleware.MessageMiddleware",
|
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
# PX Source User Access Control
|
|
"apps.px_sources.middleware.SourceUserRestrictionMiddleware",
|
|
"apps.px_sources.middleware.SourceUserSessionMiddleware",
|
|
]
|
|
|
|
ROOT_URLCONF = "PX360.urls"
|
|
|
|
TEMPLATES = [
|
|
{
|
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
"DIRS": [BASE_DIR / "templates"],
|
|
"APP_DIRS": True,
|
|
"OPTIONS": {
|
|
"context_processors": [
|
|
"django.template.context_processors.request",
|
|
"django.contrib.auth.context_processors.auth",
|
|
"django.contrib.messages.context_processors.messages",
|
|
"apps.core.context_processors.hospital_context",
|
|
"apps.core.context_processors.sidebar_counts",
|
|
],
|
|
},
|
|
},
|
|
]
|
|
|
|
WSGI_APPLICATION = "PX360.wsgi.application"
|
|
|
|
|
|
# Database
|
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
|
|
|
DATABASES = {
|
|
"default": {
|
|
"ENGINE": "django.db.backends.sqlite3",
|
|
"NAME": BASE_DIR / "db.sqlite3",
|
|
}
|
|
}
|
|
|
|
|
|
# Password validation
|
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
|
|
|
AUTH_PASSWORD_VALIDATORS = [
|
|
{
|
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
|
},
|
|
{
|
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
|
},
|
|
{
|
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
|
},
|
|
{
|
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
|
},
|
|
]
|
|
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
The INSTALLED_APPS list found in settings.py seems incomplete - I need to search for other apps like 'apps.complaints' to understand the full app registration pattern used in this project.
|
|
|
|
|
|
## Assistant
|
|
|
|
I notice the settings file seems truncated. Let me check the full file to see all apps:
|
|
|
|
|
|
### Tool: Grep: 'apps\\.complaints|apps\\.surveys' in path '/home/ismail/projects/HH/PX360/settings.py'
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"path": "/home/ismail/projects/HH/PX360/settings.py",
|
|
"limit": 5,
|
|
"pattern": "apps\\\\.complaints|apps\\\\.surveys"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
No matches found for pattern "apps\\.complaints|apps\\.surveys" in path "/home/ismail/projects/HH/PX360/settings.py".
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
The INSTALLED_APPS seems incomplete - let me check if there are multiple settings files in the project.
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me check if there's a different settings structure:
|
|
|
|
|
|
### Tool: Glob: '**/settings*.py'
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"pattern": "**/settings*.py"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Found 3 file(s) matching "**/settings*.py" in the workspace directory, sorted by modification time (newest first):
|
|
---
|
|
/home/ismail/projects/HH/apps/notifications/settings_models.py
|
|
/home/ismail/projects/HH/apps/notifications/settings_service.py
|
|
/home/ismail/projects/HH/PX360/settings.py
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me verify Django configuration by checking how the project is configured to run.
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me read the manage.py to understand the setup:
|
|
|
|
|
|
### Tool: ReadFile: manage.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/manage.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/manage.py`
|
|
|
|
**Output:**
|
|
```python
|
|
#!/usr/bin/env python
|
|
"""Django's command-line utility for administrative tasks."""
|
|
import os
|
|
import sys
|
|
|
|
|
|
def main():
|
|
"""Run administrative tasks."""
|
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev')
|
|
try:
|
|
from django.core.management import execute_from_command_line
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"Couldn't import Django. Are you sure it's installed and "
|
|
"available on your PYTHONPATH environment variable? Did you "
|
|
"forget to activate a virtual environment?"
|
|
) from exc
|
|
execute_from_command_line(sys.argv)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
The manage.py reveals the actual settings module is 'config.settings.dev', so I need to find and read that file to properly register the new app.
|
|
|
|
|
|
## Assistant
|
|
|
|
Ah! The settings module is `config.settings.dev`. Let me find that:
|
|
|
|
|
|
### Tool: Glob: '**/config/settings/**/*.py'
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"pattern": "**/config/settings/**/*.py"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Found 4 file(s) matching "**/config/settings/**/*.py" in the workspace directory, sorted by modification time (newest first):
|
|
---
|
|
/home/ismail/projects/HH/config/settings/dev.py
|
|
/home/ismail/projects/HH/config/settings/base.py
|
|
/home/ismail/projects/HH/config/settings/__init__.py
|
|
/home/ismail/projects/HH/config/settings/prod.py
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to read the base settings file to see the INSTALLED_APPS configuration and add the new executive_summary app.
|
|
|
|
|
|
### Tool: ReadFile: config/settings/base.py (first 100 lines)
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/config/settings/base.py",
|
|
"limit": 100
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/config/settings/base.py`
|
|
|
|
**Output:**
|
|
```python
|
|
Showing lines 1-100 of 510 total lines.
|
|
|
|
---
|
|
|
|
"""
|
|
Base settings for PX360 project.
|
|
This file contains settings common to all environments.
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import environ
|
|
|
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
|
|
|
# Initialize environment variables
|
|
env = environ.Env(
|
|
DEBUG=(bool, False),
|
|
ALLOWED_HOSTS=(list, []),
|
|
)
|
|
|
|
# Read .env file if it exists
|
|
environ.Env.read_env(os.path.join(BASE_DIR, ".env"))
|
|
|
|
# SECURITY WARNING: keep the secret key used in production secret!
|
|
SECRET_KEY = env("SECRET_KEY", default="django-insecure-change-this-in-production")
|
|
|
|
# SECURITY WARNING: don't run with debug turned on in production!
|
|
DEBUG = env("DEBUG")
|
|
|
|
ALLOWED_HOSTS = env("ALLOWED_HOSTS")
|
|
|
|
# Application definition
|
|
DJANGO_APPS = [
|
|
"django.contrib.admin",
|
|
"django.contrib.auth",
|
|
"django.contrib.contenttypes",
|
|
"django.contrib.sessions",
|
|
"django.contrib.messages",
|
|
"django.contrib.staticfiles",
|
|
]
|
|
|
|
THIRD_PARTY_APPS = [
|
|
"rest_framework",
|
|
"rest_framework_simplejwt",
|
|
"rest_framework_simplejwt.token_blacklist",
|
|
"django_filters",
|
|
"drf_spectacular",
|
|
"django_celery_beat",
|
|
]
|
|
|
|
LOCAL_APPS = [
|
|
"apps.core",
|
|
"apps.accounts",
|
|
"apps.organizations",
|
|
"apps.journeys",
|
|
"apps.surveys",
|
|
"apps.complaints",
|
|
"apps.feedback",
|
|
"apps.callcenter",
|
|
"apps.social",
|
|
"apps.px_action_center",
|
|
"apps.analytics",
|
|
"apps.physicians",
|
|
"apps.projects",
|
|
"apps.integrations",
|
|
"apps.notifications",
|
|
"apps.ai_engine",
|
|
"apps.dashboard",
|
|
"apps.appreciation",
|
|
"apps.observations",
|
|
"apps.px_sources",
|
|
"apps.references",
|
|
"apps.standards",
|
|
"apps.simulator",
|
|
"apps.reports",
|
|
"apps.rca",
|
|
]
|
|
|
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
|
|
|
MIDDLEWARE = [
|
|
"django.middleware.security.SecurityMiddleware",
|
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
"django.middleware.locale.LocaleMiddleware", # i18n support
|
|
"django.middleware.common.CommonMiddleware",
|
|
"django.middleware.csrf.CsrfViewMiddleware",
|
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
"apps.core.middleware.TenantMiddleware", # Multi-tenancy support
|
|
"apps.px_sources.middleware.SourceUserRestrictionMiddleware", # STRICT source user restrictions
|
|
"apps.px_sources.middleware.SourceUserSessionMiddleware", # Shorter sessions for source users
|
|
"django.contrib.messages.middleware.MessageMiddleware",
|
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
]
|
|
|
|
ROOT_URLCONF = "config.urls"
|
|
|
|
TEMPLATES = [
|
|
{
|
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
"DIRS": [BASE_DIR / "templates"],
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to add the executive_summary app to LOCAL_APPS in the base settings file.
|
|
|
|
|
|
## Assistant
|
|
|
|
Perfect! Now let me add the executive_summary app to LOCAL_APPS:
|
|
|
|
|
|
### Tool: Edit: config/settings/base.py: LOCAL_APPS = [... => LOCAL_APPS = [...
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/config/settings/base.py",
|
|
"new_string": "LOCAL_APPS = [\n \"apps.core\",\n \"apps.accounts\",\n \"apps.organizations\",\n \"apps.journeys\",\n \"apps.surveys\",\n \"apps.complaints\",\n \"apps.feedback\",\n \"apps.callcenter\",\n \"apps.social\",\n \"apps.px_action_center\",\n \"apps.analytics\",\n \"apps.physicians\",\n \"apps.projects\",\n \"apps.integrations\",\n \"apps.notifications\",\n \"apps.ai_engine\",\n \"apps.dashboard\",\n \"apps.appreciation\",\n \"apps.observations\",\n \"apps.px_sources\",\n \"apps.references\",\n \"apps.standards\",\n \"apps.simulator\",\n \"apps.reports\",\n \"apps.rca\",\n \"apps.executive_summary\",\n]",
|
|
"old_string": "LOCAL_APPS = [\n \"apps.core\",\n \"apps.accounts\",\n \"apps.organizations\",\n \"apps.journeys\",\n \"apps.surveys\",\n \"apps.complaints\",\n \"apps.feedback\",\n \"apps.callcenter\",\n \"apps.social\",\n \"apps.px_action_center\",\n \"apps.analytics\",\n \"apps.physicians\",\n \"apps.projects\",\n \"apps.integrations\",\n \"apps.notifications\",\n \"apps.ai_engine\",\n \"apps.dashboard\",\n \"apps.appreciation\",\n \"apps.observations\",\n \"apps.px_sources\",\n \"apps.references\",\n \"apps.standards\",\n \"apps.simulator\",\n \"apps.reports\",\n \"apps.rca\",\n]"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/config/settings/base.py`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `base.py`:*
|
|
```diff
|
|
"""
|
|
Base settings for PX360 project.
|
|
This file contains settings common to all environments.
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import environ
|
|
|
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
|
|
|
# Initialize environment variables
|
|
env = environ.Env(
|
|
DEBUG=(bool, False),
|
|
ALLOWED_HOSTS=(list, []),
|
|
)
|
|
|
|
# Read .env file if it exists
|
|
environ.Env.read_env(os.path.join(BASE_DIR, ".env"))
|
|
|
|
# SECURITY WARNING: keep the secret key used in production secret!
|
|
SECRET_KEY = env("SECRET_KEY", default="django-insecure-change-this-in-production")
|
|
|
|
# SECURITY WARNING: don't run with debug turned on in production!
|
|
DEBUG = env("DEBUG")
|
|
|
|
ALLOWED_HOSTS = env("ALLOWED_HOSTS")
|
|
|
|
# Application definition
|
|
DJANGO_APPS = [
|
|
"django.contrib.admin",
|
|
"django.contrib.auth",
|
|
"django.contrib.contenttypes",
|
|
"django.contrib.sessions",
|
|
"django.contrib.messages",
|
|
"django.contrib.staticfiles",
|
|
]
|
|
|
|
THIRD_PARTY_APPS = [
|
|
"rest_framework",
|
|
"rest_framework_simplejwt",
|
|
"rest_framework_simplejwt.token_blacklist",
|
|
"django_filters",
|
|
"drf_spectacular",
|
|
"django_celery_beat",
|
|
]
|
|
|
|
LOCAL_APPS = [
|
|
"apps.core",
|
|
"apps.accounts",
|
|
"apps.organizations",
|
|
"apps.journeys",
|
|
"apps.surveys",
|
|
"apps.complaints",
|
|
"apps.feedback",
|
|
"apps.callcenter",
|
|
"apps.social",
|
|
"apps.px_action_center",
|
|
"apps.analytics",
|
|
"apps.physicians",
|
|
"apps.projects",
|
|
"apps.integrations",
|
|
"apps.notifications",
|
|
"apps.ai_engine",
|
|
"apps.dashboard",
|
|
"apps.appreciation",
|
|
"apps.observations",
|
|
"apps.px_sources",
|
|
"apps.references",
|
|
"apps.standards",
|
|
"apps.simulator",
|
|
"apps.reports",
|
|
"apps.rca",
|
|
"apps.executive_summary",
|
|
]
|
|
|
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
|
|
|
MIDDLEWARE = [
|
|
"django.middleware.security.SecurityMiddleware",
|
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
"django.middleware.locale.LocaleMiddleware", # i18n support
|
|
"django.middleware.common.CommonMiddleware",
|
|
"django.middleware.csrf.CsrfViewMiddleware",
|
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
"apps.core.middleware.TenantMiddleware", # Multi-tenancy support
|
|
"apps.px_sources.middleware.SourceUserRestrictionMiddleware", # STRICT source user restrictions
|
|
"apps.px_sources.middleware.SourceUserSessionMiddleware", # Shorter sessions for source users
|
|
"django.contrib.messages.middleware.MessageMiddleware",
|
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
]
|
|
|
|
ROOT_URLCONF = "config.urls"
|
|
|
|
TEMPLATES = [
|
|
{
|
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
"DIRS": [BASE_DIR / "templates"],
|
|
"APP_DIRS": True,
|
|
"OPTIONS": {
|
|
"context_processors": [
|
|
"django.template.context_processors.debug",
|
|
"django.template.context_processors.request",
|
|
"django.contrib.auth.context_processors.auth",
|
|
"django.contrib.messages.context_processors.messages",
|
|
"django.template.context_processors.i18n",
|
|
"apps.core.context_processors.sidebar_counts",
|
|
"apps.core.context_processors.hospital_context",
|
|
"apps.accounts.context_processors.acknowledgement_counts",
|
|
],
|
|
},
|
|
},
|
|
]
|
|
|
|
WSGI_APPLICATION = "config.wsgi.application"
|
|
|
|
# Database
|
|
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
|
|
# DATABASES = {
|
|
# 'default': env.db('DATABASE_URL', default='postgresql://px360:px360@localhost:5432/px360')
|
|
# }
|
|
|
|
DATABASES = {
|
|
"default": env.db(
|
|
"DATABASE_URL",
|
|
default="postgresql://px360:px360@localhost:5433/px360",
|
|
)
|
|
}
|
|
|
|
|
|
# Password validation
|
|
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
|
AUTH_PASSWORD_VALIDATORS = [
|
|
{
|
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
|
},
|
|
{
|
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
|
},
|
|
{
|
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
|
},
|
|
{
|
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
|
},
|
|
]
|
|
|
|
# Custom User Model
|
|
AUTH_USER_MODEL = "accounts.User"
|
|
|
|
# Authentication URLs
|
|
LOGIN_URL = "/accounts/login/"
|
|
LOGIN_REDIRECT_URL = "/"
|
|
LOGOUT_REDIRECT_URL = "/accounts/login/"
|
|
|
|
# Internationalization
|
|
# https://docs.djangoproject.com/en/5.0/topics/i18n/
|
|
LANGUAGE_CODE = "en-us"
|
|
|
|
TIME_ZONE = "Asia/Riyadh"
|
|
|
|
USE_I18N = True
|
|
|
|
USE_TZ = True
|
|
|
|
# Languages supported (Arabic and English)
|
|
LANGUAGES = [
|
|
("en", "English"),
|
|
("ar", "Arabic"),
|
|
]
|
|
|
|
# Locale paths for translation files
|
|
LOCALE_PATHS = [
|
|
BASE_DIR / "locale",
|
|
]
|
|
|
|
# Static files (CSS, JavaScript, Images)
|
|
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
|
STATIC_URL = "/static/"
|
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
|
STATICFILES_DIRS = [
|
|
BASE_DIR / "static",
|
|
]
|
|
|
|
# Media files
|
|
MEDIA_URL = "/media/"
|
|
MEDIA_ROOT = BASE_DIR / "media"
|
|
|
|
# Data upload settings
|
|
# Increased limit to support bulk patient imports from HIS
|
|
DATA_UPLOAD_MAX_NUMBER_FIELDS = 10000
|
|
|
|
# WhiteNoise configuration
|
|
STORAGES = {
|
|
"default": {
|
|
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
|
},
|
|
"staticfiles": {
|
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
|
},
|
|
}
|
|
|
|
|
|
# Default primary key field type
|
|
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
|
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
|
|
# Django REST Framework
|
|
REST_FRAMEWORK = {
|
|
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
|
"rest_framework.authentication.SessionAuthentication",
|
|
],
|
|
"DEFAULT_PERMISSION_CLASSES": [
|
|
"rest_framework.permissions.IsAuthenticated",
|
|
],
|
|
"DEFAULT_FILTER_BACKENDS": [
|
|
"django_filters.rest_framework.DjangoFilterBackend",
|
|
"rest_framework.filters.SearchFilter",
|
|
"rest_framework.filters.OrderingFilter",
|
|
],
|
|
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
|
"PAGE_SIZE": 50,
|
|
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
|
}
|
|
|
|
# JWT Settings
|
|
from datetime import timedelta
|
|
|
|
SIMPLE_JWT = {
|
|
"ACCESS_TOKEN_LIFETIME": timedelta(hours=1),
|
|
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
|
|
"ROTATE_REFRESH_TOKENS": True,
|
|
"BLACKLIST_AFTER_ROTATION": True,
|
|
"UPDATE_LAST_LOGIN": True,
|
|
}
|
|
|
|
# DRF Spectacular (OpenAPI/Swagger)
|
|
SPECTACULAR_SETTINGS = {
|
|
"TITLE": "PX360 API",
|
|
"DESCRIPTION": "Patient Experience 360 Management System API",
|
|
"VERSION": "1.0.0",
|
|
"SERVE_INCLUDE_SCHEMA": False,
|
|
}
|
|
|
|
# Celery Configuration
|
|
CELERY_BROKER_URL = env("CELERY_BROKER_URL", default="redis://localhost:6379/0")
|
|
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", default="redis://localhost:6379/0")
|
|
CELERY_ACCEPT_CONTENT = ["json"]
|
|
CELERY_TASK_SERIALIZER = "json"
|
|
CELERY_RESULT_SERIALIZER = "json"
|
|
CELERY_TIMEZONE = TIME_ZONE
|
|
CELERY_TASK_TRACK_STARTED = True
|
|
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes
|
|
|
|
# Celery Beat Schedule
|
|
CELERY_BEAT_SCHEDULER = "config.celery_scheduler:PatchedDatabaseScheduler"
|
|
|
|
# Logging Configuration
|
|
LOGGING = {
|
|
"version": 1,
|
|
"disable_existing_loggers": False,
|
|
"formatters": {
|
|
"verbose": {
|
|
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
|
|
"style": "{",
|
|
},
|
|
"simple": {
|
|
"format": "{levelname} {message}",
|
|
"style": "{",
|
|
},
|
|
},
|
|
"filters": {
|
|
"require_debug_true": {
|
|
"()": "django.utils.log.RequireDebugTrue",
|
|
},
|
|
},
|
|
"handlers": {
|
|
"console": {"level": "INFO", "class": "logging.StreamHandler", "formatter": "verbose"},
|
|
"file": {
|
|
"level": "INFO",
|
|
"class": "logging.handlers.RotatingFileHandler",
|
|
"filename": BASE_DIR / "logs" / "px360.log",
|
|
"maxBytes": 1024 * 1024 * 15, # 15MB
|
|
"backupCount": 10,
|
|
"formatter": "verbose",
|
|
},
|
|
"integration_file": {
|
|
"level": "INFO",
|
|
"class": "logging.handlers.RotatingFileHandler",
|
|
"filename": BASE_DIR / "logs" / "integrations.log",
|
|
"maxBytes": 1024 * 1024 * 15, # 15MB
|
|
"backupCount": 10,
|
|
"formatter": "verbose",
|
|
},
|
|
},
|
|
"loggers": {
|
|
"django": {
|
|
"handlers": ["console", "file"],
|
|
"level": "INFO",
|
|
"propagate": False,
|
|
},
|
|
"apps": {
|
|
"handlers": ["console", "file"],
|
|
"level": "INFO",
|
|
"propagate": False,
|
|
},
|
|
"apps.integrations": {
|
|
"handlers": ["console", "integration_file"],
|
|
"level": "INFO",
|
|
"propagate": False,
|
|
},
|
|
},
|
|
}
|
|
|
|
# Create logs directory if it doesn't exist
|
|
LOGS_DIR = BASE_DIR / "logs"
|
|
LOGS_DIR.mkdir(exist_ok=True)
|
|
|
|
# PX360 Business Configuration
|
|
# These will be overridden by database configurations but provide defaults
|
|
|
|
# Survey Configuration
|
|
SURVEY_NEGATIVE_THRESHOLD = 3 # Scores below this trigger actions (out of 5)
|
|
SURVEY_TOKEN_EXPIRY_DAYS = 30
|
|
|
|
# SLA Configuration (in hours)
|
|
SLA_DEFAULTS = {
|
|
"complaint": {
|
|
"low": 72,
|
|
"medium": 48,
|
|
"high": 24,
|
|
"critical": 12,
|
|
},
|
|
"action": {
|
|
"low": 120,
|
|
"medium": 72,
|
|
"high": 48,
|
|
"critical": 24,
|
|
},
|
|
}
|
|
|
|
COMPLAINT_LINK_EXPIRY_DAYS = env.int("COMPLAINT_LINK_EXPIRY_DAYS", default=7)
|
|
|
|
# AI Configuration (LiteLLM with OpenRouter)
|
|
OPENROUTER_API_KEY = env(
|
|
"OPENROUTER_API_KEY", default="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"
|
|
)
|
|
AI_MODEL = env("AI_MODEL", default="z-ai/glm-4.5-air:free")
|
|
AI_TEMPERATURE = env.float("AI_TEMPERATURE", default=0.3)
|
|
AI_MAX_TOKENS = env.int("AI_MAX_TOKENS", default=500)
|
|
|
|
# Notification Configuration
|
|
NOTIFICATION_CHANNELS = {
|
|
"sms": {
|
|
"enabled": env.bool("SMS_ENABLED", default=False),
|
|
"provider": env("SMS_PROVIDER", default="console"),
|
|
},
|
|
"whatsapp": {
|
|
"enabled": env.bool("WHATSAPP_ENABLED", default=False),
|
|
"provider": env("WHATSAPP_PROVIDER", default="console"),
|
|
},
|
|
"email": {
|
|
"enabled": env.bool("EMAIL_ENABLED", default=True),
|
|
"provider": env("EMAIL_PROVIDER", default="console"),
|
|
},
|
|
}
|
|
|
|
# Twilio Configuration
|
|
TWILIO_ACCOUNT_SID = env("TWILIO_ACCOUNT_SID", default="")
|
|
TWILIO_AUTH_TOKEN = env("TWILIO_AUTH_TOKEN", default="")
|
|
TWILIO_PHONE_NUMBER = env("TWILIO_PHONE_NUMBER", default="")
|
|
TWILIO_MESSAGING_SERVICE_SID = env("TWILIO_MESSAGING_SERVICE_SID", default="")
|
|
|
|
# Mshastra SMS Configuration
|
|
MSHASTRA_USERNAME = env("MSHASTRA_USERNAME", default="")
|
|
MSHASTRA_PASSWORD = env("MSHASTRA_PASSWORD", default="")
|
|
MSHASTRA_SENDER_ID = env("MSHASTRA_SENDER_ID", default="")
|
|
|
|
# Dev SMS recipient - when DEBUG=True and this is set, all SMS are redirected here
|
|
DEV_SMS_RECIPIENT = env("DEV_SMS_RECIPIENT", default="")
|
|
|
|
# External API Notification Configuration
|
|
EXTERNAL_NOTIFICATION_API = {
|
|
"email": {
|
|
"enabled": env.bool("EMAIL_API_ENABLED", default=False),
|
|
"url": env("EMAIL_API_URL", default=""),
|
|
"api_key": env("EMAIL_API_KEY", default=""),
|
|
"auth_method": env("EMAIL_API_AUTH_METHOD", default="bearer"),
|
|
"method": env("EMAIL_API_METHOD", default="POST"),
|
|
"timeout": env.int("EMAIL_API_TIMEOUT", default=10),
|
|
"max_retries": env.int("EMAIL_API_MAX_RETRIES", default=3),
|
|
"retry_delay": env.int("EMAIL_API_RETRY_DELAY", default=2),
|
|
},
|
|
"sms": {
|
|
"enabled": env.bool("SMS_API_ENABLED", default=False),
|
|
"url": env("SMS_API_URL", default=""),
|
|
"api_key": env("SMS_API_KEY", default=""),
|
|
"auth_method": env("SMS_API_AUTH_METHOD", default="bearer"),
|
|
"method": env("SMS_API_METHOD", default="POST"),
|
|
"timeout": env.int("SMS_API_TIMEOUT", default=10),
|
|
"max_retries": env.int("SMS_API_MAX_RETRIES", default=3),
|
|
"retry_delay": env.int("SMS_API_RETRY_DELAY", default=2),
|
|
},
|
|
}
|
|
|
|
# Email Configuration
|
|
EMAIL_BACKEND = env("EMAIL_BACKEND", default="django.core.mail.backends.smtp.EmailBackend")
|
|
EMAIL_HOST = env("EMAIL_HOST", default="localhost")
|
|
EMAIL_PORT = env.int("EMAIL_PORT", default=2525)
|
|
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=False)
|
|
EMAIL_HOST_USER = env("EMAIL_HOST_USER", default="")
|
|
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", default="")
|
|
DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL", default="noreply@px360.sa")
|
|
|
|
SITE_URL = env("SITE_URL", default="http://localhost:8000")
|
|
EMAIL_LOGO_URL = env("EMAIL_LOGO_URL", default=f"{SITE_URL}{STATIC_URL}img/HH_P_H_Logo.png")
|
|
|
|
# HIS Integration Settings
|
|
HIS_API_URL = env("HIS_API_URL", default="https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps")
|
|
HIS_API_USERNAME = env("HIS_API_USERNAME", default="AlhhSUNZHippo")
|
|
HIS_RATINGS_API_URL = env("HIS_RATINGS_API_URL", default="")
|
|
|
|
# Password contains special characters (*#$@PAlhh^2106) - set directly to avoid parsing issues
|
|
# Check .env first, fall back to hardcoded default
|
|
try:
|
|
_env_password = env("HIS_API_PASSWORD")
|
|
if _env_password and _env_password != "*":
|
|
HIS_API_PASSWORD = _env_password
|
|
else:
|
|
HIS_API_PASSWORD = "*#$@PAlhh^2106"
|
|
except Exception:
|
|
HIS_API_PASSWORD = "*#$@PAlhh^2106"
|
|
|
|
# Security Settings
|
|
SECURE_BROWSER_XSS_FILTER = True
|
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
|
X_FRAME_OPTIONS = "DENY"
|
|
SECURE_SSL_REDIRECT = env.bool("SECURE_SSL_REDIRECT", default=False)
|
|
SESSION_COOKIE_SECURE = env.bool("SESSION_COOKIE_SECURE", default=False)
|
|
CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", default=False)
|
|
SESSION_COOKIE_HTTPONLY = True
|
|
CSRF_COOKIE_HTTPONLY = True
|
|
SESSION_COOKIE_SAMESITE = "Lax"
|
|
CSRF_COOKIE_SAMESITE = "Lax"
|
|
|
|
# Password Policy Settings
|
|
PASSWORD_MIN_LENGTH = 8
|
|
PASSWORD_COMPLEXITY = True
|
|
|
|
# Login Security - Rate Limiting
|
|
# Login attempts rate limiting (Django Axes would be recommended for production)
|
|
MAX_LOGIN_ATTEMPTS = 5
|
|
LOGIN_ATTEMPT_TIMEOUT_MINUTES = 30
|
|
|
|
# Session Security
|
|
SESSION_COOKIE_AGE = 120 * 60 # 2 hours
|
|
SESSION_EXPIRE_AT_BROWSER_CLOSE = env.bool("SESSION_EXPIRE_AT_BROWSER_CLOSE", default=True)
|
|
SESSION_SAVE_EVERY_REQUEST = True
|
|
|
|
# Multi-Tenancy Settings
|
|
TENANCY_ENABLED = True
|
|
TENANT_MODEL = "organizations.Hospital"
|
|
TENANT_FIELD = "hospital"
|
|
|
|
# Tenant isolation level
|
|
# 'strict' - Complete isolation (users only see their hospital)
|
|
# 'relaxed' - PX admins can see all hospitals
|
|
TENANT_ISOLATION_LEVEL = "strict"
|
|
|
|
|
|
# Social Media API Configuration
|
|
YOUTUBE_API_KEY = env("YOUTUBE_API_KEY", default="AIzaSyAem20etP6GkRNMmCyI1pRJF7v8U_xDyMM")
|
|
YOUTUBE_CHANNEL_ID = env("YOUTUBE_CHANNEL_ID", default="UCKoEfCXsm4_cQMtqJTvZUVQ")
|
|
|
|
FACEBOOK_PAGE_ID = env("FACEBOOK_PAGE_ID", default="938104059393026")
|
|
FACEBOOK_ACCESS_TOKEN = env(
|
|
"FACEBOOK_ACCESS_TOKEN",
|
|
default="EAATrDf0UAS8BQWSKbljCUDMbluZBbxZCSWLJkZBGIviBtK8IQ7FDHfGQZBHHm7lsgLhZBL2trT3ZBGPtsWRjntFWQovhkhx726ZBexRZCKitEMhxAiZBmls7uX946432k963Myl6aYBzJzwLhSyygZAFOGP7iIIZANVf6GtLlvAnWn0NXRwZAYR0CNNUwCEEsZAAc",
|
|
)
|
|
|
|
INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID", default="17841431861985364")
|
|
INSTAGRAM_ACCESS_TOKEN = env(
|
|
"INSTAGRAM_ACCESS_TOKEN",
|
|
default="EAATrDf0UAS8BQWSKbljCUDMbluZBbxZCSWLJkZBGIviBtK8IQ7FDHfGQZBHHm7lsgLhZBL2trT3ZBGPtsWRjntFWQovhkhx726ZBexRZCKitEMhxAiZBmls7uX946432k963Myl6aYBzJzwLhSyygZAFOGP7iIIZANVf6GtLlvAnWn0NXRwZAYR0CNNUwCEEsZAAc",
|
|
)
|
|
|
|
# Twitter/X Configuration
|
|
TWITTER_BEARER_TOKEN = env("TWITTER_BEARER_TOKEN", default=None)
|
|
TWITTER_USERNAME = env("TWITTER_USERNAME", default=None)
|
|
|
|
# LinkedIn Configuration
|
|
LINKEDIN_ACCESS_TOKEN = env("LINKEDIN_ACCESS_TOKEN", default=None)
|
|
LINKEDIN_ORGANIZATION_ID = env("LINKEDIN_ORGANIZATION_ID", default=None)
|
|
|
|
# Google Reviews Configuration
|
|
GOOGLE_CREDENTIALS_FILE = env("GOOGLE_CREDENTIALS_FILE", default="client_secret.json")
|
|
GOOGLE_TOKEN_FILE = env("GOOGLE_TOKEN_FILE", default="token.json")
|
|
GOOGLE_LOCATIONS = env.list("GOOGLE_LOCATIONS", default=[])
|
|
|
|
# OpenRouter Configuration for AI Comment Analysis
|
|
OPENROUTER_API_KEY = env(
|
|
"OPENROUTER_API_KEY", default="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"
|
|
)
|
|
OPENROUTER_MODEL = env("OPENROUTER_MODEL", default="google/gemma-3-27b-it:free")
|
|
ANALYSIS_BATCH_SIZE = env.int("ANALYSIS_BATCH_SIZE", default=2)
|
|
ANALYSIS_ENABLED = env.bool("ANALYSIS_ENABLED", default=True)
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to build the comprehensive models for the executive_summary app, including ExecutiveMetric for KPI snapshots, ExecutiveReport for AI-generated reports, PredictiveInsight for AI-detected risks, and AIRecommendation for suggested actions.
|
|
|
|
|
|
## Assistant
|
|
|
|
Excellent! Now let me create the comprehensive models for the executive summary app. Let me write the models file:
|
|
|
|
|
|
### Tool: WriteFile: Writing to apps/executive_summary/models.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"content": "\"\"\"\nExecutive Summary models - AI-powered executive dashboards and reports\n\nThis module implements:\n- Executive metrics (KPI snapshots for trending)\n- Executive reports (AI-generated weekly/monthly summaries)\n- Predictive insights (AI-detected patterns and risks)\n- AI recommendations (suggested actions)\n\"\"\"\nfrom django.db import models\nfrom django.utils.translation import gettext_lazy as _\n\nfrom apps.core.models import TimeStampedModel, UUIDModel\n\n\nclass ExecutiveMetric(UUIDModel, TimeStampedModel):\n \"\"\"\n Executive Metric - Daily KPI snapshots for trending and analysis.\n \n Stores aggregated metrics across all hospitals for executive overview.\n Used for trend analysis and dashboard display.\n \"\"\"\n\n METRIC_TYPES = [\n (\"complaints_total\", _(\"Total Complaints\")),\n (\"complaints_critical\", _(\"Critical Complaints\")),\n (\"complaints_overdue\", _(\"Overdue Complaints\")),\n (\"complaints_resolution_time\", _(\"Avg Resolution Time (hours)\")),\n (\"surveys_total\", _(\"Total Surveys\")),\n (\"surveys_satisfaction\", _(\"Satisfaction Rate %\")),\n (\"surveys_nps\", _(\"NPS Score\")),\n (\"surveys_response_rate\", _(\"Response Rate %\")),\n (\"actions_total\", _(\"Total Actions\")),\n (\"actions_open\", _(\"Open Actions\")),\n (\"actions_overdue\", _(\"Overdue Actions\")),\n (\"actions_closed\", _(\"Closed Actions\")),\n (\"observations_total\", _(\"Total Observations\")),\n (\"observations_critical\", _(\"Critical Observations\")),\n (\"inquiries_total\", _(\"Total Inquiries\")),\n (\"inquiries_resolved\", _(\"Resolved Inquiries\")),\n (\"call_center_total\", _(\"Call Center Interactions\")),\n (\"call_center_satisfaction\", _(\"Call Center Satisfaction %\")),\n (\"physician_avg_rating\", _(\"Avg Physician Rating\")),\n ]\n\n # Date for this metric snapshot\n metric_date = models.DateField(db_index=True, help_text=\"Date of this metric snapshot\")\n \n # Metric type\n metric_type = models.CharField(max_length=50, choices=METRIC_TYPES, db_index=True)\n \n # Metric value\n metric_value = models.DecimalField(max_digits=10, decimal_places=2)\n \n # Variance from previous period\n variance = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, \n help_text=\"Variance from previous period\")\n \n # Variance direction\n variance_direction = models.CharField(\n max_length=10,\n choices=[(\"up\", \"Up\"), (\"down\", \"Down\"), (\"neutral\", \"Neutral\")],\n default=\"neutral\",\n help_text=\"Direction of variance\"\n )\n \n # Hospital (null = system-wide aggregate)\n hospital = models.ForeignKey(\n \"organizations.Hospital\",\n on_delete=models.CASCADE,\n null=True,\n blank=True,\n related_name=\"executive_metrics\"\n )\n \n # Metadata\n metadata = models.JSONField(default=dict, blank=True)\n\n class Meta:\n ordering = [\"-metric_date\", \"metric_type\"]\n indexes = [\n models.Index(fields=[\"metric_date\", \"metric_type\"]),\n models.Index(fields=[\"hospital\", \"metric_date\"]),\n ]\n verbose_name = _(\"Executive Metric\")\n verbose_name_plural = _(\"Executive Metrics\")\n\n def __str__(self):\n hospital_name = f\" - {self.hospital.name}\" if self.hospital else \" (All)\"\n return f\"{self.get_metric_type_display()}: {self.metric_value}{hospital_name} ({self.metric_date})\"\n\n\nclass ExecutiveReport(UUIDModel, TimeStampedModel):\n \"\"\"\n Executive Report - AI-generated weekly/monthly narrative summaries.\n \n Contains natural language summaries generated by AI analyzing\n performance data across all hospitals.\n \"\"\"\n\n REPORT_TYPES = [\n (\"weekly\", _(\"Weekly Summary\")),\n (\"monthly\", _(\"Monthly Summary\")),\n (\"quarterly\", _(\"Quarterly Summary\")),\n (\"custom\", _(\"Custom Report\")),\n ]\n\n STATUS_CHOICES = [\n (\"pending\", _(\"Pending\")),\n (\"generating\", _(\"Generating\")),\n (\"completed\", _(\"Completed\")),\n (\"failed\", _(\"Failed\")),\n ]\n\n # Report metadata\n report_type = models.CharField(max_length=20, choices=REPORT_TYPES, db_index=True)\n status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=\"pending\", db_index=True)\n \n # Date range\n start_date = models.DateField()\n end_date = models.DateField()\n \n # AI-generated narratives (bilingual)\n narrative_en = models.TextField(blank=True, help_text=\"English narrative summary\")\n narrative_ar = models.TextField(blank=True, help_text=\"Arabic narrative summary\")\n \n # Key highlights\n highlights_en = models.JSONField(default=list, blank=True, help_text=\"Key highlights (English)\")\n highlights_ar = models.JSONField(default=list, blank=True, help_text=\"Key highlights (Arabic)\")\n \n # Key concerns\n concerns_en = models.JSONField(default=list, blank=True, help_text=\"Key concerns (English)\")\n concerns_ar = models.JSONField(default=list, blank=True, help_text=\"Key concerns (Arabic)\")\n \n # Summary metrics snapshot\n metrics_snapshot = models.JSONField(default=dict, blank=True, help_text=\"Key metrics at report time\")\n \n # AI metadata\n ai_model = models.CharField(max_length=100, blank=True, help_text=\"AI model used for generation\")\n generation_time_ms = models.IntegerField(null=True, blank=True, help_text=\"Generation time in milliseconds\")\n \n # PDF report file (if generated)\n pdf_file = models.FileField(\n upload_to=\"executive_reports/pdfs/%Y/%m/\",\n null=True,\n blank=True,\n help_text=\"Generated PDF report\"\n )\n \n # Error message if failed\n error_message = models.TextField(blank=True, help_text=\"Error message if generation failed\")\n\n class Meta:\n ordering = [\"-created_at\"]\n indexes = [\n models.Index(fields=[\"report_type\", \"-created_at\"]),\n models.Index(fields=[\"status\", \"-created_at\"]),\n ]\n verbose_name = _(\"Executive Report\")\n verbose_name_plural = _(\"Executive Reports\")\n\n def __str__(self):\n return f\"{self.get_report_type_display()} ({self.start_date} to {self.end_date}) - {self.get_status_display()}\"\n\n\nclass PredictiveInsight(UUIDModel, TimeStampedModel):\n \"\"\"\n Predictive Insight - AI-detected patterns, anomalies, and early warnings.\n \n Automatically generated insights identifying:\n - Emerging complaint patterns\n - Declining satisfaction trends\n - SLA breach risks\n - Performance anomalies\n - Resource bottlenecks\n \"\"\"\n\n INSIGHT_TYPES = [\n (\"trend_change\", _(\"Trend Change\")),\n (\"anomaly\", _(\"Anomaly Detected\")),\n (\"risk_warning\", _(\"Risk Warning\")),\n (\"sla_breach_risk\", _(\"SLA Breach Risk\")),\n (\"performance_drop\", _(\"Performance Drop\")),\n (\"volume_spike\", _(\"Volume Spike\")),\n (\"satisfaction_decline\", _(\"Satisfaction Decline\")),\n (\"positive_trend\", _(\"Positive Trend\")),\n ]\n\n SEVERITY_LEVELS = [\n (\"low\", _(\"Low\")),\n (\"medium\", _(\"Medium\")),\n (\"high\", _(\"High\")),\n (\"critical\", _(\"Critical\")),\n ]\n\n STATUS_CHOICES = [\n (\"new\", _(\"New\")),\n (\"acknowledged\", _(\"Acknowledged\")),\n (\"resolved\", _(\"Resolved\")),\n (\"dismissed\", _(\"Dismissed\")),\n ]\n\n # Insight details\n insight_type = models.CharField(max_length=30, choices=INSIGHT_TYPES, db_index=True)\n severity = models.CharField(max_length=10, choices=SEVERITY_LEVELS, default=\"medium\", db_index=True)\n status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=\"new\", db_index=True)\n \n # Title and description (bilingual)\n title_en = models.CharField(max_length=300)\n title_ar = models.CharField(max_length=300, blank=True)\n description_en = models.TextField()\n description_ar = models.TextField(blank=True)\n \n # AI-generated recommendation\n recommendation_en = models.TextField(blank=True)\n recommendation_ar = models.TextField(blank=True)\n \n # Affected entities\n hospital = models.ForeignKey(\n \"organizations.Hospital\",\n on_delete=models.SET_NULL,\n null=True,\n blank=True,\n related_name=\"predictive_insights\"\n )\n department = models.ForeignKey(\n \"organizations.Department\",\n on_delete=models.SET_NULL,\n null=True,\n blank=True,\n related_name=\"predictive_insights\"\n )\n \n # Metric affected\n metric_type = models.CharField(max_length=50, blank=True, help_text=\"Related metric type\")\n \n # Predicted values\n current_value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)\n predicted_value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)\n confidence_score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True, \n help_text=\"AI confidence score (0-100)\")\n \n # Time-related\n predicted_date = models.DateField(null=True, blank=True, help_text=\"When this is expected to occur\")\n \n # Acknowledgement\n acknowledged_by = models.ForeignKey(\n \"accounts.User\",\n on_delete=models.SET_NULL,\n null=True,\n blank=True,\n related_name=\"acknowledged_insights\"\n )\n acknowledged_at = models.DateTimeField(null=True, blank=True)\n \n # AI metadata\n ai_model = models.CharField(max_length=100, blank=True)\n detection_metadata = models.JSONField(default=dict, blank=True)\n\n class Meta:\n ordering = [\"-severity\", \"-created_at\"]\n indexes = [\n models.Index(fields=[\"status\", \"severity\", \"-created_at\"]),\n models.Index(fields=[\"hospital\", \"status\"]),\n ]\n verbose_name = _(\"Predictive Insight\")\n verbose_name_plural = _(\"Predictive Insights\")\n\n def __str__(self):\n return f\"[{self.get_severity_display()}] {self.title_en}\"\n\n\nclass AIRecommendation(UUIDModel, TimeStampedModel):\n \"\"\"\n AI Recommendation - Suggested actions based on data analysis.\n \n AI-generated recommendations for:\n - Process improvements\n - Resource allocation\n - Training needs\n - Policy changes\n - Preventive measures\n \"\"\"\n\n CATEGORY_CHOICES = [\n (\"process_improvement\", _(\"Process Improvement\")),\n (\"resource_allocation\", _(\"Resource Allocation\")),\n (\"training\", _(\"Training\")),\n (\"policy_change\", _(\"Policy Change\")),\n (\"preventive_action\", _(\"Preventive Action\")),\n (\"performance_optimization\", _(\"Performance Optimization\")),\n (\"communication\", _(\"Communication\")),\n (\"quality_assurance\", _(\"Quality Assurance\")),\n ]\n\n PRIORITY_LEVELS = [\n (\"low\", _(\"Low\")),\n (\"medium\", _(\"Medium\")),\n (\"high\", _(\"High\")),\n (\"urgent\", _(\"Urgent\")),\n ]\n\n STATUS_CHOICES = [\n (\"new\", _(\"New\")),\n (\"under_review\", _(\"Under Review\")),\n (\"approved\", _(\"Approved\")),\n (\"implemented\", _(\"Implemented\")),\n (\"rejected\", _(\"Rejected\")),\n ]\n\n # Recommendation details\n category = models.CharField(max_length=30, choices=CATEGORY_CHOICES, db_index=True)\n priority = models.CharField(max_length=10, choices=PRIORITY_LEVELS, default=\"medium\", db_index=True)\n status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=\"new\", db_index=True)\n \n # Title and description (bilingual)\n title_en = models.CharField(max_length=300)\n title_ar = models.CharField(max_length=300, blank=True)\n description_en = models.TextField()\n description_ar = models.TextField(blank=True)\n \n # Expected impact\n expected_impact_en = models.TextField(blank=True, help_text=\"Expected impact if implemented (English)\")\n expected_impact_ar = models.TextField(blank=True, help_text=\"Expected impact if implemented (Arabic)\")\n \n # Related data\n hospital = models.ForeignKey(\n \"organizations.Hospital\",\n on_delete=models.SET_NULL,\n null=True,\n blank=True,\n related_name=\"ai_recommend\"\n )\n department = models.ForeignKey(\n \"organizations.Department\",\n on_delete=models.SET_NULL,\n null=True,\n blank=True,\n related_name=\"ai_recommend\"\n )\n \n # Related insight (if generated from one)\n related_insight = models.ForeignKey(\n PredictiveInsight,\n on_delete=models.SET_NULL,\n null=True,\n blank=True,\n related_name=\"recommendations\"\n )\n \n # Implementation tracking\n implemented_by = models.ForeignKey(\n \"accounts.User\",\n on_delete=models.SET_NULL,\n null=True,\n blank=True,\n related_name=\"implemented_recommendations\"\n )\n implemented_at = models.DateTimeField(null=True, blank=True)\n \n # AI metadata\n confidence_score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)\n ai_model = models.CharField(max_length=100, blank=True)\n generation_metadata = models.JSONField(default=dict, blank=True)\n\n class Meta:\n ordering = [\"-priority\", \"-created_at\"]\n indexes = [\n models.Index(fields=[\"status\", \"priority\", \"-created_at\"]),\n models.Index(fields=[\"hospital\", \"status\"]),\n ]\n verbose_name = _(\"AI Recommendation\")\n verbose_name_plural = _(\"AI Recommendations\")\n\n def __str__(self):\n return f\"[{self.get_priority_display()}] {self.title_en}\"\n",
|
|
"file_path": "/home/ismail/projects/HH/apps/executive_summary/models.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/executive_summary/models.py`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `models.py`:*
|
|
```diff
|
|
"""
|
|
Executive Summary models - AI-powered executive dashboards and reports
|
|
|
|
This module implements:
|
|
- Executive metrics (KPI snapshots for trending)
|
|
- Executive reports (AI-generated weekly/monthly summaries)
|
|
- Predictive insights (AI-detected patterns and risks)
|
|
- AI recommendations (suggested actions)
|
|
"""
|
|
from django.db import models
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from apps.core.models import TimeStampedModel, UUIDModel
|
|
|
|
|
|
class ExecutiveMetric(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Executive Metric - Daily KPI snapshots for trending and analysis.
|
|
|
|
Stores aggregated metrics across all hospitals for executive overview.
|
|
Used for trend analysis and dashboard display.
|
|
"""
|
|
|
|
METRIC_TYPES = [
|
|
("complaints_total", _("Total Complaints")),
|
|
("complaints_critical", _("Critical Complaints")),
|
|
("complaints_overdue", _("Overdue Complaints")),
|
|
("complaints_resolution_time", _("Avg Resolution Time (hours)")),
|
|
("surveys_total", _("Total Surveys")),
|
|
("surveys_satisfaction", _("Satisfaction Rate %")),
|
|
("surveys_nps", _("NPS Score")),
|
|
("surveys_response_rate", _("Response Rate %")),
|
|
("actions_total", _("Total Actions")),
|
|
("actions_open", _("Open Actions")),
|
|
("actions_overdue", _("Overdue Actions")),
|
|
("actions_closed", _("Closed Actions")),
|
|
("observations_total", _("Total Observations")),
|
|
("observations_critical", _("Critical Observations")),
|
|
("inquiries_total", _("Total Inquiries")),
|
|
("inquiries_resolved", _("Resolved Inquiries")),
|
|
("call_center_total", _("Call Center Interactions")),
|
|
("call_center_satisfaction", _("Call Center Satisfaction %")),
|
|
("physician_avg_rating", _("Avg Physician Rating")),
|
|
]
|
|
|
|
# Date for this metric snapshot
|
|
metric_date = models.DateField(db_index=True, help_text="Date of this metric snapshot")
|
|
|
|
# Metric type
|
|
metric_type = models.CharField(max_length=50, choices=METRIC_TYPES, db_index=True)
|
|
|
|
# Metric value
|
|
metric_value = models.DecimalField(max_digits=10, decimal_places=2)
|
|
|
|
# Variance from previous period
|
|
variance = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True,
|
|
help_text="Variance from previous period")
|
|
|
|
# Variance direction
|
|
variance_direction = models.CharField(
|
|
max_length=10,
|
|
choices=[("up", "Up"), ("down", "Down"), ("neutral", "Neutral")],
|
|
default="neutral",
|
|
help_text="Direction of variance"
|
|
)
|
|
|
|
# Hospital (null = system-wide aggregate)
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital",
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name="executive_metrics"
|
|
)
|
|
|
|
# Metadata
|
|
metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-metric_date", "metric_type"]
|
|
indexes = [
|
|
models.Index(fields=["metric_date", "metric_type"]),
|
|
models.Index(fields=["hospital", "metric_date"]),
|
|
]
|
|
verbose_name = _("Executive Metric")
|
|
verbose_name_plural = _("Executive Metrics")
|
|
|
|
def __str__(self):
|
|
hospital_name = f" - {self.hospital.name}" if self.hospital else " (All)"
|
|
return f"{self.get_metric_type_display()}: {self.metric_value}{hospital_name} ({self.metric_date})"
|
|
|
|
|
|
class ExecutiveReport(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Executive Report - AI-generated weekly/monthly narrative summaries.
|
|
|
|
Contains natural language summaries generated by AI analyzing
|
|
performance data across all hospitals.
|
|
"""
|
|
|
|
REPORT_TYPES = [
|
|
("weekly", _("Weekly Summary")),
|
|
("monthly", _("Monthly Summary")),
|
|
("quarterly", _("Quarterly Summary")),
|
|
("custom", _("Custom Report")),
|
|
]
|
|
|
|
STATUS_CHOICES = [
|
|
("pending", _("Pending")),
|
|
("generating", _("Generating")),
|
|
("completed", _("Completed")),
|
|
("failed", _("Failed")),
|
|
]
|
|
|
|
# Report metadata
|
|
report_type = models.CharField(max_length=20, choices=REPORT_TYPES, db_index=True)
|
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending", db_index=True)
|
|
|
|
# Date range
|
|
start_date = models.DateField()
|
|
end_date = models.DateField()
|
|
|
|
# AI-generated narratives (bilingual)
|
|
narrative_en = models.TextField(blank=True, help_text="English narrative summary")
|
|
narrative_ar = models.TextField(blank=True, help_text="Arabic narrative summary")
|
|
|
|
# Key highlights
|
|
highlights_en = models.JSONField(default=list, blank=True, help_text="Key highlights (English)")
|
|
highlights_ar = models.JSONField(default=list, blank=True, help_text="Key highlights (Arabic)")
|
|
|
|
# Key concerns
|
|
concerns_en = models.JSONField(default=list, blank=True, help_text="Key concerns (English)")
|
|
concerns_ar = models.JSONField(default=list, blank=True, help_text="Key concerns (Arabic)")
|
|
|
|
# Summary metrics snapshot
|
|
metrics_snapshot = models.JSONField(default=dict, blank=True, help_text="Key metrics at report time")
|
|
|
|
# AI metadata
|
|
ai_model = models.CharField(max_length=100, blank=True, help_text="AI model used for generation")
|
|
generation_time_ms = models.IntegerField(null=True, blank=True, help_text="Generation time in milliseconds")
|
|
|
|
# PDF report file (if generated)
|
|
pdf_file = models.FileField(
|
|
upload_to="executive_reports/pdfs/%Y/%m/",
|
|
null=True,
|
|
blank=True,
|
|
help_text="Generated PDF report"
|
|
)
|
|
|
|
# Error message if failed
|
|
error_message = models.TextField(blank=True, help_text="Error message if generation failed")
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["report_type", "-created_at"]),
|
|
models.Index(fields=["status", "-created_at"]),
|
|
]
|
|
verbose_name = _("Executive Report")
|
|
verbose_name_plural = _("Executive Reports")
|
|
|
|
def __str__(self):
|
|
return f"{self.get_report_type_display()} ({self.start_date} to {self.end_date}) - {self.get_status_display()}"
|
|
|
|
|
|
class PredictiveInsight(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Predictive Insight - AI-detected patterns, anomalies, and early warnings.
|
|
|
|
Automatically generated insights identifying:
|
|
- Emerging complaint patterns
|
|
- Declining satisfaction trends
|
|
- SLA breach risks
|
|
- Performance anomalies
|
|
- Resource bottlenecks
|
|
"""
|
|
|
|
INSIGHT_TYPES = [
|
|
("trend_change", _("Trend Change")),
|
|
("anomaly", _("Anomaly Detected")),
|
|
("risk_warning", _("Risk Warning")),
|
|
("sla_breach_risk", _("SLA Breach Risk")),
|
|
("performance_drop", _("Performance Drop")),
|
|
("volume_spike", _("Volume Spike")),
|
|
("satisfaction_decline", _("Satisfaction Decline")),
|
|
("positive_trend", _("Positive Trend")),
|
|
]
|
|
|
|
SEVERITY_LEVELS = [
|
|
("low", _("Low")),
|
|
("medium", _("Medium")),
|
|
("high", _("High")),
|
|
("critical", _("Critical")),
|
|
]
|
|
|
|
STATUS_CHOICES = [
|
|
("new", _("New")),
|
|
("acknowledged", _("Acknowledged")),
|
|
("resolved", _("Resolved")),
|
|
("dismissed", _("Dismissed")),
|
|
]
|
|
|
|
# Insight details
|
|
insight_type = models.CharField(max_length=30, choices=INSIGHT_TYPES, db_index=True)
|
|
severity = models.CharField(max_length=10, choices=SEVERITY_LEVELS, default="medium", db_index=True)
|
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="new", db_index=True)
|
|
|
|
# Title and description (bilingual)
|
|
title_en = models.CharField(max_length=300)
|
|
title_ar = models.CharField(max_length=300, blank=True)
|
|
description_en = models.TextField()
|
|
description_ar = models.TextField(blank=True)
|
|
|
|
# AI-generated recommendation
|
|
recommendation_en = models.TextField(blank=True)
|
|
recommendation_ar = models.TextField(blank=True)
|
|
|
|
# Affected entities
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="predictive_insights"
|
|
)
|
|
department = models.ForeignKey(
|
|
"organizations.Department",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="predictive_insights"
|
|
)
|
|
|
|
# Metric affected
|
|
metric_type = models.CharField(max_length=50, blank=True, help_text="Related metric type")
|
|
|
|
# Predicted values
|
|
current_value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
|
predicted_value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
|
confidence_score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True,
|
|
help_text="AI confidence score (0-100)")
|
|
|
|
# Time-related
|
|
predicted_date = models.DateField(null=True, blank=True, help_text="When this is expected to occur")
|
|
|
|
# Acknowledgement
|
|
acknowledged_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="acknowledged_insights"
|
|
)
|
|
acknowledged_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# AI metadata
|
|
ai_model = models.CharField(max_length=100, blank=True)
|
|
detection_metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-severity", "-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["status", "severity", "-created_at"]),
|
|
models.Index(fields=["hospital", "status"]),
|
|
]
|
|
verbose_name = _("Predictive Insight")
|
|
verbose_name_plural = _("Predictive Insights")
|
|
|
|
def __str__(self):
|
|
return f"[{self.get_severity_display()}] {self.title_en}"
|
|
|
|
|
|
class AIRecommendation(UUIDModel, TimeStampedModel):
|
|
"""
|
|
AI Recommendation - Suggested actions based on data analysis.
|
|
|
|
AI-generated recommendations for:
|
|
- Process improvements
|
|
- Resource allocation
|
|
- Training needs
|
|
- Policy changes
|
|
- Preventive measures
|
|
"""
|
|
|
|
CATEGORY_CHOICES = [
|
|
("process_improvement", _("Process Improvement")),
|
|
("resource_allocation", _("Resource Allocation")),
|
|
("training", _("Training")),
|
|
("policy_change", _("Policy Change")),
|
|
("preventive_action", _("Preventive Action")),
|
|
("performance_optimization", _("Performance Optimization")),
|
|
("communication", _("Communication")),
|
|
("quality_assurance", _("Quality Assurance")),
|
|
]
|
|
|
|
PRIORITY_LEVELS = [
|
|
("low", _("Low")),
|
|
("medium", _("Medium")),
|
|
("high", _("High")),
|
|
("urgent", _("Urgent")),
|
|
]
|
|
|
|
STATUS_CHOICES = [
|
|
("new", _("New")),
|
|
("under_review", _("Under Review")),
|
|
("approved", _("Approved")),
|
|
("implemented", _("Implemented")),
|
|
("rejected", _("Rejected")),
|
|
]
|
|
|
|
# Recommendation details
|
|
category = models.CharField(max_length=30, choices=CATEGORY_CHOICES, db_index=True)
|
|
priority = models.CharField(max_length=10, choices=PRIORITY_LEVELS, default="medium", db_index=True)
|
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="new", db_index=True)
|
|
|
|
# Title and description (bilingual)
|
|
title_en = models.CharField(max_length=300)
|
|
title_ar = models.CharField(max_length=300, blank=True)
|
|
description_en = models.TextField()
|
|
description_ar = models.TextField(blank=True)
|
|
|
|
# Expected impact
|
|
expected_impact_en = models.TextField(blank=True, help_text="Expected impact if implemented (English)")
|
|
expected_impact_ar = models.TextField(blank=True, help_text="Expected impact if implemented (Arabic)")
|
|
|
|
# Related data
|
|
hospital = models.ForeignKey(
|
|
"organizations.Hospital",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="ai_recommend"
|
|
)
|
|
department = models.ForeignKey(
|
|
"organizations.Department",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="ai_recommend"
|
|
)
|
|
|
|
# Related insight (if generated from one)
|
|
related_insight = models.ForeignKey(
|
|
PredictiveInsight,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="recommendations"
|
|
)
|
|
|
|
# Implementation tracking
|
|
implemented_by = models.ForeignKey(
|
|
"accounts.User",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="implemented_recommendations"
|
|
)
|
|
implemented_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# AI metadata
|
|
confidence_score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
|
|
ai_model = models.CharField(max_length=100, blank=True)
|
|
generation_metadata = models.JSONField(default=dict, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ["-priority", "-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["status", "priority", "-created_at"]),
|
|
models.Index(fields=["hospital", "status"]),
|
|
]
|
|
verbose_name = _("AI Recommendation")
|
|
verbose_name_plural = _("AI Recommendations")
|
|
|
|
def __str__(self):
|
|
return f"[{self.get_priority_display()}] {self.title_en}"
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now that the models are created, I need to update the todo list to mark the completed tasks and move to the next one.
|
|
|
|
|
|
## Assistant
|
|
|
|
Perfect! Now let me update the todo and create the services. Due to the comprehensive nature of this feature, I'll create the services file with all AI and data aggregation logic:
|
|
|
|
|
|
### Tool: TodoWrite
|
|
**Status**: completed
|
|
|
|
**Output:**
|
|
```
|
|
- [x] Create Executive role and permissions
|
|
- [x] Create executive_summary Django app structure (models, views, services, tasks)
|
|
- [x] Build ExecutiveMetric and ExecutiveReport models
|
|
- [-] Create ExecutiveSummaryService for data aggregation
|
|
- [ ] Build Executive Dashboard view with KPI cards and filters
|
|
- [ ] Create Executive Dashboard template with ApexCharts
|
|
- [x] Add URL routing and sidebar navigation
|
|
- [ ] Implement AI Narrative Service using LiteLLM
|
|
- [ ] Build Predictive Analytics Service (trend analysis, risk detection)
|
|
- [ ] Create Recommendation Engine
|
|
- [ ] Integrate AI-generated briefs into dashboard
|
|
- [ ] Implement Natural Language Q&A interface
|
|
- [ ] Build Predictive Insights view and risk scoring
|
|
- [ ] Create Hospital comparison and leaderboard views
|
|
- [ ] Implement PDF report generation with WeasyPrint
|
|
- [ ] Create Celery scheduled tasks for automated reports
|
|
- [ ] Build mobile-optimized responsive view
|
|
- [ ] Add email delivery system for scheduled reports
|
|
- [ ] Add Arabic translations and RTL support
|
|
- [ ] Performance optimization and testing
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Given the comprehensive scope of this feature, I should delegate the services implementation to a general-purpose agent while I proceed with creating the views and templates.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me create the comprehensive services file. This will include data aggregation, AI narratives, predictive analytics, and recommendations. Due to the size, I'll create it in parts:
|
|
|
|
|
|
### Tool: Agent: Create executive summary services
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
````json
|
|
{
|
|
"description": "Create executive summary services",
|
|
"prompt": "Create a comprehensive services.py file for the executive_summary Django app at /home/ismail/projects/HH/apps/executive_summary/services.py\n\nThis file should include:\n\n1. **ExecutiveSummaryService** class with methods to:\n - aggregate_daily_metrics() - aggregates complaints, surveys, actions, observations data across all hospitals for a given date\n - get_dashboard_data(hospital=None, date_range=30) - returns comprehensive dashboard data with KPIs, trends, hospital comparisons\n - calculate_variance(current, previous) - calculates percentage variance\n - get_hospital_leaderboard() - ranks hospitals by satisfaction, complaint resolution, etc.\n - get_trend_data(metric_type, days=30) - returns trend data for charts\n\n2. **AINarrativeService** class using LiteLLM (import litellm from existing setup):\n - generate_weekly_narrative(start_date, end_date, hospital=None) - generates English narrative summary\n - generate_arabic_narrative(start_date, end_date, hospital=None) - generates Arabic narrative\n - format_metrics_for_ai(metrics_data) - formats metrics into prompt\n - The AI should analyze trends, highlight achievements, identify concerns, and provide actionable insights\n\n3. **PredictiveAnalyticsService** class:\n - detect_anomalies(metric_type, days=90) - detects statistical anomalies in metrics\n - predict_sla_breach(hospital=None, days_ahead=7) - predicts potential SLA breaches\n - identify_trend_changes(metric_type, days=60) - identifies significant trend changes\n - calculate_risk_scores(hospital=None) - calculates risk scores for hospitals/departments\n - generate_predictive_insights() - creates PredictiveInsight objects from analysis\n\n4. **RecommendationService** class:\n - generate_recommendations_from_insights() - creates AIRecommendation objects based on PredictiveInsights\n - analyze_best_practices() - identifies successful patterns from high-performing hospitals\n - suggest_resource_allocation() - suggests optimal resource allocation based on data\n\nUse Django ORM queries similar to the existing dashboard/views.py patterns. Reference models from apps.complaints.models, apps.surveys.models, apps.px_action_center.models, apps.observations.models.\n\nFor LiteLLM usage, use:\n```python\nfrom litellm import completion\n\nresponse = completion(\n model=\"openrouter/google/gemma-3-27b-it:free\",\n messages=[{\"role\": \"user\", \"content\": prompt}]\n)\n```\n\nMake the code production-ready with proper error handling, logging, and type hints. Use the translation framework for bilingual support.",
|
|
"subagent_type": "general-purpose"
|
|
}
|
|
````
|
|
|
|
**Output:**
|
|
```
|
|
The file has been created and passes syntax validation. Here is a summary of what was built:
|
|
|
|
**File created:** `/home/ismail/projects/HH/apps/executive_summary/services.py`
|
|
|
|
**Four service classes:**
|
|
|
|
1. **`ExecutiveSummaryService`** -- Aggregates metrics from complaints, surveys, PX actions, observations, inquiries, call center interactions, and physician ratings. Key methods:
|
|
- `aggregate_daily_metrics(target_date, hospital)` -- daily snapshot of all metric types
|
|
- `get_dashboard_data(hospital, date_range)` -- full dashboard payload with KPIs, variances, trends, and hospital leaderboard
|
|
- `calculate_variance(current, previous)` -- percentage variance with direction
|
|
- `get_hospital_leaderboard()` -- ranks hospitals by composite score (resolution rate 30%, satisfaction 30%, overdue rate 20%, action closure 20%)
|
|
- `get_trend_data(metric_type, days, hospital)` -- daily trend data for charting
|
|
|
|
2. **`AINarrativeService`** -- Uses LiteLLM (`openrouter/google/gemma-3-27b-it:free`) to generate executive narratives. Key methods:
|
|
- `generate_weekly_narrative(start_date, end_date, hospital)` -- English narrative with JSON-parsed executive summary, highlights, concerns, and actionable insights
|
|
- `generate_arabic_narrative(start_date, end_date, hospital)` -- Arabic equivalent with Arabic prompt
|
|
- `format_metrics_for_ai(metrics_data)` -- formats metrics into a structured human-readable prompt
|
|
|
|
3. **`PredictiveAnalyticsService`** -- Statistical analysis and prediction. Key methods:
|
|
- `detect_anomalies(metric_type, days, hospital)` -- z-score based anomaly detection (thresholds: >2 = medium, >2.5 = high, >3 = critical)
|
|
- `predict_sla_breach(hospital, days_ahead)` -- predicts SLA breaches for complaints, PX actions, and observations approaching their deadline
|
|
- `identify_trend_changes(metric_type, days, hospital)` -- compares first/second half averages, detects acceleration/deceleration
|
|
- `calculate_risk_scores(hospital)` -- composite risk score (SLA breach 30%, overdue 25%, satisfaction decline 20%, critical volume 15%, resolution time trend 10%)
|
|
- `generate_predictive_insights()` -- creates `PredictiveInsight` ORM objects from all analyses
|
|
|
|
4. **`RecommendationService`** -- Generates actionable recommendations. Key methods:
|
|
- `generate_recommendations_from_insights()` -- creates `AIRecommendation` objects for unhandled `PredictiveInsight` entries (with AI-generated content via LiteLLM)
|
|
- `analyze_best_practices()` -- identifies patterns from top-quartile hospitals and produces gap analysis for lower performers
|
|
- `suggest_resource_allocation()` -- analyzes workload distribution across hospitals and produces prioritized resource allocation recommendations
|
|
|
|
All classes use proper error handling with logging, type hints, and Django ORM query patterns consistent with `apps/dashboard/views.py`.
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now that services are created, I need to update the todo list to reflect progress and move on to building the views.
|
|
|
|
|
|
## Assistant
|
|
|
|
Excellent! Now let me create the views file for the Executive Dashboard. Let me update the todo and create the views:
|
|
|
|
|
|
### Tool: TodoWrite
|
|
**Status**: completed
|
|
|
|
**Output:**
|
|
```
|
|
- [x] Create Executive role and permissions
|
|
- [x] Create executive_summary Django app structure (models, views, services, tasks)
|
|
- [x] Build ExecutiveMetric and ExecutiveReport models
|
|
- [x] Create ExecutiveSummaryService for data aggregation
|
|
- [-] Build Executive Dashboard view with KPI cards and filters
|
|
- [ ] Create Executive Dashboard template with ApexCharts
|
|
- [x] Add URL routing and sidebar navigation
|
|
- [x] Implement AI Narrative Service using LiteLLM
|
|
- [x] Build Predictive Analytics Service (trend analysis, risk detection)
|
|
- [x] Create Recommendation Engine
|
|
- [ ] Integrate AI-generated briefs into dashboard
|
|
- [ ] Implement Natural Language Q&A interface
|
|
- [ ] Build Predictive Insights view and risk scoring
|
|
- [ ] Create Hospital comparison and leaderboard views
|
|
- [ ] Implement PDF report generation with WeasyPrint
|
|
- [ ] Create Celery scheduled tasks for automated reports
|
|
- [ ] Build mobile-optimized responsive view
|
|
- [ ] Add email delivery system for scheduled reports
|
|
- [ ] Add Arabic translations and RTL support
|
|
- [ ] Performance optimization and testing
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Given the comprehensive scope of the remaining implementation tasks (views, templates, Q&A, PDF generation, Celery tasks, etc.), I should delegate these to a general-purpose agent to implement in parallel while keeping the todo list updated.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me create the comprehensive views, URLs, templates, and tasks. I'll create them in parallel:
|
|
|
|
|
|
### Tool: Agent: Create executive summary views
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
````json
|
|
{
|
|
"description": "Create executive summary views",
|
|
"prompt": "Create a comprehensive views.py file for /home/ismail/projects/HH/apps/executive_summary/views.py\n\nThis Django app provides an AI-powered Executive Summary dashboard for top management in a healthcare patient experience system (PX360).\n\nCreate these views:\n\n1. **ExecutiveDashboardView** (LoginRequiredMixin, TemplateView):\n - URL: /executive/\n - Template: executive/dashboard.html\n - Permission: Requires user.is_executive() OR user.is_px_admin()\n - Context data:\n - AI narrative brief (latest ExecutiveReport)\n - KPI cards: complaints, surveys, actions, observations with variances\n - Risk alerts (high/critical PredictiveInsights)\n - Hospital leaderboard\n - AI recommendations (top 5)\n - Chart data (trends)\n - Filters: hospital selection, date range\n - Follow same patterns as /home/ismail/projects/HH/apps/dashboard/views.py CommandCenterView\n - Support filtering by hospital and date range\n\n2. **executive_dashboard_api** (login_required API view):\n - URL: /executive/api/\n - Returns JSON with latest metrics for HTMX updates\n - Same data structure as dashboard context\n\n3. **PredictiveInsightsView** (LoginRequiredMixin, TemplateView):\n - URL: /executive/insights/\n - Template: executive/insights.html\n - Shows all predictive insights with filtering\n - Allows acknowledging insights\n\n4. **acknowledge_insight** (POST view):\n - URL: /executive/insights/<uuid>/acknowledge/\n - Marks insight as acknowledged by user\n\n5. **ExecutiveQ&AView** (LoginRequiredMixin, TemplateView):\n - URL: /executive/qa/\n - Template: executive/qa.html\n - Natural language Q&A interface\n - Accepts question, returns AI answer with data\n\n6. **generate_executive_report** (admin view):\n - URL: /executive/reports/generate/\n - Triggers AI report generation\n - Creates ExecutiveReport object\n\n7. **ExecutivePDFReportView**:\n - URL: /executive/reports/<uuid>/pdf/\n - Generates PDF report using WeasyPrint\n - Uses WeasyPrint (already installed)\n - Returns PDF download\n\nUse these imports:\n```python\nfrom django.contrib.auth.decorators import login_required\nfrom django.contrib.auth.mixins import LoginRequiredMixin\nfrom django.shortcuts import render, redirect, get_object_or_404\nfrom django.views.generic import TemplateView\nfrom django.http import JsonResponse, HttpResponse\nfrom django.utils import timezone\nfrom django.utils.translation import gettext_lazy as _\nfrom django.conf import settings\nfrom datetime import timedelta, datetime\nimport json\nimport logging\n```\n\nFor PDF generation, use WeasyPrint:\n```python\nfrom weasyprint import HTML\nhtml_string = render_to_string('executive/pdf_report.html', context)\npdf_file = HTML(string=html_string).write_pdf()\n```\n\nFor AI Q&A, use LiteLLM:\n```python\nfrom litellm import completion\nresponse = completion(\n model=\"openrouter/google/gemma-3-27b-it:free\",\n messages=[{\"role\": \"user\", \"content\": prompt}]\n)\n```\n\nMake sure all views check for executive or px_admin permissions. Add proper logging and error handling. Follow Django best practices.",
|
|
"subagent_type": "general-purpose"
|
|
}
|
|
````
|
|
|
|
**Output:**
|
|
```
|
|
[Operation Cancelled] Reason: User cancelled tool execution.
|
|
```
|
|
|
|
|
|
### Tool: Agent: Create executive URLs
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
````json
|
|
{
|
|
"description": "Create executive URLs",
|
|
"prompt": "Create a urls.py file for /home/ismail/projects/HH/apps/executive_summary/urls.py\n\nThis should define URL patterns for the executive summary Django app.\n\nImport these views from .views:\n- ExecutiveDashboardView\n- executive_dashboard_api\n- PredictiveInsightsView\n- acknowledge_insight\n- ExecutiveQ&AView (use ExecutiveQAView instead to avoid special chars)\n- generate_executive_report\n- ExecutivePDFReportView\n\nCreate urlpatterns with:\n```python\nfrom django.urls import path\nfrom . import views\n\napp_name = \"executive\"\n\nurlpatterns = [\n path(\"\", views.ExecutiveDashboardView.as_view(), name=\"executive_dashboard\"),\n path(\"api/\", views.executive_dashboard_api, name=\"executive_api\"),\n path(\"insights/\", views.PredictiveInsightsView.as_view(), name=\"predictive_insights\"),\n path(\"insights/<uuid:insight_id>/acknowledge/\", views.acknowledge_insight, name=\"acknowledge_insight\"),\n path(\"qa/\", views.ExecutiveQAView.as_view(), name=\"executive_qa\"),\n path(\"qa/api/\", views.executive_qa_api, name=\"executive_qa_api\"),\n path(\"reports/generate/\", views.generate_executive_report, name=\"generate_report\"),\n path(\"reports/<uuid:report_id>/pdf/\", views.ExecutivePDFReportView.as_view(), name=\"report_pdf\"),\n]\n```\n\nMake sure to handle the import correctly - ExecutiveQ&AView should be ExecutiveQAView in the actual code.",
|
|
"subagent_type": "general-purpose"
|
|
}
|
|
````
|
|
|
|
**Output:**
|
|
```
|
|
Created `/home/ismail/projects/HH/apps/executive_summary/urls.py` with the URL patterns for the executive summary app. The file defines 8 routes under the `executive` app namespace, covering the dashboard, API endpoints, predictive insights, Q&A, and PDF report generation.
|
|
```
|
|
|
|
|
|
### Tool: Agent: Create executive Celery tasks
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
````json
|
|
{
|
|
"description": "Create executive Celery tasks",
|
|
"prompt": "Create a tasks.py file for /home/ismail/projects/HH/apps/executive_summary/tasks.py\n\nThis file defines Celery tasks for the executive summary app in a Django healthcare system (PX360).\n\nTasks to create:\n\n1. **generate_weekly_executive_summary**:\n - Runs every Monday at 6 AM\n - Generates weekly executive summary report\n - Uses AINarrativeService.generate_weekly_narrative()\n - Saves ExecutiveReport object\n - Logs success/failure\n\n2. **generate_monthly_executive_summary**:\n - Runs on 1st of each month at 7 AM\n - Generates monthly executive summary report\n - Same pattern as weekly\n\n3. **calculate_daily_metrics**:\n - Runs daily at 1 AM\n - Aggregates metrics for previous day across all hospitals\n - Uses ExecutiveSummaryService.aggregate_daily_metrics()\n - Creates ExecutiveMetric objects\n\n4. **generate_predictive_insights**:\n - Runs every 6 hours\n - Analyzes trends, anomalies, SLA breach risks\n - Creates PredictiveInsight objects\n - Uses PredictiveAnalyticsService\n\n5. **generate_ai_recommendations**:\n - Runs daily at 3 AM\n - Generates recommendations from insights\n - Uses RecommendationService\n\n6. **send_executive_pdf_report**:\n - Runs weekly and monthly after report generation\n - Emails PDF reports to executives\n - Uses Django email functionality\n\nUse this pattern:\n```python\nfrom celery import shared_task\nfrom django.utils import timezone\nfrom datetime import timedelta\nimport logging\n\nlogger = logging.getLogger(__name__)\n\n@shared_task\ndef task_name():\n \"\"\"Task docstring\"\"\"\n try:\n # Task logic\n logger.info(f\"Task completed successfully\")\n return {\"status\": \"success\"}\n except Exception as e:\n logger.error(f\"Task failed: {str(e)}\", exc_info=True)\n return {\"status\": \"error\", \"error\": str(e)}\n```\n\nImport services from .services import ExecutiveSummaryService, AINarrativeService, PredictiveAnalyticsService, RecommendationService\nImport models from .models import ExecutiveMetric, ExecutiveReport, PredictiveInsight, AIRecommendation\n\nMake tasks production-ready with proper error handling, logging, and retry logic.",
|
|
"subagent_type": "general-purpose"
|
|
}
|
|
````
|
|
|
|
**Output:**
|
|
```
|
|
Failed to run subagent: Model stream ended with empty response text.
|
|
```
|
|
|
|
|
|
## User
|
|
|
|
continue
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me check what was created and continue building the remaining components for the executive summary feature.
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me check what was created and continue building the remaining components:
|
|
|
|
|
|
### Tool: ReadFile: apps/executive_summary/urls.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/executive_summary/urls.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/executive_summary/urls.py`
|
|
|
|
**Output:**
|
|
```python
|
|
from django.urls import path
|
|
from . import views
|
|
|
|
app_name = "executive"
|
|
|
|
urlpatterns = [
|
|
path("", views.ExecutiveDashboardView.as_view(), name="executive_dashboard"),
|
|
path("api/", views.executive_dashboard_api, name="executive_api"),
|
|
path("insights/", views.PredictiveInsightsView.as_view(), name="predictive_insights"),
|
|
path("insights/<uuid:insight_id>/acknowledge/", views.acknowledge_insight, name="acknowledge_insight"),
|
|
path("qa/", views.ExecutiveQAView.as_view(), name="executive_qa"),
|
|
path("qa/api/", views.executive_qa_api, name="executive_qa_api"),
|
|
path("reports/generate/", views.generate_executive_report, name="generate_report"),
|
|
path("reports/<uuid:report_id>/pdf/", views.ExecutivePDFReportView.as_view(), name="report_pdf"),
|
|
]
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me check what was created and continue building the remaining components for the executive summary feature.
|
|
|
|
|
|
### Tool: ReadFile: apps/executive_summary/views.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/executive_summary/views.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/executive_summary/views.py`
|
|
|
|
**Output:**
|
|
```python
|
|
Showing lines 1-660 of 749 total lines.
|
|
|
|
---
|
|
|
|
"""
|
|
Executive Summary Views - AI-powered executive dashboard and analytics
|
|
|
|
This module provides views for:
|
|
- Executive dashboard with KPIs, AI narratives, and risk alerts
|
|
- Predictive insights management
|
|
- Executive Q&A with AI
|
|
- Executive report generation and PDF export
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import timedelta, datetime
|
|
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
from django.db.models import Avg, Count, Q
|
|
from django.http import JsonResponse, HttpResponse
|
|
from django.shortcuts import redirect, render, get_object_or_404
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.views.generic import TemplateView
|
|
from django.conf import settings
|
|
from django.contrib import messages
|
|
|
|
from weasyprint import HTML
|
|
|
|
from .models import ExecutiveReport, PredictiveInsight, AIRecommendation, ExecutiveMetric
|
|
from .services import (
|
|
ExecutiveSummaryService,
|
|
AINarrativeService,
|
|
PredictiveAnalyticsService,
|
|
RecommendationService,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# =============================================================================
|
|
# Mixins
|
|
# =============================================================================
|
|
|
|
|
|
class ExecutiveAccessMixin:
|
|
"""Mixin to restrict access to executive and px_admin users only."""
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
if not request.user.is_authenticated:
|
|
return redirect("account_login")
|
|
if not (request.user.is_executive() or request.user.is_px_admin()):
|
|
messages.error(request, _("You do not have permission to access the executive dashboard."))
|
|
return redirect("core:home")
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
|
|
# =============================================================================
|
|
# Executive Dashboard Views
|
|
# =============================================================================
|
|
|
|
|
|
class ExecutiveDashboardView(ExecutiveAccessMixin, LoginRequiredMixin, TemplateView):
|
|
"""
|
|
Executive Dashboard - AI-powered overview for top management.
|
|
|
|
Shows:
|
|
- AI-generated narrative brief (latest ExecutiveReport)
|
|
- KPI cards with variances (complaints, surveys, actions, observations)
|
|
- Risk alerts (high/critical PredictiveInsights)
|
|
- Hospital leaderboard
|
|
- AI recommendations (top 5)
|
|
- Chart data (trends)
|
|
- Filters (hospital selection, date range)
|
|
"""
|
|
|
|
template_name = "executive/dashboard.html"
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
user = self.request.user
|
|
|
|
# Parse filters
|
|
date_range = int(self.request.GET.get("date_range", 30))
|
|
hospital_id = self.request.GET.get("hospital")
|
|
|
|
# Initialize services
|
|
summary_service = ExecutiveSummaryService()
|
|
predictive_service = PredictiveAnalyticsService()
|
|
|
|
# Determine hospital scope
|
|
hospital = None
|
|
if hospital_id:
|
|
from apps.organizations.models import Hospital
|
|
hospital = get_object_or_404(Hospital, id=hospital_id)
|
|
elif user.is_hospital_admin() and user.hospital:
|
|
hospital = user.hospital
|
|
|
|
# ========================================
|
|
# AI NARRATIVE BRIEF
|
|
# ========================================
|
|
latest_report = ExecutiveReport.objects.filter(
|
|
status="completed"
|
|
).order_by("-created_at").first()
|
|
|
|
context["latest_report"] = latest_report
|
|
if latest_report:
|
|
context["ai_narrative"] = latest_report.narrative_en
|
|
context["ai_highlights"] = latest_report.highlights_en
|
|
context["ai_concerns"] = latest_report.concerns_en
|
|
context["report_period"] = f"{latest_report.start_date} to {latest_report.end_date}"
|
|
context["report_type"] = latest_report.get_report_type_display()
|
|
else:
|
|
context["ai_narrative"] = None
|
|
context["ai_highlights"] = []
|
|
context["ai_concerns"] = []
|
|
context["report_period"] = None
|
|
context["report_type"] = None
|
|
|
|
# ========================================
|
|
# DASHBOARD DATA (KPIs, trends, variances)
|
|
# ========================================
|
|
dashboard_data = summary_service.get_dashboard_data(
|
|
hospital=hospital,
|
|
date_range=date_range,
|
|
)
|
|
|
|
kpis = dashboard_data.get("kpis", {})
|
|
variances = dashboard_data.get("variances", {})
|
|
|
|
# KPI cards
|
|
context["kpi_cards"] = {
|
|
"complaints": {
|
|
"total": int(kpis.get("complaints_total", 0)),
|
|
"critical": int(kpis.get("complaints_critical", 0)),
|
|
"overdue": int(kpis.get("complaints_overdue", 0)),
|
|
"variance": variances.get("complaints_total", {}),
|
|
"resolution_time": kpis.get("complaints_resolution_time", 0),
|
|
},
|
|
"surveys": {
|
|
"total": int(kpis.get("surveys_total", 0)),
|
|
"satisfaction": float(kpis.get("surveys_satisfaction", 0)),
|
|
"nps": float(kpis.get("surveys_nps", 0)),
|
|
"variance": variances.get("surveys_satisfaction", {}),
|
|
},
|
|
"actions": {
|
|
"total": int(kpis.get("actions_total", 0)),
|
|
"open": int(kpis.get("actions_open", 0)),
|
|
"overdue": int(kpis.get("actions_overdue", 0)),
|
|
"closed": int(kpis.get("actions_closed", 0)),
|
|
"variance": variances.get("actions_total", {}),
|
|
},
|
|
"observations": {
|
|
"total": int(kpis.get("observations_total", 0)),
|
|
"critical": int(kpis.get("observations_critical", 0)),
|
|
"variance": variances.get("observations_total", {}),
|
|
},
|
|
}
|
|
|
|
# ========================================
|
|
# RISK ALERTS
|
|
# ========================================
|
|
risk_alerts = PredictiveInsight.objects.filter(
|
|
severity__in=["high", "critical"],
|
|
status__in=["new", "acknowledged"],
|
|
).select_related("hospital", "department").order_by("-severity", "-created_at")[:10]
|
|
|
|
context["risk_alerts"] = risk_alerts
|
|
context["has_risk_alerts"] = risk_alerts.exists()
|
|
|
|
# ========================================
|
|
# HOSPITAL LEADERBOARD
|
|
# ========================================
|
|
leaderboard = dashboard_data.get("hospital_leaderboard", [])
|
|
context["hospital_leaderboard"] = leaderboard[:10] # Top 10
|
|
|
|
# ========================================
|
|
# AI RECOMMENDATIONS
|
|
# ========================================
|
|
recommendations = AIRecommendation.objects.filter(
|
|
status__in=["new", "under_review"],
|
|
).select_related("hospital", "department").order_by("-priority", "-created_at")[:5]
|
|
|
|
context["ai_recommendations"] = recommendations
|
|
|
|
# ========================================
|
|
# CHART DATA
|
|
# ========================================
|
|
trends = dashboard_data.get("trends", {})
|
|
context["chart_data"] = {
|
|
"complaints_trend": json.dumps(trends.get("complaints_total", [])),
|
|
"surveys_satisfaction_trend": json.dumps(trends.get("surveys_satisfaction", [])),
|
|
"actions_trend": json.dumps(trends.get("actions_total", [])),
|
|
"observations_trend": json.dumps(trends.get("observations_total", [])),
|
|
}
|
|
|
|
# ========================================
|
|
# FILTERS
|
|
# ========================================
|
|
from apps.organizations.models import Hospital
|
|
|
|
if user.is_px_admin():
|
|
context["available_hospitals"] = Hospital.objects.filter(status="active")
|
|
elif user.is_hospital_admin() and user.hospital:
|
|
context["available_hospitals"] = [user.hospital]
|
|
else:
|
|
context["available_hospitals"] = Hospital.objects.filter(status="active")
|
|
|
|
context["selected_hospital"] = hospital
|
|
context["date_range"] = date_range
|
|
context["date_range_options"] = [
|
|
(7, _("Last 7 days")),
|
|
(14, _("Last 14 days")),
|
|
(30, _("Last 30 days")),
|
|
(60, _("Last 60 days")),
|
|
(90, _("Last 90 days")),
|
|
]
|
|
|
|
# ========================================
|
|
# METADATA
|
|
# ========================================
|
|
context["last_updated"] = timezone.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
context["is_px_admin"] = user.is_px_admin()
|
|
|
|
return context
|
|
|
|
|
|
@login_required
|
|
def executive_dashboard_api(request):
|
|
"""
|
|
API endpoint for executive dashboard HTMX updates.
|
|
|
|
Returns JSON with latest metrics, KPIs, and risk alerts.
|
|
"""
|
|
if not (request.user.is_executive() or request.user.is_px_admin()):
|
|
return JsonResponse({"error": _("Permission denied")}, status=403)
|
|
|
|
# Parse filters
|
|
date_range = int(request.GET.get("date_range", 30))
|
|
hospital_id = request.GET.get("hospital")
|
|
|
|
summary_service = ExecutiveSummaryService()
|
|
|
|
# Determine hospital scope
|
|
hospital = None
|
|
if hospital_id:
|
|
from apps.organizations.models import Hospital
|
|
try:
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
except Hospital.DoesNotExist:
|
|
pass
|
|
|
|
# Get dashboard data
|
|
dashboard_data = summary_service.get_dashboard_data(
|
|
hospital=hospital,
|
|
date_range=date_range,
|
|
)
|
|
|
|
# Get risk alerts
|
|
risk_alerts = PredictiveInsight.objects.filter(
|
|
severity__in=["high", "critical"],
|
|
status__in=["new", "acknowledged"],
|
|
).order_by("-severity", "-created_at")[:5]
|
|
|
|
risk_alerts_data = []
|
|
for insight in risk_alerts:
|
|
risk_alerts_data.append({
|
|
"id": str(insight.id),
|
|
"title": insight.title_en,
|
|
"severity": insight.severity,
|
|
"insight_type": insight.insight_type,
|
|
"hospital": insight.hospital.name if insight.hospital else None,
|
|
"created_at": insight.created_at.isoformat(),
|
|
})
|
|
|
|
# Get latest report narrative
|
|
latest_report = ExecutiveReport.objects.filter(
|
|
status="completed"
|
|
).order_by("-created_at").first()
|
|
|
|
response_data = {
|
|
"kpis": dashboard_data.get("kpis", {}),
|
|
"variances": dashboard_data.get("variances", {}),
|
|
"risk_alerts": risk_alerts_data,
|
|
"hospital_leaderboard": dashboard_data.get("hospital_leaderboard", [])[:5],
|
|
"ai_narrative": latest_report.narrative_en if latest_report else None,
|
|
"last_updated": timezone.now().isoformat(),
|
|
}
|
|
|
|
return JsonResponse(response_data)
|
|
|
|
|
|
# =============================================================================
|
|
# Predictive Insights Views
|
|
# =============================================================================
|
|
|
|
|
|
class PredictiveInsightsView(ExecutiveAccessMixin, LoginRequiredMixin, TemplateView):
|
|
"""
|
|
Predictive Insights - View and manage AI-detected patterns and risks.
|
|
|
|
Shows all predictive insights with filtering by:
|
|
- Severity (low, medium, high, critical)
|
|
- Status (new, acknowledged, resolved, dismissed)
|
|
- Insight type
|
|
- Hospital
|
|
"""
|
|
|
|
template_name = "executive/insights.html"
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
user = self.request.user
|
|
|
|
# Filters
|
|
severity_filter = self.request.GET.get("severity", "")
|
|
status_filter = self.request.GET.get("status", "")
|
|
insight_type_filter = self.request.GET.get("insight_type", "")
|
|
hospital_id = self.request.GET.get("hospital")
|
|
|
|
# Base queryset
|
|
insights_qs = PredictiveInsight.objects.select_related(
|
|
"hospital", "department", "acknowledged_by"
|
|
).order_by("-severity", "-created_at")
|
|
|
|
# Apply filters
|
|
if severity_filter:
|
|
insights_qs = insights_qs.filter(severity=severity_filter)
|
|
if status_filter:
|
|
insights_qs = insights_qs.filter(status=status_filter)
|
|
if insight_type_filter:
|
|
insights_qs = insights_qs.filter(insight_type=insight_type_filter)
|
|
if hospital_id:
|
|
from apps.organizations.models import Hospital
|
|
try:
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
insights_qs = insights_qs.filter(hospital=hospital)
|
|
except Hospital.DoesNotExist:
|
|
pass
|
|
elif user.is_hospital_admin() and user.hospital:
|
|
insights_qs = insights_qs.filter(hospital=user.hospital)
|
|
|
|
# Filter counts for sidebar
|
|
context["total_insights"] = insights_qs.count()
|
|
context["new_insights"] = insights_qs.filter(status="new").count()
|
|
context["critical_insights"] = insights_qs.filter(severity="critical").count()
|
|
context["high_insights"] = insights_qs.filter(severity="high").count()
|
|
|
|
# Pagination
|
|
from django.core.paginator import Paginator
|
|
paginator = Paginator(insights_qs, 25)
|
|
page_number = self.request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
context["insights"] = page_obj
|
|
context["page_obj"] = page_obj
|
|
|
|
# Filter options
|
|
context["severity_choices"] = PredictiveInsight.SEVERITY_LEVELS
|
|
context["status_choices"] = PredictiveInsight.STATUS_CHOICES
|
|
context["insight_type_choices"] = PredictiveInsight.INSIGHT_TYPES
|
|
|
|
# Hospitals for filter
|
|
if user.is_px_admin():
|
|
from apps.organizations.models import Hospital
|
|
context["available_hospitals"] = Hospital.objects.filter(
|
|
predictive_insights__isnull=False
|
|
).distinct()
|
|
else:
|
|
context["available_hospitals"] = []
|
|
|
|
# Current filter values
|
|
context["current_filters"] = {
|
|
"severity": severity_filter,
|
|
"status": status_filter,
|
|
"insight_type": insight_type_filter,
|
|
"hospital": hospital_id,
|
|
}
|
|
|
|
return context
|
|
|
|
|
|
@login_required
|
|
def acknowledge_insight(request, insight_id):
|
|
"""
|
|
Acknowledge a predictive insight.
|
|
|
|
Marks the insight as acknowledged by the current user.
|
|
"""
|
|
if not (request.user.is_executive() or request.user.is_px_admin()):
|
|
return JsonResponse({"error": _("Permission denied")}, status=403)
|
|
|
|
insight = get_object_or_404(PredictiveInsight, id=insight_id)
|
|
|
|
if request.method == "POST":
|
|
try:
|
|
insight.status = "acknowledged"
|
|
insight.acknowledged_by = request.user
|
|
insight.acknowledged_at = timezone.now()
|
|
insight.save(update_fields=["status", "acknowledged_by", "acknowledged_at"])
|
|
|
|
if request.headers.get("HX-Request"):
|
|
return HttpResponse(
|
|
f'<span class="text-green-600">{_("Acknowledged")}</span>'
|
|
)
|
|
return JsonResponse({
|
|
"status": "success",
|
|
"message": _("Insight acknowledged successfully"),
|
|
"insight_id": str(insight.id),
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Error acknowledging insight {insight_id}: {e}", exc_info=True)
|
|
if request.headers.get("HX-Request"):
|
|
return HttpResponse(
|
|
f'<span class="text-red-600">{_("Error acknowledging insight")}</span>',
|
|
status=500,
|
|
)
|
|
return JsonResponse({
|
|
"error": _("Error acknowledging insight"),
|
|
"details": str(e),
|
|
}, status=500)
|
|
|
|
return JsonResponse({"error": _("POST required")}, status=405)
|
|
|
|
|
|
# =============================================================================
|
|
# Executive Q&A Views
|
|
# =============================================================================
|
|
|
|
|
|
class ExecutiveQAView(ExecutiveAccessMixin, LoginRequiredMixin, TemplateView):
|
|
"""
|
|
Executive Q&A - Natural language Q&A interface for executives.
|
|
|
|
Allows executives to ask questions about PX data and receive
|
|
AI-powered answers with supporting data.
|
|
"""
|
|
|
|
template_name = "executive/qa.html"
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context["recent_questions"] = self._get_recent_questions()
|
|
context["suggested_questions"] = [
|
|
_("What is the overall satisfaction trend this month?"),
|
|
_("Which hospital has the highest complaint volume?"),
|
|
_("Are there any SLA breaches predicted this week?"),
|
|
_("What are the top 3 areas for improvement?"),
|
|
_("How does our NPS compare to last quarter?"),
|
|
]
|
|
return context
|
|
|
|
def _get_recent_questions(self):
|
|
"""Get recent questions from session for demo purposes."""
|
|
return self.request.session.get("executive_qa_history", [])
|
|
|
|
|
|
@login_required
|
|
def executive_qa_api(request):
|
|
"""
|
|
API endpoint for Executive Q&A.
|
|
|
|
Accepts a question via POST, returns an AI-generated answer with data.
|
|
"""
|
|
if request.method != "POST":
|
|
return JsonResponse({"error": _("POST required")}, status=405)
|
|
|
|
if not (request.user.is_executive() or request.user.is_px_admin()):
|
|
return JsonResponse({"error": _("Permission denied")}, status=403)
|
|
|
|
question = request.POST.get("question", "").strip()
|
|
if not question:
|
|
return JsonResponse({"error": _("Question is required")}, status=400)
|
|
|
|
try:
|
|
# Gather current data for context
|
|
summary_service = ExecutiveSummaryService()
|
|
now = timezone.now()
|
|
last_30d = now - timedelta(days=30)
|
|
|
|
# Collect key metrics
|
|
metrics = summary_service.aggregate_daily_metrics()
|
|
|
|
# Get some context data
|
|
from apps.complaints.models import Complaint
|
|
from apps.surveys.models import SurveyInstance
|
|
from apps.px_action_center.models import PXAction
|
|
|
|
total_complaints_30d = Complaint.objects.filter(created_at__gte=last_30d).count()
|
|
avg_satisfaction = SurveyInstance.objects.filter(
|
|
completed_at__gte=last_30d, total_score__isnull=False
|
|
).aggregate(avg=Avg("total_score"))["avg"] or 0
|
|
open_actions = PXAction.objects.filter(status__in=["open", "in_progress"]).count()
|
|
critical_insights = PredictiveInsight.objects.filter(
|
|
severity__in=["high", "critical"], status="new"
|
|
).count()
|
|
|
|
# Build AI prompt with data context
|
|
prompt = f"""You are an executive analyst for PX360, a healthcare patient experience platform.
|
|
Answer the following question based on the current system data.
|
|
|
|
## Current System Data:
|
|
- Total complaints (today): {metrics.get('complaints_total', 0)}
|
|
- Critical complaints: {metrics.get('complaints_critical', 0)}
|
|
- Overdue complaints: {metrics.get('complaints_overdue', 0)}
|
|
- Average resolution time: {metrics.get('complaints_resolution_time', 0):.1f} hours
|
|
- Total surveys (today): {metrics.get('surveys_total', 0)}
|
|
- Satisfaction rate: {metrics.get('surveys_satisfaction', 0):.1f}%
|
|
- NPS score: {metrics.get('surveys_nps', 0):.1f}
|
|
- Total actions (today): {metrics.get('actions_total', 0)}
|
|
- Open actions: {metrics.get('actions_open', 0)}
|
|
- Total observations (today): {metrics.get('observations_total', 0)}
|
|
- Critical observations: {metrics.get('observations_critical', 0)}
|
|
- Complaints (last 30 days): {total_complaints_30d}
|
|
- Average satisfaction (last 30 days): {float(avg_satisfaction):.1f}/5.0
|
|
- Open/in-progress actions: {open_actions}
|
|
- Critical/new insights: {critical_insights}
|
|
|
|
## Question:
|
|
{question}
|
|
|
|
Provide a concise, data-driven answer. Reference specific numbers when relevant.
|
|
If the question cannot be answered with the available data, say so and suggest what data would be needed.
|
|
|
|
Format your response as JSON:
|
|
{{
|
|
"answer": "Your answer here...",
|
|
"supporting_data": ["key data point 1", "key data point 2"],
|
|
"confidence": "high|medium|low"
|
|
}}
|
|
"""
|
|
|
|
# Call AI
|
|
from litellm import completion
|
|
response = completion(
|
|
model="openrouter/google/gemma-3-27b-it:free",
|
|
messages=[{"role": "user", "content": prompt}],
|
|
temperature=0.3,
|
|
max_tokens=1000,
|
|
)
|
|
|
|
# Parse response
|
|
try:
|
|
result = json.loads(response.choices[0].message.content)
|
|
except json.JSONDecodeError:
|
|
result = {
|
|
"answer": response.choices[0].message.content,
|
|
"supporting_data": [],
|
|
"confidence": "medium",
|
|
}
|
|
|
|
# Store in session history
|
|
qa_history = request.session.get("executive_qa_history", [])
|
|
qa_history.insert(0, {
|
|
"question": question,
|
|
"answer": result.get("answer", ""),
|
|
"timestamp": timezone.now().isoformat(),
|
|
})
|
|
request.session["executive_qa_history"] = qa_history[:10] # Keep last 10
|
|
|
|
return JsonResponse({
|
|
"status": "success",
|
|
"question": question,
|
|
"answer": result.get("answer", ""),
|
|
"supporting_data": result.get("supporting_data", []),
|
|
"confidence": result.get("confidence", "medium"),
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in executive Q&A: {e}", exc_info=True)
|
|
return JsonResponse({
|
|
"status": "error",
|
|
"error": _("Unable to process question. Please try again."),
|
|
"details": str(e) if settings.DEBUG else None,
|
|
}, status=500)
|
|
|
|
|
|
# =============================================================================
|
|
# Executive Report Generation
|
|
# =============================================================================
|
|
|
|
|
|
@login_required
|
|
def generate_executive_report(request):
|
|
"""
|
|
Trigger AI report generation for executive summary.
|
|
|
|
Creates an ExecutiveReport object and generates both English and Arabic narratives.
|
|
Only accessible to px_admin and executive users.
|
|
"""
|
|
if not (request.user.is_executive() or request.user.is_px_admin()):
|
|
messages.error(request, _("You do not have permission to generate reports."))
|
|
return redirect("executive:executive_dashboard")
|
|
|
|
if request.method != "POST":
|
|
return redirect("executive:executive_dashboard")
|
|
|
|
# Get report parameters
|
|
report_type = request.POST.get("report_type", "weekly")
|
|
start_date_str = request.POST.get("start_date")
|
|
end_date_str = request.POST.get("end_date")
|
|
|
|
now = timezone.now()
|
|
|
|
# Calculate date range
|
|
if start_date_str and end_date_str:
|
|
try:
|
|
start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date()
|
|
end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
messages.error(request, _("Invalid date format. Use YYYY-MM-DD."))
|
|
return redirect("executive:executive_dashboard")
|
|
else:
|
|
# Default based on report type
|
|
if report_type == "weekly":
|
|
end_date = now.date()
|
|
start_date = end_date - timedelta(days=7)
|
|
elif report_type == "monthly":
|
|
end_date = now.date()
|
|
start_date = end_date - timedelta(days=30)
|
|
elif report_type == "quarterly":
|
|
end_date = now.date()
|
|
start_date = end_date - timedelta(days=90)
|
|
else:
|
|
end_date = now.date()
|
|
start_date = end_date - timedelta(days=7)
|
|
|
|
try:
|
|
# Create report object
|
|
report = ExecutiveReport.objects.create(
|
|
report_type=report_type,
|
|
status="generating",
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
ai_model="openrouter/google/gemma-3-27b-it:free",
|
|
)
|
|
|
|
# Generate narratives using AI services
|
|
narrative_service = AINarrativeService()
|
|
|
|
# Generate English narrative
|
|
en_result = narrative_service.generate_weekly_narrative(
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
)
|
|
|
|
# Generate Arabic narrative
|
|
ar_result = narrative_service.generate_arabic_narrative(
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
)
|
|
|
|
# Update report with results
|
|
report.status = en_result.get("status", "completed")
|
|
report.narrative_en = en_result.get("narrative_en", "")
|
|
report.narrative_ar = ar_result.get("narrative_ar", "")
|
|
report.highlights_en = en_result.get("highlights_en", [])
|
|
report.highlights_ar = ar_result.get("highlights_ar", [])
|
|
report.concerns_en = en_result.get("concerns_en", [])
|
|
report.concerns_ar = ar_result.get("concerns_ar", [])
|
|
report.ai_model = en_result.get("ai_model", "")
|
|
re... [truncated]
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me check what files exist and create the missing ones.
|
|
|
|
|
|
## Assistant
|
|
|
|
Good, the views.py is created. Now let me create the templates and admin. Let me create them directly:
|
|
|
|
|
|
### Tool: Shell: mkdir -p /home/ismail/projects/HH/apps/executive_summary/templates/executive (Create templates directory for executive app)
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"command": "mkdir -p /home/ismail/projects/HH/apps/executive_summary/templates/executive",
|
|
"description": "Create templates directory for executive app"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Command: mkdir -p /home/ismail/projects/HH/apps/executive_summary/templates/executive
|
|
Directory: (root)
|
|
Output: (empty)
|
|
Error: (none)
|
|
Exit Code: 0
|
|
Signal: 0
|
|
Process Group PGID: 291611
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to create the dashboard template with all the UI components for the executive summary.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me create the main dashboard template with all the AI-powered components:
|
|
|
|
|
|
### Tool: WriteFile: Writing to apps/executive_summary/templates/executive/dashboard.html
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"content": "{% extends \"layouts/base.html\" %}\n{% load i18n %}\n{% load static %}\n\n{% block title %}{% trans \"Executive Summary\" %} | PX360{% endblock %}\n\n{% block extra_css %}\n<style>\n .kpi-card {\n transition: all 0.3s ease;\n }\n .kpi-card:hover {\n transform: translateY(-4px);\n box-shadow: 0 10px 25px rgba(0,0,0,0.1);\n }\n .variance-up {\n color: #10b981;\n }\n .variance-down {\n color: #ef4444;\n }\n .variance-neutral {\n color: #6b7280;\n }\n .risk-alert {\n border-left: 4px solid #ef4444;\n }\n .risk-alert.high {\n border-left-color: #f97316;\n }\n .risk-alert.critical {\n border-left-color: #ef4444;\n }\n .ai-brief {\n background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n color: white;\n }\n .leaderboard-item {\n transition: all 0.2s ease;\n }\n .leaderboard-item:hover {\n background-color: rgba(99, 102, 241, 0.05);\n }\n .pulse-dot {\n animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n }\n @keyframes pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: .5; }\n }\n</style>\n{% endblock %}\n\n{% block content %}\n<div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8\">\n <!-- Page Header -->\n <div class=\"flex flex-col md:flex-row md:items-center md:justify-between mb-8\">\n <div>\n <h1 class=\"text-3xl font-bold text-gray-900\">{% trans \"Executive Summary\" %}</h1>\n <p class=\"mt-2 text-sm text-gray-600\">\n {% trans \"AI-powered insights and analytics\" %}\n {% if selected_hospital %}\n • {{ selected_hospital.name }}\n {% else %}\n • {% trans \"All Hospitals\" %}\n {% endif %}\n </p>\n </div>\n \n <div class=\"mt-4 md:mt-0 flex items-center gap-3\">\n <!-- Date Range Filter -->\n <form method=\"get\" class=\"flex items-center gap-2\">\n {% if selected_hospital %}\n <input type=\"hidden\" name=\"hospital\" value=\"{{ selected_hospital.id }}\">\n {% endif %}\n <select name=\"date_range\" onchange=\"this.form.submit()\" \n class=\"form-select text-sm border-gray-300 rounded-lg\">\n {% for days, label in date_range_options %}\n <option value=\"{{ days }}\" {% if date_range == days %}selected{% endif %}>\n {{ label }}\n </option>\n {% endfor %}\n </select>\n \n <!-- Hospital Filter (PX Admin only) -->\n {% if is_px_admin %}\n <select name=\"hospital\" onchange=\"this.form.submit()\" \n class=\"form-select text-sm border-gray-300 rounded-lg\">\n <option value=\"\">{% trans \"All Hospitals\" %}</option>\n {% for hospital in available_hospitals %}\n <option value=\"{{ hospital.id }}\" \n {% if selected_hospital and selected_hospital.id == hospital.id %}selected{% endif %}>\n {{ hospital.name }}\n </option>\n {% endfor %}\n </select>\n {% endif %}\n </form>\n \n <!-- Refresh Button -->\n <button onclick=\"location.reload()\" class=\"p-2 text-gray-600 hover:text-indigo-600 transition\">\n <i data-lucide=\"refresh-cw\" class=\"w-5 h-5\"></i>\n </button>\n </div>\n </div>\n \n <!-- Last Updated -->\n <div class=\"flex items-center gap-2 mb-6 text-sm text-gray-500\">\n <div class=\"w-2 h-2 bg-green-500 rounded-full pulse-dot\"></div>\n <span>{% trans \"Live data\" %}</span>\n <span>•</span>\n <span>{% trans \"Last updated\" %}: {{ last_updated }}</span>\n </div>\n\n <!-- AI Executive Brief -->\n {% if ai_narrative %}\n <div class=\"ai-brief rounded-xl p-6 mb-8 shadow-lg\">\n <div class=\"flex items-start gap-3 mb-4\">\n <i data-lucide=\"sparkles\" class=\"w-6 h-6 flex-shrink-0\"></i>\n <div>\n <h2 class=\"text-xl font-bold\">{% trans \"AI Executive Brief\" %}</h2>\n <p class=\"text-sm text-white/80\">\n {% trans \"Generated by AI\" %} • {{ report_type }} • {{ report_period }}\n </p>\n </div>\n </div>\n \n <div class=\"bg-white/10 backdrop-blur rounded-lg p-4 mb-4\">\n <p class=\"text-base leading-relaxed whitespace-pre-line\">{{ ai_narrative }}</p>\n </div>\n \n <div class=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n {% if ai_highlights %}\n <div>\n <h3 class=\"text-sm font-semibold mb-2 flex items-center gap-2\">\n <i data-lucide=\"trending-up\" class=\"w-4 h-4\"></i>\n {% trans \"Key Highlights\" %}\n </h3>\n <ul class=\"space-y-1 text-sm\">\n {% for highlight in ai_highlights|slice:\":5\" %}\n <li class=\"flex items-start gap-2\">\n <i data-lucide=\"check-circle\" class=\"w-4 h-4 flex-shrink-0 mt-0.5\"></i>\n <span>{{ highlight }}</span>\n </li>\n {% endfor %}\n </ul>\n </div>\n {% endif %}\n \n {% if ai_concerns %}\n <div>\n <h3 class=\"text-sm font-semibold mb-2 flex items-center gap-2\">\n <i data-lucide=\"alert-triangle\" class=\"w-4 h-4\"></i>\n {% trans \"Key Concerns\" %}\n </h3>\n <ul class=\"space-y-1 text-sm\">\n {% for concern in ai_concerns|slice:\":5\" %}\n <li class=\"flex items-start gap-2\">\n <i data-lucide=\"alert-circle\" class=\"w-4 h-4 flex-shrink-0 mt-0.5\"></i>\n <span>{{ concern }}</span>\n </li>\n {% endfor %}\n </ul>\n </div>\n {% endif %}\n </div>\n </div>\n {% else %}\n <div class=\"bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl p-6 mb-8 text-center shadow-lg\">\n <i data-lucide=\"sparkles\" class=\"w-12 h-12 mx-auto mb-4 text-white/80\"></i>\n <h2 class=\"text-xl font-bold text-white mb-2\">{% trans \"No AI Report Available\" %}</h2>\n <p class=\"text-white/90 mb-4\">\n {% trans \"Generate your first executive summary to get AI-powered insights\" %}\n </p>\n <form method=\"post\" action=\"{% url 'executive:generate_report' %}\" class=\"inline\">\n {% csrf_token %}\n <input type=\"hidden\" name=\"report_type\" value=\"weekly\">\n <button type=\"submit\" class=\"bg-white text-indigo-600 px-6 py-2 rounded-lg font-semibold hover:bg-gray-100 transition\">\n {% trans \"Generate Report\" %}\n </button>\n </form>\n </div>\n {% endif %}\n\n <!-- Risk Alerts -->\n {% if has_risk_alerts %}\n <div class=\"mb-8\">\n <div class=\"flex items-center justify-between mb-4\">\n <h2 class=\"text-xl font-bold text-gray-900 flex items-center gap-2\">\n <i data-lucide=\"alert-triangle\" class=\"w-5 h-5 text-red-500\"></i>\n {% trans \"Risk Alerts\" %}\n <span class=\"ml-2 bg-red-100 text-red-800 text-xs font-semibold px-2.5 py-0.5 rounded-full\">\n {{ risk_alerts|length }}\n </span>\n </h2>\n <a href=\"{% url 'executive:predictive_insights' %}\" class=\"text-sm text-indigo-600 hover:text-indigo-700\">\n {% trans \"View All Insights\" %} →\n </a>\n </div>\n \n <div class=\"grid grid-cols-1 lg:grid-cols-2 gap-4\">\n {% for insight in risk_alerts %}\n <div class=\"risk-alert {{ insight.severity }} bg-white rounded-lg p-4 shadow-sm\">\n <div class=\"flex items-start justify-between gap-3\">\n <div class=\"flex-1\">\n <div class=\"flex items-center gap-2 mb-2\">\n <span class=\"px-2 py-0.5 text-xs font-semibold rounded-full\n {% if insight.severity == 'critical' %}bg-red-100 text-red-800\n {% elif insight.severity == 'high' %}bg-orange-100 text-orange-800\n {% else %}bg-yellow-100 text-yellow-800{% endif %}\">\n {{ insight.get_severity_display }}\n </span>\n <span class=\"text-xs text-gray-500\">{{ insight.get_insight_type_display }}</span>\n </div>\n <h3 class=\"font-semibold text-gray-900 mb-1\">{{ insight.title_en }}</h3>\n <p class=\"text-sm text-gray-600 line-clamp-2\">{{ insight.description_en }}</p>\n {% if insight.hospital %}\n <p class=\"text-xs text-gray-500 mt-2\">\n <i data-lucide=\"building\" class=\"w-3 h-3 inline\"></i>\n {{ insight.hospital.name }}\n </p>\n {% endif %}\n </div>\n <form method=\"post\" action=\"{% url 'executive:acknowledge_insight' insight.id %}\" \n class=\"flex-shrink-0\">\n {% csrf_token %}\n <button type=\"submit\" \n class=\"px-3 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded transition\">\n {% trans \"Acknowledge\" %}\n </button>\n </form>\n </div>\n </div>\n {% endfor %}\n </div>\n </div>\n {% endif %}\n\n <!-- KPI Cards -->\n <div class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8\">\n <!-- Complaints -->\n <div class=\"kpi-card bg-white rounded-xl p-6 shadow-sm border border-gray-200\">\n <div class=\"flex items-center justify-between mb-4\">\n <div class=\"p-2 bg-red-100 rounded-lg\">\n <i data-lucide=\"message-square\" class=\"w-6 h-6 text-red-600\"></i>\n </div>\n {% include \"executive/partials/variance_badge.html\" with variance=kpi_cards.complaints.variance %}\n </div>\n <h3 class=\"text-2xl font-bold text-gray-900\">{{ kpi_cards.complaints.total }}</h3>\n <p class=\"text-sm text-gray-600\">{% trans \"Total Complaints\" %}</p>\n <div class=\"mt-3 pt-3 border-t border-gray-100 grid grid-cols-2 gap-2 text-xs\">\n <div>\n <span class=\"text-gray-500\">{% trans \"Critical\" %}:</span>\n <span class=\"font-semibold text-red-600 ml-1\">{{ kpi_cards.complaints.critical }}</span>\n </div>\n <div>\n <span class=\"text-gray-500\">{% trans \"Overdue\" %}:</span>\n <span class=\"font-semibold text-orange-600 ml-1\">{{ kpi_cards.complaints.overdue }}</span>\n </div>\n <div class=\"col-span-2\">\n <span class=\"text-gray-500\">{% trans \"Avg Resolution\" %}:</span>\n <span class=\"font-semibold ml-1\">{{ kpi_cards.complaints.resolution_time }}h</span>\n </div>\n </div>\n </div>\n\n <!-- Surveys -->\n <div class=\"kpi-card bg-white rounded-xl p-6 shadow-sm border border-gray-200\">\n <div class=\"flex items-center justify-between mb-4\">\n <div class=\"p-2 bg-green-100 rounded-lg\">\n <i data-lucide=\"clipboard-check\" class=\"w-6 h-6 text-green-600\"></i>\n </div>\n {% include \"executive/partials/variance_badge.html\" with variance=kpi_cards.surveys.variance %}\n </div>\n <h3 class=\"text-2xl font-bold text-gray-900\">{{ kpi_cards.surveys.satisfaction }}%</h3>\n <p class=\"text-sm text-gray-600\">{% trans \"Satisfaction Rate\" %}</p>\n <div class=\"mt-3 pt-3 border-t border-gray-100 grid grid-cols-2 gap-2 text-xs\">\n <div>\n <span class=\"text-gray-500\">{% trans \"NPS\" %}:</span>\n <span class=\"font-semibold text-green-600 ml-1\">{{ kpi_cards.surveys.nps }}</span>\n </div>\n <div>\n <span class=\"text-gray-500\">{% trans \"Total\" %}:</span>\n <span class=\"font-semibold ml-1\">{{ kpi_cards.surveys.total }}</span>\n </div>\n </div>\n </div>\n\n <!-- Actions -->\n <div class=\"kpi-card bg-white rounded-xl p-6 shadow-sm border border-gray-200\">\n <div class=\"flex items-center justify-between mb-4\">\n <div class=\"p-2 bg-blue-100 rounded-lg\">\n <i data-lucide=\"activity\" class=\"w-6 h-6 text-blue-600\"></i>\n </div>\n {% include \"executive/partials/variance_badge.html\" with variance=kpi_cards.actions.variance %}\n </div>\n <h3 class=\"text-2xl font-bold text-gray-900\">{{ kpi_cards.actions.total }}</h3>\n <p class=\"text-sm text-gray-600\">{% trans \"Total Actions\" %}</p>\n <div class=\"mt-3 pt-3 border-t border-gray-100 grid grid-cols-3 gap-2 text-xs\">\n <div>\n <span class=\"text-gray-500\">{% trans \"Open\" %}:</span>\n <span class=\"font-semibold text-blue-600 ml-1\">{{ kpi_cards.actions.open }}</span>\n </div>\n <div>\n <span class=\"text-gray-500\">{% trans \"Closed\" %}:</span>\n <span class=\"font-semibold text-green-600 ml-1\">{{ kpi_cards.actions.closed }}</span>\n </div>\n <div>\n <span class=\"text-gray-500\">{% trans \"Overdue\" %}:</span>\n <span class=\"font-semibold text-red-600 ml-1\">{{ kpi_cards.actions.overdue }}</span>\n </div>\n </div>\n </div>\n\n <!-- Observations -->\n <div class=\"kpi-card bg-white rounded-xl p-6 shadow-sm border border-gray-200\">\n <div class=\"flex items-center justify-between mb-4\">\n <div class=\"p-2 bg-yellow-100 rounded-lg\">\n <i data-lucide=\"eye\" class=\"w-6 h-6 text-yellow-600\"></i>\n </div>\n {% include \"executive/partials/variance_badge.html\" with variance=kpi_cards.observations.variance %}\n </div>\n <h3 class=\"text-2xl font-bold text-gray-900\">{{ kpi_cards.observations.total }}</h3>\n <p class=\"text-sm text-gray-600\">{% trans \"Observations\" %}</p>\n <div class=\"mt-3 pt-3 border-t border-gray-100 grid grid-cols-2 gap-2 text-xs\">\n <div>\n <span class=\"text-gray-500\">{% trans \"Critical\" %}:</span>\n <span class=\"font-semibold text-red-600 ml-1\">{{ kpi_cards.observations.critical }}</span>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Charts & Leaderboard -->\n <div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\">\n <!-- Complaints Trend Chart -->\n <div class=\"lg:col-span-2 bg-white rounded-xl p-6 shadow-sm border border-gray-200\">\n <h3 class=\"text-lg font-bold text-gray-900 mb-4\">{% trans \"Complaints Trend\" %}</h3>\n <div id=\"complaintsChart\"></div>\n </div>\n\n <!-- Hospital Leaderboard -->\n <div class=\"bg-white rounded-xl p-6 shadow-sm border border-gray-200\">\n <h3 class=\"text-lg font-bold text-gray-900 mb-4 flex items-center gap-2\">\n <i data-lucide=\"trophy\" class=\"w-5 h-5 text-yellow-500\"></i>\n {% trans \"Hospital Leaderboard\" %}\n </h3>\n <div class=\"space-y-3\">\n {% for hospital in hospital_leaderboard|slice:\":5\" %}\n <div class=\"leaderboard-item flex items-center justify-between p-3 rounded-lg {% if forloop.counter == 1 %}bg-yellow-50 border border-yellow-200{% elif forloop.counter == 2 %}bg-gray-50 border border-gray-200{% elif forloop.counter == 3 %}bg-orange-50 border border-orange-200{% else %}bg-gray-50{% endif %}\">\n <div class=\"flex items-center gap-3\">\n <span class=\"w-8 h-8 flex items-center justify-center rounded-full text-sm font-bold\n {% if forloop.counter == 1 %}bg-yellow-500 text-white\n {% elif forloop.counter == 2 %}bg-gray-400 text-white\n {% elif forloop.counter == 3 %}bg-orange-400 text-white\n {% else %}bg-gray-300 text-gray-700{% endif %}\">\n {{ forloop.counter }}\n </span>\n <div>\n <p class=\"font-semibold text-gray-900 text-sm\">{{ hospital.name }}</p>\n <p class=\"text-xs text-gray-500\">{% trans \"Score\" %}: {{ hospital.composite_score|floatformat:1 }}</p>\n </div>\n </div>\n <div class=\"text-right\">\n <p class=\"text-sm font-bold text-green-600\">{{ hospital.satisfaction_rate|floatformat:0 }}%</p>\n <p class=\"text-xs text-gray-500\">{% trans \"satisfaction\" %}</p>\n </div>\n </div>\n {% empty %}\n <p class=\"text-sm text-gray-500 text-center py-4\">{% trans \"No hospital data available\" %}</p>\n {% endfor %}\n </div>\n </div>\n </div>\n\n <!-- Satisfaction Trend & AI Recommendations -->\n <div class=\"grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8\">\n <!-- Satisfaction Trend -->\n <div class=\"lg:col-span-2 bg-white rounded-xl p-6 shadow-sm border border-gray-200\">\n <h3 class=\"text-lg font-bold text-gray-900 mb-4\">{% trans \"Satisfaction Trend\" %}</h3>\n <div id=\"satisfactionChart\"></div>\n </div>\n\n <!-- AI Recommendations -->\n <div class=\"bg-white rounded-xl p-6 shadow-sm border border-gray-200\">\n <h3 class=\"text-lg font-bold text-gray-900 mb-4 flex items-center gap-2\">\n <i data-lucide=\"lightbulb\" class=\"w-5 h-5 text-amber-500\"></i>\n {% trans \"AI Recommendations\" %}\n </h3>\n <div class=\"space-y-3\">\n {% for rec in ai_recommendations %}\n <div class=\"p-3 rounded-lg border-l-4 {% if rec.priority == 'urgent' %}border-red-500 bg-red-50{% elif rec.priority == 'high' %}border-orange-500 bg-orange-50{% elif rec.priority == 'medium' %}border-yellow-500 bg-yellow-50{% else %}border-blue-500 bg-blue-50{% endif %}\">\n <h4 class=\"font-semibold text-gray-900 text-sm mb-1\">{{ rec.title_en }}</h4>\n <p class=\"text-xs text-gray-600 line-clamp-2\">{{ rec.description_en }}</p>\n {% if rec.hospital %}\n <p class=\"text-xs text-gray-500 mt-2\">\n <i data-lucide=\"building\" class=\"w-3 h-3 inline\"></i>\n {{ rec.hospital.name }}\n </p>\n {% endif %}\n </div>\n {% empty %}\n <p class=\"text-sm text-gray-500 text-center py-4\">{% trans \"No recommendations available\" %}</p>\n {% endfor %}\n </div>\n <a href=\"#\" class=\"mt-3 text-sm text-indigo-600 hover:text-indigo-700 inline-block\">\n {% trans \"View All Recommendations\" %} →\n </a>\n </div>\n </div>\n\n <!-- Quick Actions -->\n <div class=\"bg-white rounded-xl p-6 shadow-sm border border-gray-200\">\n <h3 class=\"text-lg font-bold text-gray-900 mb-4\">{% trans \"Quick Actions\" %}</h3>\n <div class=\"grid grid-cols-1 md:grid-cols-3 gap-4\">\n <a href=\"{% url 'executive:predictive_insights' %}\" \n class=\"flex items-center gap-3 p-4 rounded-lg border-2 border-gray-200 hover:border-indigo-300 hover:bg-indigo-50 transition\">\n <i data-lucide=\"trending-up\" class=\"w-6 h-6 text-indigo-600\"></i>\n <div>\n <p class=\"font-semibold text-gray-900\">{% trans \"Predictive Insights\" %}</p>\n <p class=\"text-xs text-gray-600\">{% trans \"View AI-detected patterns and risks\" %}</p>\n </div>\n </a>\n \n <a href=\"{% url 'executive:executive_qa' %}\" \n class=\"flex items-center gap-3 p-4 rounded-lg border-2 border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition\">\n <i data-lucide=\"message-circle\" class=\"w-6 h-6 text-purple-600\"></i>\n <div>\n <p class=\"font-semibold text-gray-900\">{% trans \"Ask AI\" %}</p>\n <p class=\"text-xs text-gray-600\">{% trans \"Ask questions about your data\" %}</p>\n </div>\n </a>\n \n <form method=\"post\" action=\"{% url 'executive:generate_report' %}\" class=\"flex items-center gap-3 p-4 rounded-lg border-2 border-gray-200 hover:border-green-300 hover:bg-green-50 transition cursor-pointer\">\n {% csrf_token %}\n <input type=\"hidden\" name=\"report_type\" value=\"weekly\">\n <i data-lucide=\"file-text\" class=\"w-6 h-6 text-green-600\"></i>\n <div>\n <p class=\"font-semibold text-gray-900\">{% trans \"Generate Report\" %}</p>\n <p class=\"text-xs text-gray-600\">{% trans \"Create AI executive summary\" %}</p>\n </div>\n </form>\n </div>\n </div>\n</div>\n{% endblock %}\n\n{% block extra_js %}\n<script src=\"https://cdn.jsdelivr.net/npm/apexcharts\"></script>\n<script>\ndocument.addEventListener('DOMContentLoaded', function() {\n // Complaints Trend Chart\n const complaintsData = JSON.parse('{{ chart_data.complaints_trend|escapejs }}');\n if (complaintsData && complaintsData.length > 0) {\n const complaintsOptions = {\n series: [{\n name: '{% trans \"Complaints\" %}',\n data: complaintsData.map(d => d.value)\n }],\n chart: {\n type: 'area',\n height: 300,\n toolbar: { show: false }\n },\n colors: ['#ef4444'],\n stroke: { curve: 'smooth', width: 2 },\n fill: {\n type: 'gradient',\n gradient: {\n shadeIntensity: 1,\n opacityFrom: 0.4,\n opacityTo: 0.1,\n }\n },\n xaxis: {\n categories: complaintsData.map(d => d.date),\n labels: { rotate: -45 }\n },\n yaxis: {\n labels: { formatter: function(val) { return Math.round(val); } }\n },\n tooltip: {\n x: { format: 'dd MMM yyyy' }\n }\n };\n const complaintsChart = new ApexCharts(document.querySelector(\"#complaintsChart\"), complaintsOptions);\n complaintsChart.render();\n }\n\n // Satisfaction Trend Chart\n const satisfactionData = JSON.parse('{{ chart_data.surveys_satisfaction_trend|escapejs }}');\n if (satisfactionData && satisfactionData.length > 0) {\n const satisfactionOptions = {\n series: [{\n name: '{% trans \"Satisfaction %\" %}',\n data: satisfactionData.map(d => d.value)\n }],\n chart: {\n type: 'line',\n height: 300,\n toolbar: { show: false }\n },\n colors: ['#10b981'],\n stroke: { curve: 'smooth', width: 2 },\n xaxis: {\n categories: satisfactionData.map(d => d.date),\n labels: { rotate: -45 }\n },\n yaxis: {\n min: 0,\n max: 100,\n labels: { formatter: function(val) { return val.toFixed(0) + '%'; } }\n },\n tooltip: {\n x: { format: 'dd MMM yyyy' }\n }\n };\n const satisfactionChart = new ApexCharts(document.querySelector(\"#satisfactionChart\"), satisfactionOptions);\n satisfactionChart.render();\n }\n\n // Initialize Lucide icons\n lucide.createIcons();\n});\n</script>\n{% endblock %}\n",
|
|
"file_path": "/home/ismail/projects/HH/apps/executive_summary/templates/executive/dashboard.html"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/executive_summary/templates/executive/dashboard.html`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `dashboard.html`:*
|
|
```diff
|
|
{% extends "layouts/base.html" %}
|
|
{% load i18n %}
|
|
{% load static %}
|
|
|
|
{% block title %}{% trans "Executive Summary" %} | PX360{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.kpi-card {
|
|
transition: all 0.3s ease;
|
|
}
|
|
.kpi-card:hover {
|
|
transform: translateY(-4px);
|
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
|
}
|
|
.variance-up {
|
|
color: #10b981;
|
|
}
|
|
.variance-down {
|
|
color: #ef4444;
|
|
}
|
|
.variance-neutral {
|
|
color: #6b7280;
|
|
}
|
|
.risk-alert {
|
|
border-left: 4px solid #ef4444;
|
|
}
|
|
.risk-alert.high {
|
|
border-left-color: #f97316;
|
|
}
|
|
.risk-alert.critical {
|
|
border-left-color: #ef4444;
|
|
}
|
|
.ai-brief {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
}
|
|
.leaderboard-item {
|
|
transition: all 0.2s ease;
|
|
}
|
|
.leaderboard-item:hover {
|
|
background-color: rgba(99, 102, 241, 0.05);
|
|
}
|
|
.pulse-dot {
|
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: .5; }
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<!-- Page Header -->
|
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-gray-900">{% trans "Executive Summary" %}</h1>
|
|
<p class="mt-2 text-sm text-gray-600">
|
|
{% trans "AI-powered insights and analytics" %}
|
|
{% if selected_hospital %}
|
|
• {{ selected_hospital.name }}
|
|
{% else %}
|
|
• {% trans "All Hospitals" %}
|
|
{% endif %}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="mt-4 md:mt-0 flex items-center gap-3">
|
|
<!-- Date Range Filter -->
|
|
<form method="get" class="flex items-center gap-2">
|
|
{% if selected_hospital %}
|
|
<input type="hidden" name="hospital" value="{{ selected_hospital.id }}">
|
|
{% endif %}
|
|
<select name="date_range" onchange="this.form.submit()"
|
|
class="form-select text-sm border-gray-300 rounded-lg">
|
|
{% for days, label in date_range_options %}
|
|
<option value="{{ days }}" {% if date_range == days %}selected{% endif %}>
|
|
{{ label }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
|
|
<!-- Hospital Filter (PX Admin only) -->
|
|
{% if is_px_admin %}
|
|
<select name="hospital" onchange="this.form.submit()"
|
|
class="form-select text-sm border-gray-300 rounded-lg">
|
|
<option value="">{% trans "All Hospitals" %}</option>
|
|
{% for hospital in available_hospitals %}
|
|
<option value="{{ hospital.id }}"
|
|
{% if selected_hospital and selected_hospital.id == hospital.id %}selected{% endif %}>
|
|
{{ hospital.name }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% endif %}
|
|
</form>
|
|
|
|
<!-- Refresh Button -->
|
|
<button onclick="location.reload()" class="p-2 text-gray-600 hover:text-indigo-600 transition">
|
|
<i data-lucide="refresh-cw" class="w-5 h-5"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Last Updated -->
|
|
<div class="flex items-center gap-2 mb-6 text-sm text-gray-500">
|
|
<div class="w-2 h-2 bg-green-500 rounded-full pulse-dot"></div>
|
|
<span>{% trans "Live data" %}</span>
|
|
<span>•</span>
|
|
<span>{% trans "Last updated" %}: {{ last_updated }}</span>
|
|
</div>
|
|
|
|
<!-- AI Executive Brief -->
|
|
{% if ai_narrative %}
|
|
<div class="ai-brief rounded-xl p-6 mb-8 shadow-lg">
|
|
<div class="flex items-start gap-3 mb-4">
|
|
<i data-lucide="sparkles" class="w-6 h-6 flex-shrink-0"></i>
|
|
<div>
|
|
<h2 class="text-xl font-bold">{% trans "AI Executive Brief" %}</h2>
|
|
<p class="text-sm text-white/80">
|
|
{% trans "Generated by AI" %} • {{ report_type }} • {{ report_period }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white/10 backdrop-blur rounded-lg p-4 mb-4">
|
|
<p class="text-base leading-relaxed whitespace-pre-line">{{ ai_narrative }}</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{% if ai_highlights %}
|
|
<div>
|
|
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
|
|
<i data-lucide="trending-up" class="w-4 h-4"></i>
|
|
{% trans "Key Highlights" %}
|
|
</h3>
|
|
<ul class="space-y-1 text-sm">
|
|
{% for highlight in ai_highlights|slice:":5" %}
|
|
<li class="flex items-start gap-2">
|
|
<i data-lucide="check-circle" class="w-4 h-4 flex-shrink-0 mt-0.5"></i>
|
|
<span>{{ highlight }}</span>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if ai_concerns %}
|
|
<div>
|
|
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
|
|
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
|
|
{% trans "Key Concerns" %}
|
|
</h3>
|
|
<ul class="space-y-1 text-sm">
|
|
{% for concern in ai_concerns|slice:":5" %}
|
|
<li class="flex items-start gap-2">
|
|
<i data-lucide="alert-circle" class="w-4 h-4 flex-shrink-0 mt-0.5"></i>
|
|
<span>{{ concern }}</span>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl p-6 mb-8 text-center shadow-lg">
|
|
<i data-lucide="sparkles" class="w-12 h-12 mx-auto mb-4 text-white/80"></i>
|
|
<h2 class="text-xl font-bold text-white mb-2">{% trans "No AI Report Available" %}</h2>
|
|
<p class="text-white/90 mb-4">
|
|
{% trans "Generate your first executive summary to get AI-powered insights" %}
|
|
</p>
|
|
<form method="post" action="{% url 'executive:generate_report' %}" class="inline">
|
|
{% csrf_token %}
|
|
<input type="hidden" name="report_type" value="weekly">
|
|
<button type="submit" class="bg-white text-indigo-600 px-6 py-2 rounded-lg font-semibold hover:bg-gray-100 transition">
|
|
{% trans "Generate Report" %}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Risk Alerts -->
|
|
{% if has_risk_alerts %}
|
|
<div class="mb-8">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-xl font-bold text-gray-900 flex items-center gap-2">
|
|
<i data-lucide="alert-triangle" class="w-5 h-5 text-red-500"></i>
|
|
{% trans "Risk Alerts" %}
|
|
<span class="ml-2 bg-red-100 text-red-800 text-xs font-semibold px-2.5 py-0.5 rounded-full">
|
|
{{ risk_alerts|length }}
|
|
</span>
|
|
</h2>
|
|
<a href="{% url 'executive:predictive_insights' %}" class="text-sm text-indigo-600 hover:text-indigo-700">
|
|
{% trans "View All Insights" %} →
|
|
</a>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{% for insight in risk_alerts %}
|
|
<div class="risk-alert {{ insight.severity }} bg-white rounded-lg p-4 shadow-sm">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="px-2 py-0.5 text-xs font-semibold rounded-full
|
|
{% if insight.severity == 'critical' %}bg-red-100 text-red-800
|
|
{% elif insight.severity == 'high' %}bg-orange-100 text-orange-800
|
|
{% else %}bg-yellow-100 text-yellow-800{% endif %}">
|
|
{{ insight.get_severity_display }}
|
|
</span>
|
|
<span class="text-xs text-gray-500">{{ insight.get_insight_type_display }}</span>
|
|
</div>
|
|
<h3 class="font-semibold text-gray-900 mb-1">{{ insight.title_en }}</h3>
|
|
<p class="text-sm text-gray-600 line-clamp-2">{{ insight.description_en }}</p>
|
|
{% if insight.hospital %}
|
|
<p class="text-xs text-gray-500 mt-2">
|
|
<i data-lucide="building" class="w-3 h-3 inline"></i>
|
|
{{ insight.hospital.name }}
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
<form method="post" action="{% url 'executive:acknowledge_insight' insight.id %}"
|
|
class="flex-shrink-0">
|
|
{% csrf_token %}
|
|
<button type="submit"
|
|
class="px-3 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded transition">
|
|
{% trans "Acknowledge" %}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- KPI Cards -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
<!-- Complaints -->
|
|
<div class="kpi-card bg-white rounded-xl p-6 shadow-sm border border-gray-200">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="p-2 bg-red-100 rounded-lg">
|
|
<i data-lucide="message-square" class="w-6 h-6 text-red-600"></i>
|
|
</div>
|
|
{% include "executive/partials/variance_badge.html" with variance=kpi_cards.complaints.variance %}
|
|
</div>
|
|
<h3 class="text-2xl font-bold text-gray-900">{{ kpi_cards.complaints.total }}</h3>
|
|
<p class="text-sm text-gray-600">{% trans "Total Complaints" %}</p>
|
|
<div class="mt-3 pt-3 border-t border-gray-100 grid grid-cols-2 gap-2 text-xs">
|
|
<div>
|
|
<span class="text-gray-500">{% trans "Critical" %}:</span>
|
|
<span class="font-semibold text-red-600 ml-1">{{ kpi_cards.complaints.critical }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">{% trans "Overdue" %}:</span>
|
|
<span class="font-semibold text-orange-600 ml-1">{{ kpi_cards.complaints.overdue }}</span>
|
|
</div>
|
|
<div class="col-span-2">
|
|
<span class="text-gray-500">{% trans "Avg Resolution" %}:</span>
|
|
<span class="font-semibold ml-1">{{ kpi_cards.complaints.resolution_time }}h</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Surveys -->
|
|
<div class="kpi-card bg-white rounded-xl p-6 shadow-sm border border-gray-200">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="p-2 bg-green-100 rounded-lg">
|
|
<i data-lucide="clipboard-check" class="w-6 h-6 text-green-600"></i>
|
|
</div>
|
|
{% include "executive/partials/variance_badge.html" with variance=kpi_cards.surveys.variance %}
|
|
</div>
|
|
<h3 class="text-2xl font-bold text-gray-900">{{ kpi_cards.surveys.satisfaction }}%</h3>
|
|
<p class="text-sm text-gray-600">{% trans "Satisfaction Rate" %}</p>
|
|
<div class="mt-3 pt-3 border-t border-gray-100 grid grid-cols-2 gap-2 text-xs">
|
|
<div>
|
|
<span class="text-gray-500">{% trans "NPS" %}:</span>
|
|
<span class="font-semibold text-green-600 ml-1">{{ kpi_cards.surveys.nps }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">{% trans "Total" %}:</span>
|
|
<span class="font-semibold ml-1">{{ kpi_cards.surveys.total }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="kpi-card bg-white rounded-xl p-6 shadow-sm border border-gray-200">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="p-2 bg-blue-100 rounded-lg">
|
|
<i data-lucide="activity" class="w-6 h-6 text-blue-600"></i>
|
|
</div>
|
|
{% include "executive/partials/variance_badge.html" with variance=kpi_cards.actions.variance %}
|
|
</div>
|
|
<h3 class="text-2xl font-bold text-gray-900">{{ kpi_cards.actions.total }}</h3>
|
|
<p class="text-sm text-gray-600">{% trans "Total Actions" %}</p>
|
|
<div class="mt-3 pt-3 border-t border-gray-100 grid grid-cols-3 gap-2 text-xs">
|
|
<div>
|
|
<span class="text-gray-500">{% trans "Open" %}:</span>
|
|
<span class="font-semibold text-blue-600 ml-1">{{ kpi_cards.actions.open }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">{% trans "Closed" %}:</span>
|
|
<span class="font-semibold text-green-600 ml-1">{{ kpi_cards.actions.closed }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-gray-500">{% trans "Overdue" %}:</span>
|
|
<span class="font-semibold text-red-600 ml-1">{{ kpi_cards.actions.overdue }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Observations -->
|
|
<div class="kpi-card bg-white rounded-xl p-6 shadow-sm border border-gray-200">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="p-2 bg-yellow-100 rounded-lg">
|
|
<i data-lucide="eye" class="w-6 h-6 text-yellow-600"></i>
|
|
</div>
|
|
{% include "executive/partials/variance_badge.html" with variance=kpi_cards.observations.variance %}
|
|
</div>
|
|
<h3 class="text-2xl font-bold text-gray-900">{{ kpi_cards.observations.total }}</h3>
|
|
<p class="text-sm text-gray-600">{% trans "Observations" %}</p>
|
|
<div class="mt-3 pt-3 border-t border-gray-100 grid grid-cols-2 gap-2 text-xs">
|
|
<div>
|
|
<span class="text-gray-500">{% trans "Critical" %}:</span>
|
|
<span class="font-semibold text-red-600 ml-1">{{ kpi_cards.observations.critical }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts & Leaderboard -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
|
<!-- Complaints Trend Chart -->
|
|
<div class="lg:col-span-2 bg-white rounded-xl p-6 shadow-sm border border-gray-200">
|
|
<h3 class="text-lg font-bold text-gray-900 mb-4">{% trans "Complaints Trend" %}</h3>
|
|
<div id="complaintsChart"></div>
|
|
</div>
|
|
|
|
<!-- Hospital Leaderboard -->
|
|
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-200">
|
|
<h3 class="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
|
|
<i data-lucide="trophy" class="w-5 h-5 text-yellow-500"></i>
|
|
{% trans "Hospital Leaderboard" %}
|
|
</h3>
|
|
<div class="space-y-3">
|
|
{% for hospital in hospital_leaderboard|slice:":5" %}
|
|
<div class="leaderboard-item flex items-center justify-between p-3 rounded-lg {% if forloop.counter == 1 %}bg-yellow-50 border border-yellow-200{% elif forloop.counter == 2 %}bg-gray-50 border border-gray-200{% elif forloop.counter == 3 %}bg-orange-50 border border-orange-200{% else %}bg-gray-50{% endif %}">
|
|
<div class="flex items-center gap-3">
|
|
<span class="w-8 h-8 flex items-center justify-center rounded-full text-sm font-bold
|
|
{% if forloop.counter == 1 %}bg-yellow-500 text-white
|
|
{% elif forloop.counter == 2 %}bg-gray-400 text-white
|
|
{% elif forloop.counter == 3 %}bg-orange-400 text-white
|
|
{% else %}bg-gray-300 text-gray-700{% endif %}">
|
|
{{ forloop.counter }}
|
|
</span>
|
|
<div>
|
|
<p class="font-semibold text-gray-900 text-sm">{{ hospital.name }}</p>
|
|
<p class="text-xs text-gray-500">{% trans "Score" %}: {{ hospital.composite_score|floatformat:1 }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<p class="text-sm font-bold text-green-600">{{ hospital.satisfaction_rate|floatformat:0 }}%</p>
|
|
<p class="text-xs text-gray-500">{% trans "satisfaction" %}</p>
|
|
</div>
|
|
</div>
|
|
{% empty %}
|
|
<p class="text-sm text-gray-500 text-center py-4">{% trans "No hospital data available" %}</p>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Satisfaction Trend & AI Recommendations -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
|
<!-- Satisfaction Trend -->
|
|
<div class="lg:col-span-2 bg-white rounded-xl p-6 shadow-sm border border-gray-200">
|
|
<h3 class="text-lg font-bold text-gray-900 mb-4">{% trans "Satisfaction Trend" %}</h3>
|
|
<div id="satisfactionChart"></div>
|
|
</div>
|
|
|
|
<!-- AI Recommendations -->
|
|
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-200">
|
|
<h3 class="text-lg font-bold text-gray-900 mb-4 flex items-center gap-2">
|
|
<i data-lucide="lightbulb" class="w-5 h-5 text-amber-500"></i>
|
|
{% trans "AI Recommendations" %}
|
|
</h3>
|
|
<div class="space-y-3">
|
|
{% for rec in ai_recommendations %}
|
|
<div class="p-3 rounded-lg border-l-4 {% if rec.priority == 'urgent' %}border-red-500 bg-red-50{% elif rec.priority == 'high' %}border-orange-500 bg-orange-50{% elif rec.priority == 'medium' %}border-yellow-500 bg-yellow-50{% else %}border-blue-500 bg-blue-50{% endif %}">
|
|
<h4 class="font-semibold text-gray-900 text-sm mb-1">{{ rec.title_en }}</h4>
|
|
<p class="text-xs text-gray-600 line-clamp-2">{{ rec.description_en }}</p>
|
|
{% if rec.hospital %}
|
|
<p class="text-xs text-gray-500 mt-2">
|
|
<i data-lucide="building" class="w-3 h-3 inline"></i>
|
|
{{ rec.hospital.name }}
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
{% empty %}
|
|
<p class="text-sm text-gray-500 text-center py-4">{% trans "No recommendations available" %}</p>
|
|
{% endfor %}
|
|
</div>
|
|
<a href="#" class="mt-3 text-sm text-indigo-600 hover:text-indigo-700 inline-block">
|
|
{% trans "View All Recommendations" %} →
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Actions -->
|
|
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-200">
|
|
<h3 class="text-lg font-bold text-gray-900 mb-4">{% trans "Quick Actions" %}</h3>
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<a href="{% url 'executive:predictive_insights' %}"
|
|
class="flex items-center gap-3 p-4 rounded-lg border-2 border-gray-200 hover:border-indigo-300 hover:bg-indigo-50 transition">
|
|
<i data-lucide="trending-up" class="w-6 h-6 text-indigo-600"></i>
|
|
<div>
|
|
<p class="font-semibold text-gray-900">{% trans "Predictive Insights" %}</p>
|
|
<p class="text-xs text-gray-600">{% trans "View AI-detected patterns and risks" %}</p>
|
|
</div>
|
|
</a>
|
|
|
|
<a href="{% url 'executive:executive_qa' %}"
|
|
class="flex items-center gap-3 p-4 rounded-lg border-2 border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition">
|
|
<i data-lucide="message-circle" class="w-6 h-6 text-purple-600"></i>
|
|
<div>
|
|
<p class="font-semibold text-gray-900">{% trans "Ask AI" %}</p>
|
|
<p class="text-xs text-gray-600">{% trans "Ask questions about your data" %}</p>
|
|
</div>
|
|
</a>
|
|
|
|
<form method="post" action="{% url 'executive:generate_report' %}" class="flex items-center gap-3 p-4 rounded-lg border-2 border-gray-200 hover:border-green-300 hover:bg-green-50 transition cursor-pointer">
|
|
{% csrf_token %}
|
|
<input type="hidden" name="report_type" value="weekly">
|
|
<i data-lucide="file-text" class="w-6 h-6 text-green-600"></i>
|
|
<div>
|
|
<p class="font-semibold text-gray-900">{% trans "Generate Report" %}</p>
|
|
<p class="text-xs text-gray-600">{% trans "Create AI executive summary" %}</p>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Complaints Trend Chart
|
|
const complaintsData = JSON.parse('{{ chart_data.complaints_trend|escapejs }}');
|
|
if (complaintsData && complaintsData.length > 0) {
|
|
const complaintsOptions = {
|
|
series: [{
|
|
name: '{% trans "Complaints" %}',
|
|
data: complaintsData.map(d => d.value)
|
|
}],
|
|
chart: {
|
|
type: 'area',
|
|
height: 300,
|
|
toolbar: { show: false }
|
|
},
|
|
colors: ['#ef4444'],
|
|
stroke: { curve: 'smooth', width: 2 },
|
|
fill: {
|
|
type: 'gradient',
|
|
gradient: {
|
|
shadeIntensity: 1,
|
|
opacityFrom: 0.4,
|
|
opacityTo: 0.1,
|
|
}
|
|
},
|
|
xaxis: {
|
|
categories: complaintsData.map(d => d.date),
|
|
labels: { rotate: -45 }
|
|
},
|
|
yaxis: {
|
|
labels: { formatter: function(val) { return Math.round(val); } }
|
|
},
|
|
tooltip: {
|
|
x: { format: 'dd MMM yyyy' }
|
|
}
|
|
};
|
|
const complaintsChart = new ApexCharts(document.querySelector("#complaintsChart"), complaintsOptions);
|
|
complaintsChart.render();
|
|
}
|
|
|
|
// Satisfaction Trend Chart
|
|
const satisfactionData = JSON.parse('{{ chart_data.surveys_satisfaction_trend|escapejs }}');
|
|
if (satisfactionData && satisfactionData.length > 0) {
|
|
const satisfactionOptions = {
|
|
series: [{
|
|
name: '{% trans "Satisfaction %" %}',
|
|
data: satisfactionData.map(d => d.value)
|
|
}],
|
|
chart: {
|
|
type: 'line',
|
|
height: 300,
|
|
toolbar: { show: false }
|
|
},
|
|
colors: ['#10b981'],
|
|
stroke: { curve: 'smooth', width: 2 },
|
|
xaxis: {
|
|
categories: satisfactionData.map(d => d.date),
|
|
labels: { rotate: -45 }
|
|
},
|
|
yaxis: {
|
|
min: 0,
|
|
max: 100,
|
|
labels: { formatter: function(val) { return val.toFixed(0) + '%'; } }
|
|
},
|
|
tooltip: {
|
|
x: { format: 'dd MMM yyyy' }
|
|
}
|
|
};
|
|
const satisfactionChart = new ApexCharts(document.querySelector("#satisfactionChart"), satisfactionOptions);
|
|
satisfactionChart.render();
|
|
}
|
|
|
|
// Initialize Lucide icons
|
|
lucide.createIcons();
|
|
});
|
|
</script>
|
|
{% endblock %}
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
The dashboard template references a variance badge partial template, so I need to create that next.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me create the variance badge partial template:
|
|
|
|
|
|
### Tool: Shell: mkdir -p /home/ismail/projects/HH/apps/executive_summary/templates/executive/partials (Create partials directory for executive templates)
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"command": "mkdir -p /home/ismail/projects/HH/apps/executive_summary/templates/executive/partials",
|
|
"description": "Create partials directory for executive templates"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Command: mkdir -p /home/ismail/projects/HH/apps/executive_summary/templates/executive/partials
|
|
Directory: (root)
|
|
Output: (empty)
|
|
Error: (none)
|
|
Exit Code: 0
|
|
Signal: 0
|
|
Process Group PGID: 293586
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to create the variance badge partial template that was referenced in the dashboard template.
|
|
|
|
|
|
### Tool: WriteFile: Writing to apps/executive_summary/templates/executive/partials/variance_badge.html
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"content": "{% if variance %}\n<span class=\"inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold\n {% if variance.direction == 'up' and variance.value > 0 %}\n {% if variance.positive_up %}bg-green-100 text-green-800{% else %}bg-red-100 text-red-800{% endif %}\n {% elif variance.direction == 'down' and variance.value < 0 %}\n {% if variance.positive_down %}bg-green-100 text-green-800{% else %}bg-red-100 text-red-800{% endif %}\n {% else %}\n bg-gray-100 text-gray-800\n {% endif %}\">\n {% if variance.direction == 'up' %}\n ↑\n {% elif variance.direction == 'down' %}\n ↓\n {% else %}\n →\n {% endif %}\n {{ variance.value|floatformat:1 }}%\n</span>\n{% endif %}\n",
|
|
"file_path": "/home/ismail/projects/HH/apps/executive_summary/templates/executive/partials/variance_badge.html"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/executive_summary/templates/executive/partials/variance_badge.html`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `variance_badge.html`:*
|
|
```diff
|
|
{% if variance %}
|
|
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold
|
|
{% if variance.direction == 'up' and variance.value > 0 %}
|
|
{% if variance.positive_up %}bg-green-100 text-green-800{% else %}bg-red-100 text-red-800{% endif %}
|
|
{% elif variance.direction == 'down' and variance.value < 0 %}
|
|
{% if variance.positive_down %}bg-green-100 text-green-800{% else %}bg-red-100 text-red-800{% endif %}
|
|
{% else %}
|
|
bg-gray-100 text-gray-800
|
|
{% endif %}">
|
|
{% if variance.direction == 'up' %}
|
|
↑
|
|
{% elif variance.direction == 'down' %}
|
|
↓
|
|
{% else %}
|
|
→
|
|
{% endif %}
|
|
{{ variance.value|floatformat:1 }}%
|
|
</span>
|
|
{% endif %}
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to create the predictive insights, Q&A templates, admin configuration, and register the URLs in the main config.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me create the predictive insights and Q&A templates, admin, tasks, and register the URLs. I'll do these in parallel:
|
|
|
|
|
|
### Tool: WriteFile: Writing to apps/executive_summary/templates/executive/insights.html
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"content": "{% extends \"layouts/base.html\" %}\n{% load i18n %}\n{% load static %}\n\n{% block title %}{% trans \"Predictive Insights\" %} | PX360{% endblock %}\n\n{% block content %}\n<div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8\">\n <!-- Page Header -->\n <div class=\"flex flex-col md:flex-row md:items-center md:justify-between mb-8\">\n <div>\n <h1 class=\"text-3xl font-bold text-gray-900\">{% trans \"Predictive Insights\" %}</h1>\n <p class=\"mt-2 text-sm text-gray-600\">{% trans \"AI-detected patterns, anomalies, and early warnings\" %}</p>\n </div>\n \n <!-- Summary Stats -->\n <div class=\"mt-4 md:mt-0 flex items-center gap-4\">\n <div class=\"text-center\">\n <p class=\"text-2xl font-bold text-gray-900\">{{ total_insights }}</p>\n <p class=\"text-xs text-gray-600\">{% trans \"Total\" %}</p>\n </div>\n <div class=\"text-center\">\n <p class=\"text-2xl font-bold text-red-600\">{{ critical_insights }}</p>\n <p class=\"text-xs text-gray-600\">{% trans \"Critical\" %}</p>\n </div>\n <div class=\"text-center\">\n <p class=\"text-2xl font-bold text-orange-600\">{{ high_insights }}</p>\n <p class=\"text-xs text-gray-600\">{% trans \"High\" %}</p>\n </div>\n <div class=\"text-center\">\n <p class=\"text-2xl font-bold text-blue-600\">{{ new_insights }}</p>\n <p class=\"text-xs text-gray-600\">{% trans \"New\" %}</p>\n </div>\n </div>\n </div>\n\n <!-- Filters -->\n <div class=\"bg-white rounded-lg p-4 mb-6 shadow-sm border border-gray-200\">\n <form method=\"get\" class=\"grid grid-cols-1 md:grid-cols-4 gap-4\">\n <div>\n <label class=\"block text-sm font-medium text-gray-700 mb-1\">{% trans \"Severity\" %}</label>\n <select name=\"severity\" onchange=\"this.form.submit()\" class=\"form-select text-sm\">\n <option value=\"\">{% trans \"All Severities\" %}</option>\n {% for value, label in severity_choices %}\n <option value=\"{{ value }}\" {% if current_filters.severity == value %}selected{% endif %}>\n {{ label }}\n </option>\n {% endfor %}\n </select>\n </div>\n \n <div>\n <label class=\"block text-sm font-medium text-gray-700 mb-1\">{% trans \"Status\" %}</label>\n <select name=\"status\" onchange=\"this.form.submit()\" class=\"form-select text-sm\">\n <option value=\"\">{% trans \"All Statuses\" %}</option>\n {% for value, label in status_choices %}\n <option value=\"{{ value }}\" {% if current_filters.status == value %}selected{% endif %}>\n {{ label }}\n </option>\n {% endfor %}\n </select>\n </div>\n \n <div>\n <label class=\"block text-sm font-medium text-gray-700 mb-1\">{% trans \"Type\" %}</label>\n <select name=\"insight_type\" onchange=\"this.form.submit()\" class=\"form-select text-sm\">\n <option value=\"\">{% trans \"All Types\" %}</option>\n {% for value, label in insight_type_choices %}\n <option value=\"{{ value }}\" {% if current_filters.insight_type == value %}selected{% endif %}>\n {{ label }}\n </option>\n {% endfor %}\n </select>\n </div>\n \n {% if available_hospitals %}\n <div>\n <label class=\"block text-sm font-medium text-gray-700 mb-1\">{% trans \"Hospital\" %}</label>\n <select name=\"hospital\" onchange=\"this.form.submit()\" class=\"form-select text-sm\">\n <option value=\"\">{% trans \"All Hospitals\" %}</option>\n {% for hospital in available_hospitals %}\n <option value=\"{{ hospital.id }}\" {% if current_filters.hospital == hospital.id|stringformat:\"s\" %}selected{% endif %}>\n {{ hospital.name }}\n </option>\n {% endfor %}\n </select>\n </div>\n {% endif %}\n </form>\n </div>\n\n <!-- Insights List -->\n <div class=\"space-y-4\">\n {% for insight in insights %}\n <div class=\"bg-white rounded-lg p-6 shadow-sm border border-gray-200 border-l-4 \n {% if insight.severity == 'critical' %}border-l-red-500\n {% elif insight.severity == 'high' %}border-l-orange-500\n {% elif insight.severity == 'medium' %}border-l-yellow-500\n {% else %}border-l-blue-500{% endif %}\">\n \n <div class=\"flex items-start justify-between gap-4\">\n <div class=\"flex-1\">\n <div class=\"flex items-center gap-3 mb-3\">\n <span class=\"px-3 py-1 text-xs font-semibold rounded-full\n {% if insight.severity == 'critical' %}bg-red-100 text-red-800\n {% elif insight.severity == 'high' %}bg-orange-100 text-orange-800\n {% elif insight.severity == 'medium' %}bg-yellow-100 text-yellow-800\n {% else %}bg-blue-100 text-blue-800{% endif %}\">\n {{ insight.get_severity_display }}\n </span>\n <span class=\"text-xs text-gray-500\">{{ insight.get_insight_type_display }}</span>\n <span class=\"text-xs text-gray-500\">•</span>\n <span class=\"text-xs text-gray-500\">{{ insight.created_at|date:\"M d, Y H:i\" }}</span>\n \n {% if insight.status == 'acknowledged' %}\n <span class=\"px-2 py-0.5 text-xs bg-green-100 text-green-800 rounded-full\">\n {% trans \"Acknowledged\" %}\n </span>\n {% endif %}\n </div>\n \n <h3 class=\"text-lg font-semibold text-gray-900 mb-2\">{{ insight.title_en }}</h3>\n <p class=\"text-sm text-gray-600 mb-3\">{{ insight.description_en }}</p>\n \n {% if insight.recommendation_en %}\n <div class=\"bg-blue-50 rounded-lg p-3 mb-3\">\n <p class=\"text-sm font-medium text-blue-900 mb-1 flex items-center gap-2\">\n <i data-lucide=\"lightbulb\" class=\"w-4 h-4\"></i>\n {% trans \"AI Recommendation\" %}\n </p>\n <p class=\"text-sm text-blue-800\">{{ insight.recommendation_en }}</p>\n </div>\n {% endif %}\n \n <div class=\"flex items-center gap-4 text-sm text-gray-500\">\n {% if insight.hospital %}\n <span class=\"flex items-center gap-1\">\n <i data-lucide=\"building\" class=\"w-4 h-4\"></i>\n {{ insight.hospital.name }}\n </span>\n {% endif %}\n {% if insight.department %}\n <span class=\"flex items-center gap-1\">\n <i data-lucide=\"layers\" class=\"w-4 h-4\"></i>\n {{ insight.department.name }}\n </span>\n {% endif %}\n {% if insight.confidence_score %}\n <span>{% trans \"Confidence\" %}: {{ insight.confidence_score|floatformat:0 }}%</span>\n {% endif %}\n {% if insight.predicted_date %}\n <span>{% trans \"Predicted\" %}: {{ insight.predicted_date|date:\"M d, Y\" }}</span>\n {% endif %}\n </div>\n </div>\n \n {% if insight.status == 'new' %}\n <form method=\"post\" action=\"{% url 'executive:acknowledge_insight' insight.id %}\" class=\"flex-shrink-0\">\n {% csrf_token %}\n <button type=\"submit\" \n class=\"px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition\">\n {% trans \"Acknowledge\" %}\n </button>\n </form>\n {% endif %}\n </div>\n </div>\n {% empty %}\n <div class=\"bg-white rounded-lg p-12 text-center shadow-sm border border-gray-200\">\n <i data-lucide=\"check-circle\" class=\"w-16 h-16 mx-auto text-green-500 mb-4\"></i>\n <h3 class=\"text-xl font-semibold text-gray-900 mb-2\">{% trans \"No Insights Available\" %}</h3>\n <p class=\"text-gray-600\">{% trans \"No predictive insights match your current filters\" %}</p>\n </div>\n {% endfor %}\n </div>\n\n <!-- Pagination -->\n {% if page_obj.has_other_pages %}\n <div class=\"mt-8 flex items-center justify-center gap-2\">\n {% if page_obj.has_previous %}\n <a href=\"?page=1{% if current_filters.severity %}&severity={{ current_filters.severity }}{% endif %}{% if current_filters.status %}&status={{ current_filters.status }}{% endif %}{% if current_filters.insight_type %}&insight_type={{ current_filters.insight_type }}{% endif %}{% if current_filters.hospital %}&hospital={{ current_filters.hospital }}{% endif %}\" \n class=\"px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50\">\n {% trans \"First\" %}\n </a>\n <a href=\"?page={{ page_obj.previous_page_number }}{% if current_filters.severity %}&severity={{ current_filters.severity }}{% endif %}{% if current_filters.status %}&status={{ current_filters.status }}{% endif %}{% if current_filters.insight_type %}&insight_type={{ current_filters.insight_type }}{% endif %}{% if current_filters.hospital %}&hospital={{ current_filters.hospital }}{% endif %}\" \n class=\"px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50\">\n {% trans \"Previous\" %}\n </a>\n {% endif %}\n \n <span class=\"px-4 py-2 text-sm text-gray-700\">\n {% trans \"Page\" %} {{ page_obj.number }} {% trans \"of\" %} {{ page_obj.paginator.num_pages }}\n </span>\n \n {% if page_obj.has_next %}\n <a href=\"?page={{ page_obj.next_page_number }}{% if current_filters.severity %}&severity={{ current_filters.severity }}{% endif %}{% if current_filters.status %}&status={{ current_filters.status }}{% endif %}{% if current_filters.insight_type %}&insight_type={{ current_filters.insight_type }}{% endif %}{% if current_filters.hospital %}&hospital={{ current_filters.hospital }}{% endif %}\" \n class=\"px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50\">\n {% trans \"Next\" %}\n </a>\n <a href=\"?page={{ page_obj.paginator.num_pages }}{% if current_filters.severity %}&severity={{ current_filters.severity }}{% endif %}{% if current_filters.status %}&status={{ current_filters.status }}{% endif %}{% if current_filters.insight_type %}&insight_type={{ current_filters.insight_type }}{% endif %}{% if current_filters.hospital %}&hospital={{ current_filters.hospital }}{% endif %}\" \n class=\"px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50\">\n {% trans \"Last\" %}\n </a>\n {% endif %}\n </div>\n {% endif %}\n</div>\n{% endblock %}\n\n{% block extra_js %}\n<script>\ndocument.addEventListener('DOMContentLoaded', function() {\n lucide.createIcons();\n});\n</script>\n{% endblock %}\n",
|
|
"file_path": "/home/ismail/projects/HH/apps/executive_summary/templates/executive/insights.html"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/executive_summary/templates/executive/insights.html`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `insights.html`:*
|
|
```diff
|
|
{% extends "layouts/base.html" %}
|
|
{% load i18n %}
|
|
{% load static %}
|
|
|
|
{% block title %}{% trans "Predictive Insights" %} | PX360{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<!-- Page Header -->
|
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-gray-900">{% trans "Predictive Insights" %}</h1>
|
|
<p class="mt-2 text-sm text-gray-600">{% trans "AI-detected patterns, anomalies, and early warnings" %}</p>
|
|
</div>
|
|
|
|
<!-- Summary Stats -->
|
|
<div class="mt-4 md:mt-0 flex items-center gap-4">
|
|
<div class="text-center">
|
|
<p class="text-2xl font-bold text-gray-900">{{ total_insights }}</p>
|
|
<p class="text-xs text-gray-600">{% trans "Total" %}</p>
|
|
</div>
|
|
<div class="text-center">
|
|
<p class="text-2xl font-bold text-red-600">{{ critical_insights }}</p>
|
|
<p class="text-xs text-gray-600">{% trans "Critical" %}</p>
|
|
</div>
|
|
<div class="text-center">
|
|
<p class="text-2xl font-bold text-orange-600">{{ high_insights }}</p>
|
|
<p class="text-xs text-gray-600">{% trans "High" %}</p>
|
|
</div>
|
|
<div class="text-center">
|
|
<p class="text-2xl font-bold text-blue-600">{{ new_insights }}</p>
|
|
<p class="text-xs text-gray-600">{% trans "New" %}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="bg-white rounded-lg p-4 mb-6 shadow-sm border border-gray-200">
|
|
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">{% trans "Severity" %}</label>
|
|
<select name="severity" onchange="this.form.submit()" class="form-select text-sm">
|
|
<option value="">{% trans "All Severities" %}</option>
|
|
{% for value, label in severity_choices %}
|
|
<option value="{{ value }}" {% if current_filters.severity == value %}selected{% endif %}>
|
|
{{ label }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">{% trans "Status" %}</label>
|
|
<select name="status" onchange="this.form.submit()" class="form-select text-sm">
|
|
<option value="">{% trans "All Statuses" %}</option>
|
|
{% for value, label in status_choices %}
|
|
<option value="{{ value }}" {% if current_filters.status == value %}selected{% endif %}>
|
|
{{ label }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">{% trans "Type" %}</label>
|
|
<select name="insight_type" onchange="this.form.submit()" class="form-select text-sm">
|
|
<option value="">{% trans "All Types" %}</option>
|
|
{% for value, label in insight_type_choices %}
|
|
<option value="{{ value }}" {% if current_filters.insight_type == value %}selected{% endif %}>
|
|
{{ label }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
{% if available_hospitals %}
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">{% trans "Hospital" %}</label>
|
|
<select name="hospital" onchange="this.form.submit()" class="form-select text-sm">
|
|
<option value="">{% trans "All Hospitals" %}</option>
|
|
{% for hospital in available_hospitals %}
|
|
<option value="{{ hospital.id }}" {% if current_filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
|
|
{{ hospital.name }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
{% endif %}
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Insights List -->
|
|
<div class="space-y-4">
|
|
{% for insight in insights %}
|
|
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-200 border-l-4
|
|
{% if insight.severity == 'critical' %}border-l-red-500
|
|
{% elif insight.severity == 'high' %}border-l-orange-500
|
|
{% elif insight.severity == 'medium' %}border-l-yellow-500
|
|
{% else %}border-l-blue-500{% endif %}">
|
|
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-3 mb-3">
|
|
<span class="px-3 py-1 text-xs font-semibold rounded-full
|
|
{% if insight.severity == 'critical' %}bg-red-100 text-red-800
|
|
{% elif insight.severity == 'high' %}bg-orange-100 text-orange-800
|
|
{% elif insight.severity == 'medium' %}bg-yellow-100 text-yellow-800
|
|
{% else %}bg-blue-100 text-blue-800{% endif %}">
|
|
{{ insight.get_severity_display }}
|
|
</span>
|
|
<span class="text-xs text-gray-500">{{ insight.get_insight_type_display }}</span>
|
|
<span class="text-xs text-gray-500">•</span>
|
|
<span class="text-xs text-gray-500">{{ insight.created_at|date:"M d, Y H:i" }}</span>
|
|
|
|
{% if insight.status == 'acknowledged' %}
|
|
<span class="px-2 py-0.5 text-xs bg-green-100 text-green-800 rounded-full">
|
|
{% trans "Acknowledged" %}
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ insight.title_en }}</h3>
|
|
<p class="text-sm text-gray-600 mb-3">{{ insight.description_en }}</p>
|
|
|
|
{% if insight.recommendation_en %}
|
|
<div class="bg-blue-50 rounded-lg p-3 mb-3">
|
|
<p class="text-sm font-medium text-blue-900 mb-1 flex items-center gap-2">
|
|
<i data-lucide="lightbulb" class="w-4 h-4"></i>
|
|
{% trans "AI Recommendation" %}
|
|
</p>
|
|
<p class="text-sm text-blue-800">{{ insight.recommendation_en }}</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="flex items-center gap-4 text-sm text-gray-500">
|
|
{% if insight.hospital %}
|
|
<span class="flex items-center gap-1">
|
|
<i data-lucide="building" class="w-4 h-4"></i>
|
|
{{ insight.hospital.name }}
|
|
</span>
|
|
{% endif %}
|
|
{% if insight.department %}
|
|
<span class="flex items-center gap-1">
|
|
<i data-lucide="layers" class="w-4 h-4"></i>
|
|
{{ insight.department.name }}
|
|
</span>
|
|
{% endif %}
|
|
{% if insight.confidence_score %}
|
|
<span>{% trans "Confidence" %}: {{ insight.confidence_score|floatformat:0 }}%</span>
|
|
{% endif %}
|
|
{% if insight.predicted_date %}
|
|
<span>{% trans "Predicted" %}: {{ insight.predicted_date|date:"M d, Y" }}</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{% if insight.status == 'new' %}
|
|
<form method="post" action="{% url 'executive:acknowledge_insight' insight.id %}" class="flex-shrink-0">
|
|
{% csrf_token %}
|
|
<button type="submit"
|
|
class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition">
|
|
{% trans "Acknowledge" %}
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% empty %}
|
|
<div class="bg-white rounded-lg p-12 text-center shadow-sm border border-gray-200">
|
|
<i data-lucide="check-circle" class="w-16 h-16 mx-auto text-green-500 mb-4"></i>
|
|
<h3 class="text-xl font-semibold text-gray-900 mb-2">{% trans "No Insights Available" %}</h3>
|
|
<p class="text-gray-600">{% trans "No predictive insights match your current filters" %}</p>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
{% if page_obj.has_other_pages %}
|
|
<div class="mt-8 flex items-center justify-center gap-2">
|
|
{% if page_obj.has_previous %}
|
|
<a href="?page=1{% if current_filters.severity %}&severity={{ current_filters.severity }}{% endif %}{% if current_filters.status %}&status={{ current_filters.status }}{% endif %}{% if current_filters.insight_type %}&insight_type={{ current_filters.insight_type }}{% endif %}{% if current_filters.hospital %}&hospital={{ current_filters.hospital }}{% endif %}"
|
|
class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
|
|
{% trans "First" %}
|
|
</a>
|
|
<a href="?page={{ page_obj.previous_page_number }}{% if current_filters.severity %}&severity={{ current_filters.severity }}{% endif %}{% if current_filters.status %}&status={{ current_filters.status }}{% endif %}{% if current_filters.insight_type %}&insight_type={{ current_filters.insight_type }}{% endif %}{% if current_filters.hospital %}&hospital={{ current_filters.hospital }}{% endif %}"
|
|
class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
|
|
{% trans "Previous" %}
|
|
</a>
|
|
{% endif %}
|
|
|
|
<span class="px-4 py-2 text-sm text-gray-700">
|
|
{% trans "Page" %} {{ page_obj.number }} {% trans "of" %} {{ page_obj.paginator.num_pages }}
|
|
</span>
|
|
|
|
{% if page_obj.has_next %}
|
|
<a href="?page={{ page_obj.next_page_number }}{% if current_filters.severity %}&severity={{ current_filters.severity }}{% endif %}{% if current_filters.status %}&status={{ current_filters.status }}{% endif %}{% if current_filters.insight_type %}&insight_type={{ current_filters.insight_type }}{% endif %}{% if current_filters.hospital %}&hospital={{ current_filters.hospital }}{% endif %}"
|
|
class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
|
|
{% trans "Next" %}
|
|
</a>
|
|
<a href="?page={{ page_obj.paginator.num_pages }}{% if current_filters.severity %}&severity={{ current_filters.severity }}{% endif %}{% if current_filters.status %}&status={{ current_filters.status }}{% endif %}{% if current_filters.insight_type %}&insight_type={{ current_filters.insight_type }}{% endif %}{% if current_filters.hospital %}&hospital={{ current_filters.hospital }}{% endif %}"
|
|
class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
|
|
{% trans "Last" %}
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
lucide.createIcons();
|
|
});
|
|
</script>
|
|
{% endblock %}
|
|
|
|
```
|
|
|
|
|
|
### Tool: WriteFile: Writing to apps/executive_summary/templates/executive/qa.html
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"content": "{% extends \"layouts/base.html\" %}\n{% load i18n %}\n{% load static %}\n\n{% block title %}{% trans \"Ask AI\" %} | PX360{% endblock %}\n\n{% block content %}\n<div class=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8\">\n <!-- Page Header -->\n <div class=\"text-center mb-8\">\n <h1 class=\"text-3xl font-bold text-gray-900 mb-2\">{% trans \"Ask AI About Your Data\" %}</h1>\n <p class=\"text-gray-600\">{% trans \"Get instant answers from your PX360 data using AI\" %}</p>\n </div>\n\n <!-- Suggested Questions -->\n <div class=\"mb-6\">\n <p class=\"text-sm font-medium text-gray-700 mb-3\">{% trans \"Suggested Questions\" %}:</p>\n <div class=\"flex flex-wrap gap-2\">\n {% for question in suggested_questions %}\n <button onclick=\"document.getElementById('questionInput').value='{{ question|escapejs }}'; document.getElementById('questionForm').submit();\" \n class=\"px-4 py-2 bg-indigo-50 text-indigo-700 text-sm rounded-full hover:bg-indigo-100 transition\">\n {{ question }}\n </button>\n {% endfor %}\n </div>\n </div>\n\n <!-- Question Input -->\n <form id=\"questionForm\" method=\"post\" action=\"{% url 'executive:executive_qa_api' %}\" \n class=\"bg-white rounded-xl p-6 shadow-lg border border-gray-200 mb-8\">\n {% csrf_token %}\n <div class=\"flex gap-3\">\n <input type=\"text\" id=\"questionInput\" name=\"question\" \n placeholder=\"{% trans 'Type your question...' %}\" \n class=\"flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500\"\n required>\n <button type=\"submit\" \n id=\"submitBtn\"\n class=\"px-6 py-3 bg-indigo-600 text-white font-semibold rounded-lg hover:bg-indigo-700 transition flex items-center gap-2\">\n <i data-lucide=\"send\" class=\"w-5 h-5\"></i>\n {% trans \"Ask\" %}\n </button>\n </div>\n </form>\n\n <!-- Loading Indicator -->\n <div id=\"loadingIndicator\" class=\"hidden text-center py-8\">\n <div class=\"inline-flex items-center gap-3\">\n <div class=\"animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600\"></div>\n <span class=\"text-gray-600\">{% trans \"AI is thinking...\" %}</span>\n </div>\n </div>\n\n <!-- Answer Display -->\n <div id=\"answerContainer\" class=\"hidden\">\n <div class=\"bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl p-6 text-white shadow-lg\">\n <div class=\"flex items-start gap-3 mb-4\">\n <i data-lucide=\"sparkles\" class=\"w-6 h-6 flex-shrink-0\"></i>\n <div class=\"flex-1\">\n <h3 class=\"text-lg font-bold\">{% trans \"AI Answer\" %}</h3>\n <p id=\"questionText\" class=\"text-sm text-white/80 mt-1\"></p>\n </div>\n </div>\n <div class=\"bg-white/10 backdrop-blur rounded-lg p-4\">\n <p id=\"answerText\" class=\"text-base leading-relaxed\"></p>\n </div>\n <div id=\"supportingData\" class=\"mt-4 flex flex-wrap gap-2\"></div>\n <div id=\"confidenceBadge\" class=\"mt-3 inline-flex items-center gap-2 px-3 py-1 bg-white/20 rounded-full text-sm\"></div>\n </div>\n </div>\n\n <!-- Recent Questions History -->\n {% if recent_questions %}\n <div class=\"mt-8\">\n <h2 class=\"text-xl font-bold text-gray-900 mb-4\">{% trans \"Recent Questions\" %}</h2>\n <div class=\"space-y-4\">\n {% for q in recent_questions|slice:\":5\" %}\n <div class=\"bg-white rounded-lg p-6 shadow-sm border border-gray-200\">\n <p class=\"font-semibold text-gray-900 mb-2 flex items-center gap-2\">\n <i data-lucide=\"help-circle\" class=\"w-4 h-4 text-indigo-600\"></i>\n {{ q.question }}\n </p>\n <p class=\"text-sm text-gray-600 ml-6\">{{ q.answer }}</p>\n <p class=\"text-xs text-gray-400 mt-2 ml-6\">{{ q.timestamp }}</p>\n </div>\n {% endfor %}\n </div>\n </div>\n {% endif %}\n</div>\n{% endblock %}\n\n{% block extra_js %}\n<script>\ndocument.getElementById('questionForm').addEventListener('submit', async function(e) {\n e.preventDefault();\n \n const question = document.getElementById('questionInput').value;\n const submitBtn = document.getElementById('submitBtn');\n const loadingIndicator = document.getElementById('loadingIndicator');\n const answerContainer = document.getElementById('answerContainer');\n \n // Show loading\n submitBtn.disabled = true;\n submitBtn.innerHTML = '<div class=\"animate-spin rounded-full h-5 w-5 border-b-2 border-white\"></div>';\n loadingIndicator.classList.remove('hidden');\n answerContainer.classList.add('hidden');\n \n try {\n const formData = new FormData();\n formData.append('question', question);\n formData.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value);\n \n const response = await fetch('{% url \"executive:executive_qa_api\" %}', {\n method: 'POST',\n headers: {'X-Requested-With': 'XMLHttpRequest'},\n body: formData\n });\n \n const data = await response.json();\n \n if (data.status === 'success') {\n // Display answer\n document.getElementById('questionText').textContent = question;\n document.getElementById('answerText').textContent = data.answer;\n \n // Supporting data\n const supportingDataEl = document.getElementById('supportingData');\n supportingDataEl.innerHTML = '';\n if (data.supporting_data && data.supporting_data.length > 0) {\n data.supporting_data.forEach(item => {\n const badge = document.createElement('span');\n badge.className = 'px-2 py-1 bg-white/20 rounded text-xs';\n badge.textContent = item;\n supportingDataEl.appendChild(badge);\n });\n }\n \n // Confidence badge\n const confidenceBadge = document.getElementById('confidenceBadge');\n const confidence = data.confidence || 'medium';\n confidenceBadge.innerHTML = `<i data-lucide=\"shield\" class=\"w-4 h-4\"></i> Confidence: ${confidence}`;\n \n answerContainer.classList.remove('hidden');\n lucide.createIcons();\n } else {\n alert(data.error || '{% trans \"An error occurred\" %}');\n }\n } catch (error) {\n console.error('Error:', error);\n alert('{% trans \"Failed to get answer. Please try again.\" %}');\n } finally {\n submitBtn.disabled = false;\n submitBtn.innerHTML = '<i data-lucide=\"send\" class=\"w-5 h-5\"></i> {% trans \"Ask\" %}';\n loadingIndicator.classList.add('hidden');\n lucide.createIcons();\n }\n});\n</script>\n{% endblock %}\n",
|
|
"file_path": "/home/ismail/projects/HH/apps/executive_summary/templates/executive/qa.html"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/executive_summary/templates/executive/qa.html`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `qa.html`:*
|
|
```diff
|
|
{% extends "layouts/base.html" %}
|
|
{% load i18n %}
|
|
{% load static %}
|
|
|
|
{% block title %}{% trans "Ask AI" %} | PX360{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<!-- Page Header -->
|
|
<div class="text-center mb-8">
|
|
<h1 class="text-3xl font-bold text-gray-900 mb-2">{% trans "Ask AI About Your Data" %}</h1>
|
|
<p class="text-gray-600">{% trans "Get instant answers from your PX360 data using AI" %}</p>
|
|
</div>
|
|
|
|
<!-- Suggested Questions -->
|
|
<div class="mb-6">
|
|
<p class="text-sm font-medium text-gray-700 mb-3">{% trans "Suggested Questions" %}:</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
{% for question in suggested_questions %}
|
|
<button onclick="document.getElementById('questionInput').value='{{ question|escapejs }}'; document.getElementById('questionForm').submit();"
|
|
class="px-4 py-2 bg-indigo-50 text-indigo-700 text-sm rounded-full hover:bg-indigo-100 transition">
|
|
{{ question }}
|
|
</button>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Question Input -->
|
|
<form id="questionForm" method="post" action="{% url 'executive:executive_qa_api' %}"
|
|
class="bg-white rounded-xl p-6 shadow-lg border border-gray-200 mb-8">
|
|
{% csrf_token %}
|
|
<div class="flex gap-3">
|
|
<input type="text" id="questionInput" name="question"
|
|
placeholder="{% trans 'Type your question...' %}"
|
|
class="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
required>
|
|
<button type="submit"
|
|
id="submitBtn"
|
|
class="px-6 py-3 bg-indigo-600 text-white font-semibold rounded-lg hover:bg-indigo-700 transition flex items-center gap-2">
|
|
<i data-lucide="send" class="w-5 h-5"></i>
|
|
{% trans "Ask" %}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Loading Indicator -->
|
|
<div id="loadingIndicator" class="hidden text-center py-8">
|
|
<div class="inline-flex items-center gap-3">
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
|
<span class="text-gray-600">{% trans "AI is thinking..." %}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Answer Display -->
|
|
<div id="answerContainer" class="hidden">
|
|
<div class="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl p-6 text-white shadow-lg">
|
|
<div class="flex items-start gap-3 mb-4">
|
|
<i data-lucide="sparkles" class="w-6 h-6 flex-shrink-0"></i>
|
|
<div class="flex-1">
|
|
<h3 class="text-lg font-bold">{% trans "AI Answer" %}</h3>
|
|
<p id="questionText" class="text-sm text-white/80 mt-1"></p>
|
|
</div>
|
|
</div>
|
|
<div class="bg-white/10 backdrop-blur rounded-lg p-4">
|
|
<p id="answerText" class="text-base leading-relaxed"></p>
|
|
</div>
|
|
<div id="supportingData" class="mt-4 flex flex-wrap gap-2"></div>
|
|
<div id="confidenceBadge" class="mt-3 inline-flex items-center gap-2 px-3 py-1 bg-white/20 rounded-full text-sm"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Questions History -->
|
|
{% if recent_questions %}
|
|
<div class="mt-8">
|
|
<h2 class="text-xl font-bold text-gray-900 mb-4">{% trans "Recent Questions" %}</h2>
|
|
<div class="space-y-4">
|
|
{% for q in recent_questions|slice:":5" %}
|
|
<div class="bg-white rounded-lg p-6 shadow-sm border border-gray-200">
|
|
<p class="font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
|
<i data-lucide="help-circle" class="w-4 h-4 text-indigo-600"></i>
|
|
{{ q.question }}
|
|
</p>
|
|
<p class="text-sm text-gray-600 ml-6">{{ q.answer }}</p>
|
|
<p class="text-xs text-gray-400 mt-2 ml-6">{{ q.timestamp }}</p>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
document.getElementById('questionForm').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
const question = document.getElementById('questionInput').value;
|
|
const submitBtn = document.getElementById('submitBtn');
|
|
const loadingIndicator = document.getElementById('loadingIndicator');
|
|
const answerContainer = document.getElementById('answerContainer');
|
|
|
|
// Show loading
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>';
|
|
loadingIndicator.classList.remove('hidden');
|
|
answerContainer.classList.add('hidden');
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('question', question);
|
|
formData.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value);
|
|
|
|
const response = await fetch('{% url "executive:executive_qa_api" %}', {
|
|
method: 'POST',
|
|
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
|
body: formData
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
// Display answer
|
|
document.getElementById('questionText').textContent = question;
|
|
document.getElementById('answerText').textContent = data.answer;
|
|
|
|
// Supporting data
|
|
const supportingDataEl = document.getElementById('supportingData');
|
|
supportingDataEl.innerHTML = '';
|
|
if (data.supporting_data && data.supporting_data.length > 0) {
|
|
data.supporting_data.forEach(item => {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'px-2 py-1 bg-white/20 rounded text-xs';
|
|
badge.textContent = item;
|
|
supportingDataEl.appendChild(badge);
|
|
});
|
|
}
|
|
|
|
// Confidence badge
|
|
const confidenceBadge = document.getElementById('confidenceBadge');
|
|
const confidence = data.confidence || 'medium';
|
|
confidenceBadge.innerHTML = `<i data-lucide="shield" class="w-4 h-4"></i> Confidence: ${confidence}`;
|
|
|
|
answerContainer.classList.remove('hidden');
|
|
lucide.createIcons();
|
|
} else {
|
|
alert(data.error || '{% trans "An error occurred" %}');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
alert('{% trans "Failed to get answer. Please try again." %}');
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = '<i data-lucide="send" class="w-5 h-5"></i> {% trans "Ask" %}';
|
|
loadingIndicator.classList.add('hidden');
|
|
lucide.createIcons();
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to create the admin.py to enable Django admin management of the executive summary models and register the executive app URLs in the main config.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me create the admin.py, tasks.py, and register the URLs in the main config:
|
|
|
|
|
|
### Tool: WriteFile: Writing to apps/executive_summary/admin.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"content": "\"\"\"\nExecutive Summary Admin - Django admin interface for executive summary models\n\"\"\"\nfrom django.contrib import admin\nfrom django.utils.html import format_html\nfrom django.urls import reverse\nfrom django.utils import timezone\n\nfrom .models import ExecutiveMetric, ExecutiveReport, PredictiveInsight, AIRecommendation\n\n\n@admin.register(ExecutiveMetric)\nclass ExecutiveMetricAdmin(admin.ModelAdmin):\n list_display = [\n 'metric_type', 'metric_value', 'variance_display', \n 'metric_date', 'hospital', 'created_at'\n ]\n list_filter = ['metric_type', 'metric_date', 'hospital']\n date_hierarchy = 'metric_date'\n search_fields = ['metric_type', 'hospital__name']\n ordering = ['-metric_date']\n \n def variance_display(self, obj):\n if obj.variance is None:\n return \"-\"\n arrow = \"↑\" if obj.variance_direction == \"up\" else \"↓\" if obj.variance_direction == \"down\" else \"→\"\n color = \"#10b981\" if obj.variance_direction == \"up\" else \"#ef4444\" if obj.variance_direction == \"down\" else \"#6b7280\"\n return format_html(\n '<span style=\"color: {}; font-weight: bold;\">{} {}%</span>',\n color, arrow, obj.variance\n )\n variance_display.short_description = \"Variance\"\n\n\n@admin.register(ExecutiveReport)\nclass ExecutiveReportAdmin(admin.ModelAdmin):\n list_display = [\n 'report_type', 'status', 'start_date', 'end_date', \n 'generation_time_display', 'created_at', 'pdf_link'\n ]\n list_filter = ['report_type', 'status', 'created_at']\n date_hierarchy = 'created_at'\n readonly_fields = ['created_at', 'updated_at']\n ordering = ['-created_at']\n \n def generation_time_display(self, obj):\n if obj.generation_time_ms:\n return f\"{obj.generation_time_ms / 1000:.2f}s\"\n return \"-\"\n generation_time_display.short_description = \"Gen Time\"\n \n def pdf_link(self, obj):\n if obj.pdf_file:\n return format_html('<a href=\"{}\" target=\"_blank\">Download PDF</a>', obj.pdf_file.url)\n return \"-\"\n pdf_link.short_description = \"PDF\"\n\n\n@admin.register(PredictiveInsight)\nclass PredictiveInsightAdmin(admin.ModelAdmin):\n list_display = [\n 'title_en', 'severity_badge', 'insight_type', \n 'status', 'hospital', 'created_at', 'acknowledged_by_display'\n ]\n list_filter = ['severity', 'status', 'insight_type', 'hospital', 'created_at']\n search_fields = ['title_en', 'description_en', 'hospital__name']\n date_hierarchy = 'created_at'\n ordering = ['-severity', '-created_at']\n readonly_fields = ['created_at', 'updated_at', 'acknowledged_at']\n \n def severity_badge(self, obj):\n colors = {\n 'low': '#3b82f6',\n 'medium': '#f59e0b',\n 'high': '#f97316',\n 'critical': '#ef4444',\n }\n color = colors.get(obj.severity, '#6b7280')\n return format_html(\n '<span style=\"background-color: {}; color: white; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: bold;\">{}</span>',\n color, obj.get_severity_display()\n )\n severity_badge.short_description = \"Severity\"\n \n def acknowledged_by_display(self, obj):\n if obj.acknowledged_by:\n return obj.acknowledged_by.get_full_name() or obj.acknowledged_by.email\n return \"-\"\n acknowledged_by_display.short_description = \"Acknowledged By\"\n\n\n@admin.register(AIRecommendation)\nclass AIRecommendationAdmin(admin.ModelAdmin):\n list_display = [\n 'title_en', 'category', 'priority_badge', \n 'status', 'hospital', 'created_at'\n ]\n list_filter = ['category', 'priority', 'status', 'hospital', 'created_at']\n search_fields = ['title_en', 'description_en', 'hospital__name']\n date_hierarchy = 'created_at'\n ordering = ['-priority', '-created_at']\n readonly_fields = ['created_at', 'updated_at']\n \n def priority_badge(self, obj):\n colors = {\n 'low': '#3b82f6',\n 'medium': '#f59e0b',\n 'high': '#f97316',\n 'urgent': '#ef4444',\n }\n color = colors.get(obj.priority, '#6b7280')\n return format_html(\n '<span style=\"background-color: {}; color: white; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: bold;\">{}</span>',\n color, obj.get_priority_display()\n )\n priority_badge.short_description = \"Priority\"\n",
|
|
"file_path": "/home/ismail/projects/HH/apps/executive_summary/admin.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/executive_summary/admin.py`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `admin.py`:*
|
|
```diff
|
|
"""
|
|
Executive Summary Admin - Django admin interface for executive summary models
|
|
"""
|
|
from django.contrib import admin
|
|
from django.utils.html import format_html
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
from .models import ExecutiveMetric, ExecutiveReport, PredictiveInsight, AIRecommendation
|
|
|
|
|
|
@admin.register(ExecutiveMetric)
|
|
class ExecutiveMetricAdmin(admin.ModelAdmin):
|
|
list_display = [
|
|
'metric_type', 'metric_value', 'variance_display',
|
|
'metric_date', 'hospital', 'created_at'
|
|
]
|
|
list_filter = ['metric_type', 'metric_date', 'hospital']
|
|
date_hierarchy = 'metric_date'
|
|
search_fields = ['metric_type', 'hospital__name']
|
|
ordering = ['-metric_date']
|
|
|
|
def variance_display(self, obj):
|
|
if obj.variance is None:
|
|
return "-"
|
|
arrow = "↑" if obj.variance_direction == "up" else "↓" if obj.variance_direction == "down" else "→"
|
|
color = "#10b981" if obj.variance_direction == "up" else "#ef4444" if obj.variance_direction == "down" else "#6b7280"
|
|
return format_html(
|
|
'<span style="color: {}; font-weight: bold;">{} {}%</span>',
|
|
color, arrow, obj.variance
|
|
)
|
|
variance_display.short_description = "Variance"
|
|
|
|
|
|
@admin.register(ExecutiveReport)
|
|
class ExecutiveReportAdmin(admin.ModelAdmin):
|
|
list_display = [
|
|
'report_type', 'status', 'start_date', 'end_date',
|
|
'generation_time_display', 'created_at', 'pdf_link'
|
|
]
|
|
list_filter = ['report_type', 'status', 'created_at']
|
|
date_hierarchy = 'created_at'
|
|
readonly_fields = ['created_at', 'updated_at']
|
|
ordering = ['-created_at']
|
|
|
|
def generation_time_display(self, obj):
|
|
if obj.generation_time_ms:
|
|
return f"{obj.generation_time_ms / 1000:.2f}s"
|
|
return "-"
|
|
generation_time_display.short_description = "Gen Time"
|
|
|
|
def pdf_link(self, obj):
|
|
if obj.pdf_file:
|
|
return format_html('<a href="{}" target="_blank">Download PDF</a>', obj.pdf_file.url)
|
|
return "-"
|
|
pdf_link.short_description = "PDF"
|
|
|
|
|
|
@admin.register(PredictiveInsight)
|
|
class PredictiveInsightAdmin(admin.ModelAdmin):
|
|
list_display = [
|
|
'title_en', 'severity_badge', 'insight_type',
|
|
'status', 'hospital', 'created_at', 'acknowledged_by_display'
|
|
]
|
|
list_filter = ['severity', 'status', 'insight_type', 'hospital', 'created_at']
|
|
search_fields = ['title_en', 'description_en', 'hospital__name']
|
|
date_hierarchy = 'created_at'
|
|
ordering = ['-severity', '-created_at']
|
|
readonly_fields = ['created_at', 'updated_at', 'acknowledged_at']
|
|
|
|
def severity_badge(self, obj):
|
|
colors = {
|
|
'low': '#3b82f6',
|
|
'medium': '#f59e0b',
|
|
'high': '#f97316',
|
|
'critical': '#ef4444',
|
|
}
|
|
color = colors.get(obj.severity, '#6b7280')
|
|
return format_html(
|
|
'<span style="background-color: {}; color: white; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: bold;">{}</span>',
|
|
color, obj.get_severity_display()
|
|
)
|
|
severity_badge.short_description = "Severity"
|
|
|
|
def acknowledged_by_display(self, obj):
|
|
if obj.acknowledged_by:
|
|
return obj.acknowledged_by.get_full_name() or obj.acknowledged_by.email
|
|
return "-"
|
|
acknowledged_by_display.short_description = "Acknowledged By"
|
|
|
|
|
|
@admin.register(AIRecommendation)
|
|
class AIRecommendationAdmin(admin.ModelAdmin):
|
|
list_display = [
|
|
'title_en', 'category', 'priority_badge',
|
|
'status', 'hospital', 'created_at'
|
|
]
|
|
list_filter = ['category', 'priority', 'status', 'hospital', 'created_at']
|
|
search_fields = ['title_en', 'description_en', 'hospital__name']
|
|
date_hierarchy = 'created_at'
|
|
ordering = ['-priority', '-created_at']
|
|
readonly_fields = ['created_at', 'updated_at']
|
|
|
|
def priority_badge(self, obj):
|
|
colors = {
|
|
'low': '#3b82f6',
|
|
'medium': '#f59e0b',
|
|
'high': '#f97316',
|
|
'urgent': '#ef4444',
|
|
}
|
|
color = colors.get(obj.priority, '#6b7280')
|
|
return format_html(
|
|
'<span style="background-color: {}; color: white; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: bold;">{}</span>',
|
|
color, obj.get_priority_display()
|
|
)
|
|
priority_badge.short_description = "Priority"
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me create the Celery tasks file and register the URLs in the main config.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me create the Celery tasks file and register the URLs in the main config:
|
|
|
|
|
|
### Tool: WriteFile: Writing to apps/executive_summary/tasks.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"content": "\"\"\"\nExecutive Summary Celery Tasks\n\nScheduled tasks for:\n- Generating weekly/monthly executive reports\n- Calculating daily metrics\n- Generating predictive insights\n- Creating AI recommendations\n- Sending PDF reports via email\n\"\"\"\nfrom celery import shared_task\nfrom django.utils import timezone\nfrom datetime import timedelta, datetime\nimport logging\nimport time\n\nlogger = logging.getLogger(__name__)\n\n\n@shared_task\ndef generate_weekly_executive_summary():\n \"\"\"\n Generate weekly executive summary report.\n \n Runs every Monday at 6 AM.\n Creates an ExecutiveReport with AI-generated narrative for the past week.\n \"\"\"\n from .models import ExecutiveReport\n from .services import AINarrativeService\n \n try:\n logger.info(\"Starting weekly executive summary generation\")\n \n end_date = timezone.now().date()\n start_date = end_date - timedelta(days=7)\n \n # Check if report already exists\n existing = ExecutiveReport.objects.filter(\n report_type=\"weekly\",\n start_date=start_date,\n end_date=end_date,\n ).first()\n \n if existing:\n logger.info(f\"Weekly report already exists for {start_date} to {end_date}\")\n return {\"status\": \"skipped\", \"report_id\": str(existing.id)}\n \n # Generate narratives using AI\n narrative_service = AINarrativeService()\n \n # Generate English narrative\n logger.info(\"Generating English narrative...\")\n en_result = narrative_service.generate_weekly_narrative(\n start_date=start_date,\n end_date=end_date,\n )\n \n # Generate Arabic narrative\n logger.info(\"Generating Arabic narrative...\")\n ar_result = narrative_service.generate_arabic_narrative(\n start_date=start_date,\n end_date=end_date,\n )\n \n # Create report\n report = ExecutiveReport.objects.create(\n report_type=\"weekly\",\n status=en_result.get(\"status\", \"completed\"),\n start_date=start_date,\n end_date=end_date,\n narrative_en=en_result.get(\"narrative_en\", \"\"),\n narrative_ar=ar_result.get(\"narrative_ar\", \"\"),\n highlights_en=en_result.get(\"highlights_en\", []),\n highlights_ar=ar_result.get(\"highlights_ar\", []),\n concerns_en=en_result.get(\"concerns_en\", []),\n concerns_ar=ar_result.get(\"concerns_ar\", []),\n ai_model=en_result.get(\"ai_model\", \"\"),\n generation_time_ms=en_result.get(\"generation_time_ms\"),\n error_message=en_result.get(\"error\", \"\") or ar_result.get(\"error\", \"\"),\n )\n \n logger.info(f\"Weekly executive summary generated: {report.id}\")\n return {\n \"status\": \"success\",\n \"report_id\": str(report.id),\n \"report_type\": \"weekly\",\n }\n \n except Exception as e:\n logger.error(f\"Failed to generate weekly executive summary: {str(e)}\", exc_info=True)\n return {\"status\": \"error\", \"error\": str(e)}\n\n\n@shared_task\ndef generate_monthly_executive_summary():\n \"\"\"\n Generate monthly executive summary report.\n \n Runs on 1st of each month at 7 AM.\n Creates an ExecutiveReport with AI-generated narrative for the past month.\n \"\"\"\n from .models import ExecutiveReport\n from .services import AINarrativeService\n \n try:\n logger.info(\"Starting monthly executive summary generation\")\n \n end_date = timezone.now().date()\n start_date = end_date - timedelta(days=30)\n \n # Check if report already exists\n existing = ExecutiveReport.objects.filter(\n report_type=\"monthly\",\n start_date=start_date,\n end_date=end_date,\n ).first()\n \n if existing:\n logger.info(f\"Monthly report already exists for {start_date} to {end_date}\")\n return {\"status\": \"skipped\", \"report_id\": str(existing.id)}\n \n # Generate narratives using AI\n narrative_service = AINarrativeService()\n \n # Generate English narrative\n logger.info(\"Generating English narrative...\")\n en_result = narrative_service.generate_weekly_narrative(\n start_date=start_date,\n end_date=end_date,\n )\n \n # Generate Arabic narrative\n logger.info(\"Generating Arabic narrative...\")\n ar_result = narrative_service.generate_arabic_narrative(\n start_date=start_date,\n end_date=end_date,\n )\n \n # Create report\n report = ExecutiveReport.objects.create(\n report_type=\"monthly\",\n status=en_result.get(\"status\", \"completed\"),\n start_date=start_date,\n end_date=end_date,\n narrative_en=en_result.get(\"narrative_en\", \"\"),\n narrative_ar=ar_result.get(\"narrative_ar\", \"\"),\n highlights_en=en_result.get(\"highlights_en\", []),\n highlights_ar=ar_result.get(\"highlights_ar\", []),\n concerns_en=en_result.get(\"concerns_en\", []),\n concerns_ar=ar_result.get(\"concerns_ar\", []),\n ai_model=en_result.get(\"ai_model\", \"\"),\n generation_time_ms=en_result.get(\"generation_time_ms\"),\n error_message=en_result.get(\"error\", \"\") or ar_result.get(\"error\", \"\"),\n )\n \n logger.info(f\"Monthly executive summary generated: {report.id}\")\n return {\n \"status\": \"success\",\n \"report_id\": str(report.id),\n \"report_type\": \"monthly\",\n }\n \n except Exception as e:\n logger.error(f\"Failed to generate monthly executive summary: {str(e)}\", exc_info=True)\n return {\"status\": \"error\", \"error\": str(e)}\n\n\n@shared_task\ndef calculate_daily_metrics():\n \"\"\"\n Calculate and store daily metrics for all hospitals.\n \n Runs daily at 1 AM.\n Aggregates metrics from complaints, surveys, actions, observations, etc.\n \"\"\"\n from .models import ExecutiveMetric\n from .services import ExecutiveSummaryService\n from apps.organizations.models import Hospital\n \n try:\n logger.info(\"Starting daily metrics calculation\")\n \n target_date = (timezone.now() - timedelta(days=1)).date()\n \n # Calculate system-wide metrics\n logger.info(f\"Calculating system-wide metrics for {target_date}\")\n summary_service = ExecutiveSummaryService()\n metrics = summary_service.aggregate_daily_metrics(target_date, hospital=None)\n \n # Calculate per-hospital metrics\n hospitals = Hospital.objects.filter(status=\"active\")\n for hospital in hospitals:\n logger.info(f\"Calculating metrics for {hospital.name}\")\n metrics = summary_service.aggregate_daily_metrics(target_date, hospital=hospital)\n \n logger.info(f\"Daily metrics calculated for {target_date}\")\n return {\n \"status\": \"success\",\n \"date\": target_date.isoformat(),\n \"hospitals_processed\": hospitals.count(),\n }\n \n except Exception as e:\n logger.error(f\"Failed to calculate daily metrics: {str(e)}\", exc_info=True)\n return {\"status\": \"error\", \"error\": str(e)}\n\n\n@shared_task\ndef generate_predictive_insights():\n \"\"\"\n Generate predictive insights from data analysis.\n \n Runs every 6 hours.\n Analyzes trends, anomalies, SLA breach risks, and creates PredictiveInsight objects.\n \"\"\"\n from .services import PredictiveAnalyticsService\n \n try:\n logger.info(\"Starting predictive insights generation\")\n \n predictive_service = PredictiveAnalyticsService()\n result = predictive_service.generate_predictive_insights()\n \n logger.info(f\"Predictive insights generated: {result.get('insights_created', 0)} insights created\")\n return {\n \"status\": \"success\",\n \"insights_created\": result.get(\"insights_created\", 0),\n \"insights_updated\": result.get(\"insights_updated\", 0),\n }\n \n except Exception as e:\n logger.error(f\"Failed to generate predictive insights: {str(e)}\", exc_info=True)\n return {\"status\": \"error\", \"error\": str(e)}\n\n\n@shared_task\ndef generate_ai_recommendations():\n \"\"\"\n Generate AI recommendations from predictive insights.\n \n Runs daily at 3 AM.\n Creates AIRecommendation objects based on unhandled PredictiveInsights.\n \"\"\"\n from .services import RecommendationService\n \n try:\n logger.info(\"Starting AI recommendations generation\")\n \n recommendation_service = RecommendationService()\n result = recommendation_service.generate_recommendations_from_insights()\n \n logger.info(f\"AI recommendations generated: {result.get('recommendations_created', 0)}\")\n return {\n \"status\": \"success\",\n \"recommendations_created\": result.get(\"recommendations_created\", 0),\n }\n \n except Exception as e:\n logger.error(f\"Failed to generate AI recommendations: {str(e)}\", exc_info=True)\n return {\"status\": \"error\", \"error\": str(e)}\n\n\n@shared_task\ndef send_executive_pdf_report():\n \"\"\"\n Send PDF reports to executives via email.\n \n Runs weekly and monthly after report generation.\n Emails the latest executive PDF report to all executive users.\n \"\"\"\n from django.core.mail import EmailMessage\n from django.conf import settings\n from .models import ExecutiveReport\n from apps.accounts.models import User\n \n try:\n logger.info(\"Starting executive PDF report email delivery\")\n \n # Get latest completed report\n latest_report = ExecutiveReport.objects.filter(\n status=\"completed\",\n pdf_file__isnull=False\n ).order_by(\"-created_at\").first()\n \n if not latest_report:\n logger.info(\"No completed PDF report found to send\")\n return {\"status\": \"skipped\", \"reason\": \"no_pdf_report\"}\n \n # Get executive users\n executives = User.objects.filter(\n groups__name=\"Executive\",\n is_active=True,\n ).distinct()\n \n if not executives.exists():\n logger.info(\"No executive users found to send report to\")\n return {\"status\": \"skipped\", \"reason\": \"no_executives\"}\n \n # Prepare email\n subject = f\"Executive Summary Report - {latest_report.get_report_type_display()}\"\n body = f\"\"\"Dear Executive,\n\nPlease find attached the {latest_report.get_report_type_display().lower()} summary report for the period {latest_report.start_date} to {latest_report.end_date}.\n\nKey Highlights:\n{chr(10).join(f\"• {highlight}\" for highlight in latest_report.highlights_en[:5])}\n\nKey Concerns:\n{chr(10).join(f\"• {concern}\" for concern in latest_report.concerns_en[:5])}\n\nThis report was generated automatically by the PX360 AI system.\n\nBest regards,\nPX360 Team\n\"\"\"\n \n # Send emails\n emails_sent = 0\n for executive in executives:\n if executive.email:\n try:\n email = EmailMessage(\n subject=subject,\n body=body,\n to=[executive.email],\n )\n email.attach_file(latest_report.pdf_file.path)\n email.send()\n emails_sent += 1\n logger.info(f\"Report sent to {executive.email}\")\n except Exception as e:\n logger.error(f\"Failed to send report to {executive.email}: {str(e)}\")\n \n logger.info(f\"PDF reports sent to {emails_sent} executives\")\n return {\n \"status\": \"success\",\n \"emails_sent\": emails_sent,\n \"report_id\": str(latest_report.id),\n }\n \n except Exception as e:\n logger.error(f\"Failed to send executive PDF reports: {str(e)}\", exc_info=True)\n return {\"status\": \"error\", \"error\": str(e)}\n",
|
|
"file_path": "/home/ismail/projects/HH/apps/executive_summary/tasks.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/executive_summary/tasks.py`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `tasks.py`:*
|
|
```diff
|
|
"""
|
|
Executive Summary Celery Tasks
|
|
|
|
Scheduled tasks for:
|
|
- Generating weekly/monthly executive reports
|
|
- Calculating daily metrics
|
|
- Generating predictive insights
|
|
- Creating AI recommendations
|
|
- Sending PDF reports via email
|
|
"""
|
|
from celery import shared_task
|
|
from django.utils import timezone
|
|
from datetime import timedelta, datetime
|
|
import logging
|
|
import time
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@shared_task
|
|
def generate_weekly_executive_summary():
|
|
"""
|
|
Generate weekly executive summary report.
|
|
|
|
Runs every Monday at 6 AM.
|
|
Creates an ExecutiveReport with AI-generated narrative for the past week.
|
|
"""
|
|
from .models import ExecutiveReport
|
|
from .services import AINarrativeService
|
|
|
|
try:
|
|
logger.info("Starting weekly executive summary generation")
|
|
|
|
end_date = timezone.now().date()
|
|
start_date = end_date - timedelta(days=7)
|
|
|
|
# Check if report already exists
|
|
existing = ExecutiveReport.objects.filter(
|
|
report_type="weekly",
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
).first()
|
|
|
|
if existing:
|
|
logger.info(f"Weekly report already exists for {start_date} to {end_date}")
|
|
return {"status": "skipped", "report_id": str(existing.id)}
|
|
|
|
# Generate narratives using AI
|
|
narrative_service = AINarrativeService()
|
|
|
|
# Generate English narrative
|
|
logger.info("Generating English narrative...")
|
|
en_result = narrative_service.generate_weekly_narrative(
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
)
|
|
|
|
# Generate Arabic narrative
|
|
logger.info("Generating Arabic narrative...")
|
|
ar_result = narrative_service.generate_arabic_narrative(
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
)
|
|
|
|
# Create report
|
|
report = ExecutiveReport.objects.create(
|
|
report_type="weekly",
|
|
status=en_result.get("status", "completed"),
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
narrative_en=en_result.get("narrative_en", ""),
|
|
narrative_ar=ar_result.get("narrative_ar", ""),
|
|
highlights_en=en_result.get("highlights_en", []),
|
|
highlights_ar=ar_result.get("highlights_ar", []),
|
|
concerns_en=en_result.get("concerns_en", []),
|
|
concerns_ar=ar_result.get("concerns_ar", []),
|
|
ai_model=en_result.get("ai_model", ""),
|
|
generation_time_ms=en_result.get("generation_time_ms"),
|
|
error_message=en_result.get("error", "") or ar_result.get("error", ""),
|
|
)
|
|
|
|
logger.info(f"Weekly executive summary generated: {report.id}")
|
|
return {
|
|
"status": "success",
|
|
"report_id": str(report.id),
|
|
"report_type": "weekly",
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate weekly executive summary: {str(e)}", exc_info=True)
|
|
return {"status": "error", "error": str(e)}
|
|
|
|
|
|
@shared_task
|
|
def generate_monthly_executive_summary():
|
|
"""
|
|
Generate monthly executive summary report.
|
|
|
|
Runs on 1st of each month at 7 AM.
|
|
Creates an ExecutiveReport with AI-generated narrative for the past month.
|
|
"""
|
|
from .models import ExecutiveReport
|
|
from .services import AINarrativeService
|
|
|
|
try:
|
|
logger.info("Starting monthly executive summary generation")
|
|
|
|
end_date = timezone.now().date()
|
|
start_date = end_date - timedelta(days=30)
|
|
|
|
# Check if report already exists
|
|
existing = ExecutiveReport.objects.filter(
|
|
report_type="monthly",
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
).first()
|
|
|
|
if existing:
|
|
logger.info(f"Monthly report already exists for {start_date} to {end_date}")
|
|
return {"status": "skipped", "report_id": str(existing.id)}
|
|
|
|
# Generate narratives using AI
|
|
narrative_service = AINarrativeService()
|
|
|
|
# Generate English narrative
|
|
logger.info("Generating English narrative...")
|
|
en_result = narrative_service.generate_weekly_narrative(
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
)
|
|
|
|
# Generate Arabic narrative
|
|
logger.info("Generating Arabic narrative...")
|
|
ar_result = narrative_service.generate_arabic_narrative(
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
)
|
|
|
|
# Create report
|
|
report = ExecutiveReport.objects.create(
|
|
report_type="monthly",
|
|
status=en_result.get("status", "completed"),
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
narrative_en=en_result.get("narrative_en", ""),
|
|
narrative_ar=ar_result.get("narrative_ar", ""),
|
|
highlights_en=en_result.get("highlights_en", []),
|
|
highlights_ar=ar_result.get("highlights_ar", []),
|
|
concerns_en=en_result.get("concerns_en", []),
|
|
concerns_ar=ar_result.get("concerns_ar", []),
|
|
ai_model=en_result.get("ai_model", ""),
|
|
generation_time_ms=en_result.get("generation_time_ms"),
|
|
error_message=en_result.get("error", "") or ar_result.get("error", ""),
|
|
)
|
|
|
|
logger.info(f"Monthly executive summary generated: {report.id}")
|
|
return {
|
|
"status": "success",
|
|
"report_id": str(report.id),
|
|
"report_type": "monthly",
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate monthly executive summary: {str(e)}", exc_info=True)
|
|
return {"status": "error", "error": str(e)}
|
|
|
|
|
|
@shared_task
|
|
def calculate_daily_metrics():
|
|
"""
|
|
Calculate and store daily metrics for all hospitals.
|
|
|
|
Runs daily at 1 AM.
|
|
Aggregates metrics from complaints, surveys, actions, observations, etc.
|
|
"""
|
|
from .models import ExecutiveMetric
|
|
from .services import ExecutiveSummaryService
|
|
from apps.organizations.models import Hospital
|
|
|
|
try:
|
|
logger.info("Starting daily metrics calculation")
|
|
|
|
target_date = (timezone.now() - timedelta(days=1)).date()
|
|
|
|
# Calculate system-wide metrics
|
|
logger.info(f"Calculating system-wide metrics for {target_date}")
|
|
summary_service = ExecutiveSummaryService()
|
|
metrics = summary_service.aggregate_daily_metrics(target_date, hospital=None)
|
|
|
|
# Calculate per-hospital metrics
|
|
hospitals = Hospital.objects.filter(status="active")
|
|
for hospital in hospitals:
|
|
logger.info(f"Calculating metrics for {hospital.name}")
|
|
metrics = summary_service.aggregate_daily_metrics(target_date, hospital=hospital)
|
|
|
|
logger.info(f"Daily metrics calculated for {target_date}")
|
|
return {
|
|
"status": "success",
|
|
"date": target_date.isoformat(),
|
|
"hospitals_processed": hospitals.count(),
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to calculate daily metrics: {str(e)}", exc_info=True)
|
|
return {"status": "error", "error": str(e)}
|
|
|
|
|
|
@shared_task
|
|
def generate_predictive_insights():
|
|
"""
|
|
Generate predictive insights from data analysis.
|
|
|
|
Runs every 6 hours.
|
|
Analyzes trends, anomalies, SLA breach risks, and creates PredictiveInsight objects.
|
|
"""
|
|
from .services import PredictiveAnalyticsService
|
|
|
|
try:
|
|
logger.info("Starting predictive insights generation")
|
|
|
|
predictive_service = PredictiveAnalyticsService()
|
|
result = predictive_service.generate_predictive_insights()
|
|
|
|
logger.info(f"Predictive insights generated: {result.get('insights_created', 0)} insights created")
|
|
return {
|
|
"status": "success",
|
|
"insights_created": result.get("insights_created", 0),
|
|
"insights_updated": result.get("insights_updated", 0),
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate predictive insights: {str(e)}", exc_info=True)
|
|
return {"status": "error", "error": str(e)}
|
|
|
|
|
|
@shared_task
|
|
def generate_ai_recommendations():
|
|
"""
|
|
Generate AI recommendations from predictive insights.
|
|
|
|
Runs daily at 3 AM.
|
|
Creates AIRecommendation objects based on unhandled PredictiveInsights.
|
|
"""
|
|
from .services import RecommendationService
|
|
|
|
try:
|
|
logger.info("Starting AI recommendations generation")
|
|
|
|
recommendation_service = RecommendationService()
|
|
result = recommendation_service.generate_recommendations_from_insights()
|
|
|
|
logger.info(f"AI recommendations generated: {result.get('recommendations_created', 0)}")
|
|
return {
|
|
"status": "success",
|
|
"recommendations_created": result.get("recommendations_created", 0),
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate AI recommendations: {str(e)}", exc_info=True)
|
|
return {"status": "error", "error": str(e)}
|
|
|
|
|
|
@shared_task
|
|
def send_executive_pdf_report():
|
|
"""
|
|
Send PDF reports to executives via email.
|
|
|
|
Runs weekly and monthly after report generation.
|
|
Emails the latest executive PDF report to all executive users.
|
|
"""
|
|
from django.core.mail import EmailMessage
|
|
from django.conf import settings
|
|
from .models import ExecutiveReport
|
|
from apps.accounts.models import User
|
|
|
|
try:
|
|
logger.info("Starting executive PDF report email delivery")
|
|
|
|
# Get latest completed report
|
|
latest_report = ExecutiveReport.objects.filter(
|
|
status="completed",
|
|
pdf_file__isnull=False
|
|
).order_by("-created_at").first()
|
|
|
|
if not latest_report:
|
|
logger.info("No completed PDF report found to send")
|
|
return {"status": "skipped", "reason": "no_pdf_report"}
|
|
|
|
# Get executive users
|
|
executives = User.objects.filter(
|
|
groups__name="Executive",
|
|
is_active=True,
|
|
).distinct()
|
|
|
|
if not executives.exists():
|
|
logger.info("No executive users found to send report to")
|
|
return {"status": "skipped", "reason": "no_executives"}
|
|
|
|
# Prepare email
|
|
subject = f"Executive Summary Report - {latest_report.get_report_type_display()}"
|
|
body = f"""Dear Executive,
|
|
|
|
Please find attached the {latest_report.get_report_type_display().lower()} summary report for the period {latest_report.start_date} to {latest_report.end_date}.
|
|
|
|
Key Highlights:
|
|
{chr(10).join(f"• {highlight}" for highlight in latest_report.highlights_en[:5])}
|
|
|
|
Key Concerns:
|
|
{chr(10).join(f"• {concern}" for concern in latest_report.concerns_en[:5])}
|
|
|
|
This report was generated automatically by the PX360 AI system.
|
|
|
|
Best regards,
|
|
PX360 Team
|
|
"""
|
|
|
|
# Send emails
|
|
emails_sent = 0
|
|
for executive in executives:
|
|
if executive.email:
|
|
try:
|
|
email = EmailMessage(
|
|
subject=subject,
|
|
body=body,
|
|
to=[executive.email],
|
|
)
|
|
email.attach_file(latest_report.pdf_file.path)
|
|
email.send()
|
|
emails_sent += 1
|
|
logger.info(f"Report sent to {executive.email}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send report to {executive.email}: {str(e)}")
|
|
|
|
logger.info(f"PDF reports sent to {emails_sent} executives")
|
|
return {
|
|
"status": "success",
|
|
"emails_sent": emails_sent,
|
|
"report_id": str(latest_report.id),
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to send executive PDF reports: {str(e)}", exc_info=True)
|
|
return {"status": "error", "error": str(e)}
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me register the executive_summary URLs in the main config and add the Celery beat schedule.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me register the executive_summary URLs in the main config and add the Celery beat schedule:
|
|
|
|
|
|
### Tool: Edit: config/urls.py: from django.contrib import adm... => from django.contrib import adm...
|
|
**Status**: failed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/config/urls.py",
|
|
"new_string": "from django.contrib import admin\nfrom django.urls import path, include\nfrom django.conf import settings\nfrom django.conf.urls.static import static\nfrom drf_spectacular.views import (\n SpectacularAPIView,\n SpectacularRedocView,\n SpectacularSwaggerView,\n)\nfrom apps.dashboard.views import CommandCenterView\nfrom rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView\n\nurlpatterns = [\n path(\"admin/\", admin.site.urls),\n path(\"\", CommandCenterView.as_view(), name=\"home\"), # Root URL now shows Command Center\n path(\"dashboard/\", include(\"apps.dashboard.urls\")),\n path(\"accounts/\", include(\"apps.accounts.urls\")),\n path(\"surveys/\", include(\"apps.surveys.urls\")),\n path(\"complaints/\", include(\"apps.complaints.urls\")),\n path(\"physicians/\", include(\"apps.physicians.urls\")),\n path(\"journeys/\", include(\"apps.journeys.urls\")),\n path(\"call-center/\", include(\"apps.callcenter.urls\")),\n path(\"integrations/\", include(\"apps.integrations.urls\")),\n path(\"notifications/\", include(\"apps.notifications.urls\")),\n path(\"social/\", include(\"apps.social.urls\")),\n path(\"projects/\", include(\"apps.projects.urls\")),\n path(\"actions/\", include(\"apps.px_action_center.urls\")),\n path(\"executive/\", include(\"apps.executive_summary.urls\")), # Executive Summary URLs\n path(\"organizations/\", include(\"apps.organizations.urls\")),\n path(\"feedback/\", include(\"apps.feedback.urls\")),\n path(\"analytics/\", include(\"apps.analytics.urls\")),\n path(\"api/auth/token/\", TokenObtainPairView.as_view(), name=\"token_obtain_pair\"),\n path(\"api/auth/token/refresh/\", TokenRefreshView.as_view(), name=\"token_refresh\"),\n path(\"api/schema/\", SpectacularAPIView.as_view(), name=\"schema\"),\n path(\"api/docs/\", SpectacularSwaggerView.as_view(url_name=\"schema\"), name=\"swagger-ui\"),\n path(\"api/redoc/\", SpectacularRedocView.as_view(url_name=\"schema\"), name=\"redoc\"),\n path(\"ai-engine/\", include(\"apps.ai_engine.urls\")),\n path(\"appreciations/\", include(\"apps.appreciation.urls\")),\n path(\"observations/\", include(\"apps.observations.urls\")),\n path(\"px-sources/\", include(\"apps.px_sources.urls\")),\n path(\"references/\", include(\"apps.references.urls\")),\n path(\"standards/\", include(\"apps.standards.urls\")),\n path(\"simulator/\", include(\"apps.simulator.urls\")),\n path(\"reports/\", include(\"apps.reports.urls\")),\n path(\"rca/\", include(\"apps.rca.urls\")),\n path(\"health/\", include(\"apps.core.urls\")),\n]\n\nif settings.DEBUG:\n urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)\n urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)\n urlpatterns.append(path(\"__debug__/\", include(\"debug_toolbar.urls\")))",
|
|
"old_string": "from django.contrib import admin\nfrom django.urls import path, include\nfrom django.conf import settings\nfrom django.conf.urls.static import static\nfrom drf_spectacular.views import (\n SpectacularAPIView,\n SpectacularRedocView,\n SpectacularSwaggerView,\n)\nfrom apps.dashboard.views import CommandCenterView\nfrom rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView\n\nurlpatterns = [\n path(\"admin/\", admin.site.urls),\n path(\"\", CommandCenterView.as_view(), name=\"home\"), # Root URL now shows Command Center\n path(\"dashboard/\", include(\"apps.dashboard.urls\")),\n path(\"accounts/\", include(\"apps.accounts.urls\")),\n path(\"surveys/\", include(\"apps.surveys.urls\")),\n path(\"complaints/\", include(\"apps.complaints.urls\")),\n path(\"physicians/\", include(\"apps.physicians.urls\")),\n path(\"journeys/\", include(\"apps.journeys.urls\")),\n path(\"call-center/\", include(\"apps.callcenter.urls\")),\n path(\"integrations/\", include(\"apps.integrations.urls\")),\n path(\"notifications/\", include(\"apps.notifications.urls\")),\n path(\"social/\", include(\"apps.social.urls\")),\n path(\"projects/\", include(\"apps.projects.urls\")),\n path(\"actions/\", include(\"apps.px_action_center.urls\")),\n path(\"organizations/\", include(\"apps.organizations.urls\")),\n path(\"feedback/\", include(\"apps.feedback.urls\")),\n path(\"analytics/\", include(\"apps.analytics.urls\")),\n path(\"api/auth/token/\", TokenObtainPairView.as_view(), name=\"token_obtain_pair\"),\n path(\"api/auth/token/refresh/\", TokenRefreshView.as_view(), name=\"token_refresh\"),\n path(\"api/schema/\", SpectacularAPIView.as_view(), name=\"schema\"),\n path(\"api/docs/\", SpectacularSwaggerView.as_view(url_name=\"schema\"), name=\"swagger-ui\"),\n path(\"api/redoc/\", SpectacularRedocView.as_view(url_name=\"schema\"), name=\"redoc\"),\n path(\"ai-engine/\", include(\"apps.ai_engine.urls\")),\n path(\"appreciations/\", include(\"apps.appreciation.urls\")),\n path(\"observations/\", include(\"apps.observations.urls\")),\n path(\"px-sources/\", include(\"apps.px_sources.urls\")),\n path(\"references/\", include(\"apps.references.urls\")),\n path(\"standards/\", include(\"apps.standards.urls\")),\n path(\"simulator/\", include(\"apps.simulator.urls\")),\n path(\"reports/\", include(\"apps.reports.urls\")),\n path(\"rca/\", include(\"apps.rca.urls\")),\n path(\"health/\", include(\"apps.core.urls\")),\n]\n\nif settings.DEBUG:\n urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)\n urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)\n urlpatterns.append(path(\"__debug__/\", include(\"debug_toolbar.urls\")))"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/config/urls.py`
|
|
|
|
**Output:**
|
|
```python
|
|
Failed to edit, 0 occurrences found for old_string in /home/ismail/projects/HH/config/urls.py. No edits made. The exact text in old_string was not found. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use read_file tool to verify.
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
The edit failed because the old_string didn't match the actual file content, so I need to read the file first to see its exact current content.
|
|
|
|
|
|
### Tool: ReadFile: config/urls.py (first 50 lines)
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/config/urls.py",
|
|
"limit": 50
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/config/urls.py`
|
|
|
|
**Output:**
|
|
```python
|
|
Showing lines 1-50 of 72 total lines.
|
|
|
|
---
|
|
|
|
"""
|
|
URL configuration for PX360 project.
|
|
"""
|
|
|
|
from django.conf import settings
|
|
from django.conf.urls.static import static
|
|
from django.contrib import admin
|
|
from django.urls import include, path
|
|
from django.conf.urls.i18n import i18n_patterns
|
|
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
|
from apps.organizations.views import api_patient_search, api_staff_search
|
|
|
|
urlpatterns = [
|
|
# Language switching
|
|
path("i18n/", include("django.conf.urls.i18n")),
|
|
# Admin
|
|
path("admin/", admin.site.urls),
|
|
# Dashboard
|
|
path("", include("apps.dashboard.urls")),
|
|
# Health check endpoint
|
|
path("health/", include("apps.core.urls", namespace="health")),
|
|
# Core pages (public submissions, hospital selection)
|
|
path("core/", include("apps.core.urls", namespace="core")),
|
|
# UI Pages
|
|
path("complaints/", include("apps.complaints.urls")),
|
|
path("inquiries/", include("apps.complaints.urls_inquiries")),
|
|
path("physicians/", include("apps.physicians.urls", namespace="physicians")),
|
|
path("feedback/", include("apps.feedback.urls")),
|
|
path("actions/", include("apps.px_action_center.urls")),
|
|
path("accounts/", include("apps.accounts.urls", namespace="accounts")),
|
|
path("journeys/", include("apps.journeys.urls")),
|
|
path("surveys/", include("apps.surveys.urls")),
|
|
path("social/", include("apps.social.urls")),
|
|
path("callcenter/", include("apps.callcenter.urls")),
|
|
path("analytics/", include("apps.analytics.urls")),
|
|
path("organizations/", include("apps.organizations.urls")),
|
|
path("projects/", include("apps.projects.urls")),
|
|
path("config/", include("apps.core.config_urls")),
|
|
path("ai-engine/", include("apps.ai_engine.urls")),
|
|
path("appreciation/", include("apps.appreciation.urls", namespace="appreciation")),
|
|
path("notifications/", include("apps.notifications.urls", namespace="notifications")),
|
|
path("observations/", include("apps.observations.urls")),
|
|
path("px-sources/", include("apps.px_sources.urls")),
|
|
path("references/", include("apps.references.urls", namespace="references")),
|
|
path("standards/", include("apps.standards.urls")),
|
|
path("reports/", include("apps.reports.urls", namespace="reports")),
|
|
path("rca/", include("apps.rca.urls", namespace="rca")),
|
|
# API endpoints
|
|
path("api/auth/", include("apps.accounts.urls", namespace="api_auth")),
|
|
path("api/physicians/", include("apps.physicians.urls", namespace="api_physicians")),
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I can see the actual URL structure - I need to insert the executive_summary URLs in the right place among the other app URL includes.
|
|
|
|
|
|
### Tool: Edit: config/urls.py: path("actions/", include("... => path("actions/", include("...
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/config/urls.py",
|
|
"new_string": " path(\"actions/\", include(\"apps.px_action_center.urls\")),\n path(\"executive/\", include(\"apps.executive_summary.urls\")),\n path(\"accounts/\", include(\"apps.accounts.urls\", namespace=\"accounts\")),",
|
|
"old_string": " path(\"actions/\", include(\"apps.px_action_center.urls\")),\n path(\"accounts/\", include(\"apps.accounts.urls\", namespace=\"accounts\")),"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/config/urls.py`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `urls.py`:*
|
|
```diff
|
|
"""
|
|
URL configuration for PX360 project.
|
|
"""
|
|
|
|
from django.conf import settings
|
|
from django.conf.urls.static import static
|
|
from django.contrib import admin
|
|
from django.urls import include, path
|
|
from django.conf.urls.i18n import i18n_patterns
|
|
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
|
from apps.organizations.views import api_patient_search, api_staff_search
|
|
|
|
urlpatterns = [
|
|
# Language switching
|
|
path("i18n/", include("django.conf.urls.i18n")),
|
|
# Admin
|
|
path("admin/", admin.site.urls),
|
|
# Dashboard
|
|
path("", include("apps.dashboard.urls")),
|
|
# Health check endpoint
|
|
path("health/", include("apps.core.urls", namespace="health")),
|
|
# Core pages (public submissions, hospital selection)
|
|
path("core/", include("apps.core.urls", namespace="core")),
|
|
# UI Pages
|
|
path("complaints/", include("apps.complaints.urls")),
|
|
path("inquiries/", include("apps.complaints.urls_inquiries")),
|
|
path("physicians/", include("apps.physicians.urls", namespace="physicians")),
|
|
path("feedback/", include("apps.feedback.urls")),
|
|
path("actions/", include("apps.px_action_center.urls")),
|
|
path("executive/", include("apps.executive_summary.urls")),
|
|
path("accounts/", include("apps.accounts.urls", namespace="accounts")),
|
|
path("journeys/", include("apps.journeys.urls")),
|
|
path("surveys/", include("apps.surveys.urls")),
|
|
path("social/", include("apps.social.urls")),
|
|
path("callcenter/", include("apps.callcenter.urls")),
|
|
path("analytics/", include("apps.analytics.urls")),
|
|
path("organizations/", include("apps.organizations.urls")),
|
|
path("projects/", include("apps.projects.urls")),
|
|
path("config/", include("apps.core.config_urls")),
|
|
path("ai-engine/", include("apps.ai_engine.urls")),
|
|
path("appreciation/", include("apps.appreciation.urls", namespace="appreciation")),
|
|
path("notifications/", include("apps.notifications.urls", namespace="notifications")),
|
|
path("observations/", include("apps.observations.urls")),
|
|
path("px-sources/", include("apps.px_sources.urls")),
|
|
path("references/", include("apps.references.urls", namespace="references")),
|
|
path("standards/", include("apps.standards.urls")),
|
|
path("reports/", include("apps.reports.urls", namespace="reports")),
|
|
path("rca/", include("apps.rca.urls", namespace="rca")),
|
|
# API endpoints
|
|
path("api/auth/", include("apps.accounts.urls", namespace="api_auth")),
|
|
path("api/physicians/", include("apps.physicians.urls", namespace="api_physicians")),
|
|
path("api/integrations/", include("apps.integrations.urls")),
|
|
path("api/notifications/", include("apps.notifications.urls", namespace="api_notifications")),
|
|
path("api/v1/appreciation/", include("apps.appreciation.urls", namespace="api_appreciation")),
|
|
path("api/simulator/", include("apps.simulator.urls", namespace="api_simulator")),
|
|
path("api/patients/search/", api_patient_search, name="api_patient_search"),
|
|
path("api/staffs/search/", api_staff_search, name="api_staff_search"),
|
|
# OpenAPI/Swagger documentation
|
|
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
|
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
|
|
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
|
|
]
|
|
|
|
# Serve media files in development
|
|
if settings.DEBUG:
|
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
|
|
|
# Customize admin site
|
|
admin.site.site_header = "PX360 Administration"
|
|
admin.site.site_title = "PX360 Admin Portal"
|
|
admin.site.index_title = "Welcome to PX360 Administration"
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
After registering the URLs, I need to add the Celery beat schedule for the executive summary tasks, so I should first find where the celery beat schedule is configured.
|
|
|
|
|
|
## Assistant
|
|
|
|
Perfect! Now let me check the celery beat schedule configuration and add the executive summary tasks:
|
|
|
|
|
|
### Tool: Grep: 'CELERY_BEAT_SCHEDULE|celery.*schedule'
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"pattern": "CELERY_BEAT_SCHEDULE|celery.*schedule"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Found 25 matches for pattern "CELERY_BEAT_SCHEDULE|celery.*schedule" in the workspace directory:
|
|
---
|
|
/home/ismail/projects/HH/docker-compose.yml:75: command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
|
|
/home/ismail/projects/HH/PX360/settings.py:142:# Django Celery Beat Scheduler
|
|
/home/ismail/projects/HH/PX360/settings.py:143:CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
|
/home/ismail/projects/HH/README.md:97:- Celery beat scheduler
|
|
/home/ismail/projects/HH/config/celery_scheduler.py:2:Custom Celery Beat scheduler to fix Python 3.12 zoneinfo compatibility issue.
|
|
/home/ismail/projects/HH/config/celery_scheduler.py:57: This class is referenced in CELERY_BEAT_SCHEDULER setting and lazily imports
|
|
/home/ismail/projects/HH/config/celery_scheduler.py:69: from django_celery_beat.schedulers import DatabaseScheduler
|
|
/home/ismail/projects/HH/config/settings/base.py:259:# Celery Beat Schedule
|
|
/home/ismail/projects/HH/config/settings/base.py:260:CELERY_BEAT_SCHEDULER = "config.celery_scheduler:PatchedDatabaseScheduler"
|
|
/home/ismail/projects/HH/config/celery.py:8:from celery.schedules import crontab
|
|
/home/ismail/projects/HH/config/celery.py:15:from config.celery_scheduler import apply_tzcrontab_patch
|
|
/home/ismail/projects/HH/config/celery.py:30:# Celery Beat schedule for periodic tasks
|
|
/home/ismail/projects/HH/docs/SURVEY_TRACKING_IMPLEMENTATION.md:283:# Via Celery (scheduled)
|
|
/home/ismail/projects/HH/docs/SURVEY_TRACKING_IMPLEMENTATION.md:295:**Celery Beat Schedule:**
|
|
/home/ismail/projects/HH/docs/SETUP_GUIDE.md:247:# Start Celery beat (for scheduled tasks)
|
|
/home/ismail/projects/HH/docs/SLA_TESTING_PLAN.md:405:1. Configure Celery Beat to run tasks on schedule
|
|
/home/ismail/projects/HH/docs/SLA_TESTING_PLAN.md:432:- [ ] Celery Beat scheduler running
|
|
/home/ismail/projects/HH/docs/SLA_TESTING_PLAN.md:505:- Check: Celery Beat scheduler running
|
|
/home/ismail/projects/HH/docs/SLA_SYSTEM_SETUP_AND_TESTING_GUIDE.md:463:celery -A config inspect scheduled
|
|
/home/ismail/projects/HH/docs/SLA_SYSTEM_SETUP_AND_TESTING_GUIDE.md:519:from celery.schedules import crontab
|
|
/home/ismail/projects/HH/docs/SLA_SYSTEM_SETUP_AND_TESTING_GUIDE.md:539:# Start Celery Beat scheduler
|
|
/home/ismail/projects/HH/docs/SLA_SYSTEM_SETUP_AND_TESTING_GUIDE.md:540:celery -A config beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler
|
|
/home/ismail/projects/HH/docs/ARCHITECTURE.md:381:- celery-beat (Scheduler)
|
|
/home/ismail/projects/HH/docs/SLA_TESTING_GUIDE.md:227:### Celery Beat Schedule
|
|
/home/ismail/projects/HH/docs/SLA_TESTING_GUIDE.md:233:from celery.schedules import crontab
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Let me check the Celery configuration file to understand how scheduled tasks are defined so I can add the executive summary tasks.
|
|
|
|
|
|
### Tool: ReadFile: config/celery.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/config/celery.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/config/celery.py`
|
|
|
|
**Output:**
|
|
```python
|
|
"""
|
|
Celery configuration for PX360 project.
|
|
"""
|
|
|
|
import os
|
|
|
|
from celery import Celery
|
|
from celery.schedules import crontab
|
|
|
|
# Set the default Django settings module for the 'celery' program.
|
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev")
|
|
|
|
# Apply zoneinfo compatibility patch for django-celery-beat with Python 3.12+
|
|
# This must be done before Celery app is created
|
|
from config.celery_scheduler import apply_tzcrontab_patch
|
|
|
|
apply_tzcrontab_patch()
|
|
|
|
app = Celery("px360")
|
|
|
|
# Using a string here means the worker doesn't have to serialize
|
|
# the configuration object to child processes.
|
|
# - namespace='CELERY' means all celery-related configuration keys
|
|
# should have a `CELERY_` prefix.
|
|
app.config_from_object("django.conf:settings", namespace="CELERY")
|
|
|
|
# Load task modules from all registered Django apps.
|
|
app.autodiscover_tasks()
|
|
|
|
# Celery Beat schedule for periodic tasks
|
|
app.conf.beat_schedule = {
|
|
# Fetch patient data from HIS every 25 minutes
|
|
"fetch-his-surveys": {
|
|
"task": "apps.integrations.tasks.fetch_his_surveys",
|
|
"schedule": crontab(minute="*/25"),
|
|
"options": {
|
|
"expires": 1500,
|
|
},
|
|
},
|
|
# TEST TASK - Fetch from JSON file (uncomment for testing, remove when done)
|
|
# 'test-fetch-his-surveys-from-json': {
|
|
# 'task': 'apps.integrations.tasks.test_fetch_his_surveys_from_json',
|
|
# 'schedule': crontab(minute='*/5'), # Every 5 minutes
|
|
# },
|
|
# Send pending scheduled surveys every 10 minutes
|
|
# "send-pending-scheduled-surveys": {
|
|
# "task": "apps.surveys.tasks.send_pending_scheduled_surveys",
|
|
# "schedule": crontab(minute="*/10"), # Every 10 minutes
|
|
# },
|
|
# Check for overdue complaints every 15 minutes
|
|
"check-overdue-complaints": {
|
|
"task": "apps.complaints.tasks.check_overdue_complaints",
|
|
"schedule": crontab(minute="*/15"),
|
|
},
|
|
# Check for overdue actions every 15 minutes
|
|
"check-overdue-actions": {
|
|
"task": "apps.px_action_center.tasks.check_overdue_actions",
|
|
"schedule": crontab(minute="*/15"),
|
|
},
|
|
# Send SLA reminders every hour
|
|
"send-sla-reminders": {
|
|
"task": "apps.complaints.tasks.send_sla_reminders",
|
|
"schedule": crontab(minute=0), # Every hour at minute 0
|
|
},
|
|
# Check for overdue explanation requests every 15 minutes
|
|
"check-overdue-explanation-requests": {
|
|
"task": "apps.complaints.tasks.check_overdue_explanation_requests",
|
|
"schedule": crontab(minute="*/15"),
|
|
},
|
|
# Send explanation reminders every hour
|
|
"send-explanation-reminders": {
|
|
"task": "apps.complaints.tasks.send_explanation_reminders",
|
|
"schedule": crontab(minute=0), # Every hour at minute 0
|
|
},
|
|
# Check for overdue inquiries every 15 minutes
|
|
"check-overdue-inquiries": {
|
|
"task": "apps.complaints.tasks.check_overdue_inquiries",
|
|
"schedule": crontab(minute="*/15"),
|
|
},
|
|
# Send inquiry SLA reminders every hour
|
|
"send-inquiry-sla-reminders": {
|
|
"task": "apps.complaints.tasks.send_inquiry_sla_reminders",
|
|
"schedule": crontab(minute=0), # Every hour at minute 0
|
|
},
|
|
# Check for overdue observations every 15 minutes
|
|
"check-overdue-observations": {
|
|
"task": "apps.observations.tasks.check_overdue_observations",
|
|
"schedule": crontab(minute="*/15"),
|
|
},
|
|
# Send observation SLA reminders every hour
|
|
"send-observation-sla-reminders": {
|
|
"task": "apps.observations.tasks.send_observation_sla_reminders",
|
|
"schedule": crontab(minute=0), # Every hour at minute 0
|
|
},
|
|
# Send onboarding reminders every hour
|
|
"send-onboarding-reminders": {
|
|
"task": "apps.accounts.tasks.send_onboarding_reminders",
|
|
"schedule": crontab(minute=30), # Every hour at minute 30
|
|
},
|
|
# Clean up expired invitations daily at midnight
|
|
"cleanup-expired-invitations": {
|
|
"task": "apps.accounts.tasks.cleanup_expired_invitations",
|
|
"schedule": crontab(hour=0, minute=0), # Daily at midnight
|
|
},
|
|
# Calculate daily KPIs at 1 AM
|
|
"calculate-daily-kpis": {
|
|
"task": "apps.analytics.tasks.calculate_daily_kpis",
|
|
"schedule": crontab(hour=1, minute=0),
|
|
},
|
|
# Fetch doctor ratings from HIS on the 1st of each month at 1 AM (before aggregation)
|
|
"fetch-his-doctor-ratings-monthly": {
|
|
"task": "apps.physicians.tasks.fetch_his_doctor_ratings_monthly",
|
|
"schedule": crontab(hour=1, minute=0, day_of_month=1),
|
|
},
|
|
# Fetch doctor ratings from HIS daily at 1:30 AM (yesterday's ratings)
|
|
"fetch-his-doctor-ratings-daily": {
|
|
"task": "apps.physicians.tasks.fetch_his_doctor_ratings_daily",
|
|
"schedule": crontab(hour=1, minute=30),
|
|
},
|
|
# Calculate physician monthly ratings on the 1st of each month at 2 AM
|
|
"calculate-physician-ratings": {
|
|
"task": "apps.physicians.tasks.calculate_monthly_ratings",
|
|
"schedule": crontab(hour=2, minute=0, day_of_month=1),
|
|
},
|
|
# Scraping schedules
|
|
"scrape-youtube-hourly": {
|
|
"task": "social.tasks.scrape_youtube_comments",
|
|
"schedule": 3600.0, # Every hour (in seconds)
|
|
},
|
|
"scrape-facebook-every-6-hours": {
|
|
"task": "social.tasks.scrape_facebook_comments",
|
|
"schedule": 6 * 3600.0, # Every 6 hours
|
|
},
|
|
"scrape-instagram-daily": {
|
|
"task": "social.tasks.scrape_instagram_comments",
|
|
"schedule": crontab(hour=8, minute=0), # Daily at 8:00 AM
|
|
},
|
|
"scrape-twitter-every-2-hours": {
|
|
"task": "social.tasks.scrape_twitter_comments",
|
|
"schedule": 2 * 3600.0, # Every 2 hours
|
|
},
|
|
"scrape-linkedin-daily": {
|
|
"task": "social.tasks.scrape_linkedin_comments",
|
|
"schedule": crontab(hour=9, minute=0), # Daily at 9:00 AM
|
|
},
|
|
"scrape-google-reviews-daily": {
|
|
"task": "social.tasks.scrape_google_reviews",
|
|
"schedule": crontab(hour=10, minute=0), # Daily at 10:00 AM
|
|
},
|
|
# Commented out - individual platform tasks provide sufficient coverage
|
|
# 'scrape-all-platforms-daily': {
|
|
# 'task': 'social.tasks.scrape_all_platforms',
|
|
# 'schedule': crontab(hour=2, minute=0), # Daily at 2:00 AM
|
|
# },
|
|
# Analysis schedules
|
|
"analyze-comments-fallback": {
|
|
"task": "social.tasks.analyze_pending_comments",
|
|
"schedule": 30 * 60.0, # Every 30 minutes
|
|
"kwargs": {"limit": 100},
|
|
},
|
|
# Process scheduled reports every 15 minutes
|
|
"process-scheduled-reports": {
|
|
"task": "apps.reports.tasks.process_scheduled_reports",
|
|
"schedule": crontab(minute="*/15"),
|
|
},
|
|
# Schedule observation monthly follow-ups daily at 6 AM
|
|
"schedule-observation-monthly-followups": {
|
|
"task": "apps.observations.tasks.schedule_monthly_followups",
|
|
"schedule": crontab(hour=6, minute=0),
|
|
},
|
|
# Process observation monthly follow-ups daily at 7 AM
|
|
"process-observation-monthly-followups": {
|
|
"task": "apps.observations.tasks.process_monthly_followups",
|
|
"schedule": crontab(hour=7, minute=0),
|
|
},
|
|
# Analyze feedback sentiment every 30 minutes
|
|
"analyze-feedback-sentiment": {
|
|
"task": "apps.feedback.tasks.process_pending_sentiment_analysis",
|
|
"schedule": crontab(minute="*/30"),
|
|
},
|
|
# Analyze survey text responses every 30 minutes
|
|
"analyze-survey-text-responses": {
|
|
"task": "apps.surveys.tasks.process_survey_text_analysis",
|
|
"schedule": crontab(minute="*/30"),
|
|
},
|
|
# Pre-compute analytics dashboard cache daily at 3 AM
|
|
"precompute-analytics-cache": {
|
|
"task": "apps.analytics.tasks.precompute_dashboard_cache_task",
|
|
"schedule": crontab(hour=3, minute=0),
|
|
},
|
|
# Generate AI executive summary daily at 6 AM
|
|
"generate-daily-executive-summary": {
|
|
"task": "apps.analytics.tasks.generate_executive_summary_task",
|
|
"schedule": crontab(hour=6, minute=0),
|
|
},
|
|
# Generate AI action recommendations daily at 6:30 AM
|
|
"generate-daily-action-recommendations": {
|
|
"task": "apps.analytics.tasks.generate_action_recommendations_task",
|
|
"schedule": crontab(hour=6, minute=30),
|
|
},
|
|
# Send weekly PX digest email every Monday at 8 AM
|
|
"send-weekly-px-digest": {
|
|
"task": "apps.analytics.tasks_digest.send_weekly_digest_task",
|
|
"schedule": crontab(hour=8, minute=0, day_of_week=1), # Monday
|
|
},
|
|
# Send monthly PX digest email on 1st of each month at 8 AM
|
|
"send-monthly-px-digest": {
|
|
"task": "apps.analytics.tasks_digest.send_monthly_digest_task",
|
|
"schedule": crontab(hour=8, minute=0, day_of_month=1),
|
|
},
|
|
}
|
|
|
|
|
|
@app.task(bind=True, ignore_result=True)
|
|
def debug_task(self):
|
|
"""Debug task to test Celery is working."""
|
|
print(f"Request: {self.request!r}")
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Now I need to add the executive summary Celery tasks to the beat schedule configuration.
|
|
|
|
|
|
## Assistant
|
|
|
|
Perfect! Now let me add the executive summary tasks to the Celery beat schedule:
|
|
|
|
|
|
### Tool: Edit: config/celery.py: # Send monthly PX digest e... => # Send monthly PX digest e...
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/config/celery.py",
|
|
"new_string": " # Send monthly PX digest email on 1st of each month at 8 AM\n \"send-monthly-px-digest\": {\n \"task\": \"apps.analytics.tasks_digest.send_monthly_digest_task\",\n \"schedule\": crontab(hour=8, minute=0, day_of_month=1),\n },\n # Executive Summary Tasks\n # Calculate daily metrics at 1 AM\n \"calculate-daily-executive-metrics\": {\n \"task\": \"apps.executive_summary.tasks.calculate_daily_metrics\",\n \"schedule\": crontab(hour=1, minute=0),\n },\n # Generate predictive insights every 6 hours\n \"generate-predictive-insights\": {\n \"task\": \"apps.executive_summary.tasks.generate_predictive_insights\",\n \"schedule\": crontab(hour=\"*/6\"), # Every 6 hours\n },\n # Generate AI recommendations daily at 3 AM\n \"generate-ai-recommendations\": {\n \"task\": \"apps.executive_summary.tasks.generate_ai_recommendations\",\n \"schedule\": crontab(hour=3, minute=0),\n },\n # Generate weekly executive summary every Monday at 6 AM\n \"generate-weekly-executive-summary\": {\n \"task\": \"apps.executive_summary.tasks.generate_weekly_executive_summary\",\n \"schedule\": crontab(hour=6, minute=0, day_of_week=1), # Monday\n },\n # Generate monthly executive summary on 1st at 7 AM\n \"generate-monthly-executive-summary\": {\n \"task\": \"apps.executive_summary.tasks.generate_monthly_executive_summary\",\n \"schedule\": crontab(hour=7, minute=0, day_of_month=1),\n },\n # Send executive PDF reports every Monday at 9 AM\n \"send-executive-pdf-report\": {\n \"task\": \"apps.executive_summary.tasks.send_executive_pdf_report\",\n \"schedule\": crontab(hour=9, minute=0, day_of_week=1), # Monday\n },\n}",
|
|
"old_string": " # Send monthly PX digest email on 1st of each month at 8 AM\n \"send-monthly-px-digest\": {\n \"task\": \"apps.analytics.tasks_digest.send_monthly_digest_task\",\n \"schedule\": crontab(hour=8, minute=0, day_of_month=1),\n },\n}"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/config/celery.py`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `celery.py`:*
|
|
```diff
|
|
"""
|
|
Celery configuration for PX360 project.
|
|
"""
|
|
|
|
import os
|
|
|
|
from celery import Celery
|
|
from celery.schedules import crontab
|
|
|
|
# Set the default Django settings module for the 'celery' program.
|
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev")
|
|
|
|
# Apply zoneinfo compatibility patch for django-celery-beat with Python 3.12+
|
|
# This must be done before Celery app is created
|
|
from config.celery_scheduler import apply_tzcrontab_patch
|
|
|
|
apply_tzcrontab_patch()
|
|
|
|
app = Celery("px360")
|
|
|
|
# Using a string here means the worker doesn't have to serialize
|
|
# the configuration object to child processes.
|
|
# - namespace='CELERY' means all celery-related configuration keys
|
|
# should have a `CELERY_` prefix.
|
|
app.config_from_object("django.conf:settings", namespace="CELERY")
|
|
|
|
# Load task modules from all registered Django apps.
|
|
app.autodiscover_tasks()
|
|
|
|
# Celery Beat schedule for periodic tasks
|
|
app.conf.beat_schedule = {
|
|
# Fetch patient data from HIS every 25 minutes
|
|
"fetch-his-surveys": {
|
|
"task": "apps.integrations.tasks.fetch_his_surveys",
|
|
"schedule": crontab(minute="*/25"),
|
|
"options": {
|
|
"expires": 1500,
|
|
},
|
|
},
|
|
# TEST TASK - Fetch from JSON file (uncomment for testing, remove when done)
|
|
# 'test-fetch-his-surveys-from-json': {
|
|
# 'task': 'apps.integrations.tasks.test_fetch_his_surveys_from_json',
|
|
# 'schedule': crontab(minute='*/5'), # Every 5 minutes
|
|
# },
|
|
# Send pending scheduled surveys every 10 minutes
|
|
# "send-pending-scheduled-surveys": {
|
|
# "task": "apps.surveys.tasks.send_pending_scheduled_surveys",
|
|
# "schedule": crontab(minute="*/10"), # Every 10 minutes
|
|
# },
|
|
# Check for overdue complaints every 15 minutes
|
|
"check-overdue-complaints": {
|
|
"task": "apps.complaints.tasks.check_overdue_complaints",
|
|
"schedule": crontab(minute="*/15"),
|
|
},
|
|
# Check for overdue actions every 15 minutes
|
|
"check-overdue-actions": {
|
|
"task": "apps.px_action_center.tasks.check_overdue_actions",
|
|
"schedule": crontab(minute="*/15"),
|
|
},
|
|
# Send SLA reminders every hour
|
|
"send-sla-reminders": {
|
|
"task": "apps.complaints.tasks.send_sla_reminders",
|
|
"schedule": crontab(minute=0), # Every hour at minute 0
|
|
},
|
|
# Check for overdue explanation requests every 15 minutes
|
|
"check-overdue-explanation-requests": {
|
|
"task": "apps.complaints.tasks.check_overdue_explanation_requests",
|
|
"schedule": crontab(minute="*/15"),
|
|
},
|
|
# Send explanation reminders every hour
|
|
"send-explanation-reminders": {
|
|
"task": "apps.complaints.tasks.send_explanation_reminders",
|
|
"schedule": crontab(minute=0), # Every hour at minute 0
|
|
},
|
|
# Check for overdue inquiries every 15 minutes
|
|
"check-overdue-inquiries": {
|
|
"task": "apps.complaints.tasks.check_overdue_inquiries",
|
|
"schedule": crontab(minute="*/15"),
|
|
},
|
|
# Send inquiry SLA reminders every hour
|
|
"send-inquiry-sla-reminders": {
|
|
"task": "apps.complaints.tasks.send_inquiry_sla_reminders",
|
|
"schedule": crontab(minute=0), # Every hour at minute 0
|
|
},
|
|
# Check for overdue observations every 15 minutes
|
|
"check-overdue-observations": {
|
|
"task": "apps.observations.tasks.check_overdue_observations",
|
|
"schedule": crontab(minute="*/15"),
|
|
},
|
|
# Send observation SLA reminders every hour
|
|
"send-observation-sla-reminders": {
|
|
"task": "apps.observations.tasks.send_observation_sla_reminders",
|
|
"schedule": crontab(minute=0), # Every hour at minute 0
|
|
},
|
|
# Send onboarding reminders every hour
|
|
"send-onboarding-reminders": {
|
|
"task": "apps.accounts.tasks.send_onboarding_reminders",
|
|
"schedule": crontab(minute=30), # Every hour at minute 30
|
|
},
|
|
# Clean up expired invitations daily at midnight
|
|
"cleanup-expired-invitations": {
|
|
"task": "apps.accounts.tasks.cleanup_expired_invitations",
|
|
"schedule": crontab(hour=0, minute=0), # Daily at midnight
|
|
},
|
|
# Calculate daily KPIs at 1 AM
|
|
"calculate-daily-kpis": {
|
|
"task": "apps.analytics.tasks.calculate_daily_kpis",
|
|
"schedule": crontab(hour=1, minute=0),
|
|
},
|
|
# Fetch doctor ratings from HIS on the 1st of each month at 1 AM (before aggregation)
|
|
"fetch-his-doctor-ratings-monthly": {
|
|
"task": "apps.physicians.tasks.fetch_his_doctor_ratings_monthly",
|
|
"schedule": crontab(hour=1, minute=0, day_of_month=1),
|
|
},
|
|
# Fetch doctor ratings from HIS daily at 1:30 AM (yesterday's ratings)
|
|
"fetch-his-doctor-ratings-daily": {
|
|
"task": "apps.physicians.tasks.fetch_his_doctor_ratings_daily",
|
|
"schedule": crontab(hour=1, minute=30),
|
|
},
|
|
# Calculate physician monthly ratings on the 1st of each month at 2 AM
|
|
"calculate-physician-ratings": {
|
|
"task": "apps.physicians.tasks.calculate_monthly_ratings",
|
|
"schedule": crontab(hour=2, minute=0, day_of_month=1),
|
|
},
|
|
# Scraping schedules
|
|
"scrape-youtube-hourly": {
|
|
"task": "social.tasks.scrape_youtube_comments",
|
|
"schedule": 3600.0, # Every hour (in seconds)
|
|
},
|
|
"scrape-facebook-every-6-hours": {
|
|
"task": "social.tasks.scrape_facebook_comments",
|
|
"schedule": 6 * 3600.0, # Every 6 hours
|
|
},
|
|
"scrape-instagram-daily": {
|
|
"task": "social.tasks.scrape_instagram_comments",
|
|
"schedule": crontab(hour=8, minute=0), # Daily at 8:00 AM
|
|
},
|
|
"scrape-twitter-every-2-hours": {
|
|
"task": "social.tasks.scrape_twitter_comments",
|
|
"schedule": 2 * 3600.0, # Every 2 hours
|
|
},
|
|
"scrape-linkedin-daily": {
|
|
"task": "social.tasks.scrape_linkedin_comments",
|
|
"schedule": crontab(hour=9, minute=0), # Daily at 9:00 AM
|
|
},
|
|
"scrape-google-reviews-daily": {
|
|
"task": "social.tasks.scrape_google_reviews",
|
|
"schedule": crontab(hour=10, minute=0), # Daily at 10:00 AM
|
|
},
|
|
# Commented out - individual platform tasks provide sufficient coverage
|
|
# 'scrape-all-platforms-daily': {
|
|
# 'task': 'social.tasks.scrape_all_platforms',
|
|
# 'schedule': crontab(hour=2, minute=0), # Daily at 2:00 AM
|
|
# },
|
|
# Analysis schedules
|
|
"analyze-comments-fallback": {
|
|
"task": "social.tasks.analyze_pending_comments",
|
|
"schedule": 30 * 60.0, # Every 30 minutes
|
|
"kwargs": {"limit": 100},
|
|
},
|
|
# Process scheduled reports every 15 minutes
|
|
"process-scheduled-reports": {
|
|
"task": "apps.reports.tasks.process_scheduled_reports",
|
|
"schedule": crontab(minute="*/15"),
|
|
},
|
|
# Schedule observation monthly follow-ups daily at 6 AM
|
|
"schedule-observation-monthly-followups": {
|
|
"task": "apps.observations.tasks.schedule_monthly_followups",
|
|
"schedule": crontab(hour=6, minute=0),
|
|
},
|
|
# Process observation monthly follow-ups daily at 7 AM
|
|
"process-observation-monthly-followups": {
|
|
"task": "apps.observations.tasks.process_monthly_followups",
|
|
"schedule": crontab(hour=7, minute=0),
|
|
},
|
|
# Analyze feedback sentiment every 30 minutes
|
|
"analyze-feedback-sentiment": {
|
|
"task": "apps.feedback.tasks.process_pending_sentiment_analysis",
|
|
"schedule": crontab(minute="*/30"),
|
|
},
|
|
# Analyze survey text responses every 30 minutes
|
|
"analyze-survey-text-responses": {
|
|
"task": "apps.surveys.tasks.process_survey_text_analysis",
|
|
"schedule": crontab(minute="*/30"),
|
|
},
|
|
# Pre-compute analytics dashboard cache daily at 3 AM
|
|
"precompute-analytics-cache": {
|
|
"task": "apps.analytics.tasks.precompute_dashboard_cache_task",
|
|
"schedule": crontab(hour=3, minute=0),
|
|
},
|
|
# Generate AI executive summary daily at 6 AM
|
|
"generate-daily-executive-summary": {
|
|
"task": "apps.analytics.tasks.generate_executive_summary_task",
|
|
"schedule": crontab(hour=6, minute=0),
|
|
},
|
|
# Generate AI action recommendations daily at 6:30 AM
|
|
"generate-daily-action-recommendations": {
|
|
"task": "apps.analytics.tasks.generate_action_recommendations_task",
|
|
"schedule": crontab(hour=6, minute=30),
|
|
},
|
|
# Send weekly PX digest email every Monday at 8 AM
|
|
"send-weekly-px-digest": {
|
|
"task": "apps.analytics.tasks_digest.send_weekly_digest_task",
|
|
"schedule": crontab(hour=8, minute=0, day_of_week=1), # Monday
|
|
},
|
|
# Send monthly PX digest email on 1st of each month at 8 AM
|
|
"send-monthly-px-digest": {
|
|
"task": "apps.analytics.tasks_digest.send_monthly_digest_task",
|
|
"schedule": crontab(hour=8, minute=0, day_of_month=1),
|
|
},
|
|
# Executive Summary Tasks
|
|
# Calculate daily metrics at 1 AM
|
|
"calculate-daily-executive-metrics": {
|
|
"task": "apps.executive_summary.tasks.calculate_daily_metrics",
|
|
"schedule": crontab(hour=1, minute=0),
|
|
},
|
|
# Generate predictive insights every 6 hours
|
|
"generate-predictive-insights": {
|
|
"task": "apps.executive_summary.tasks.generate_predictive_insights",
|
|
"schedule": crontab(hour="*/6"), # Every 6 hours
|
|
},
|
|
# Generate AI recommendations daily at 3 AM
|
|
"generate-ai-recommendations": {
|
|
"task": "apps.executive_summary.tasks.generate_ai_recommendations",
|
|
"schedule": crontab(hour=3, minute=0),
|
|
},
|
|
# Generate weekly executive summary every Monday at 6 AM
|
|
"generate-weekly-executive-summary": {
|
|
"task": "apps.executive_summary.tasks.generate_weekly_executive_summary",
|
|
"schedule": crontab(hour=6, minute=0, day_of_week=1), # Monday
|
|
},
|
|
# Generate monthly executive summary on 1st at 7 AM
|
|
"generate-monthly-executive-summary": {
|
|
"task": "apps.executive_summary.tasks.generate_monthly_executive_summary",
|
|
"schedule": crontab(hour=7, minute=0, day_of_month=1),
|
|
},
|
|
# Send executive PDF reports every Monday at 9 AM
|
|
"send-executive-pdf-report": {
|
|
"task": "apps.executive_summary.tasks.send_executive_pdf_report",
|
|
"schedule": crontab(hour=9, minute=0, day_of_week=1), # Monday
|
|
},
|
|
}
|
|
|
|
|
|
@app.task(bind=True, ignore_result=True)
|
|
def debug_task(self):
|
|
"""Debug task to test Celery is working."""
|
|
print(f"Request: {self.request!r}")
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
After adding all the core files, I need to create database migrations and verify the code compiles correctly.
|
|
|
|
|
|
## Assistant
|
|
|
|
Excellent! Now let me create the database migrations and run them. Let me also update the todo list:
|
|
|
|
|
|
### Tool: TodoWrite
|
|
**Status**: completed
|
|
|
|
**Output:**
|
|
```
|
|
- [x] Create Executive role and permissions
|
|
- [x] Create executive_summary Django app structure (models, views, services, tasks)
|
|
- [x] Build ExecutiveMetric and ExecutiveReport models
|
|
- [x] Create ExecutiveSummaryService for data aggregation
|
|
- [x] Build Executive Dashboard view with KPI cards and filters
|
|
- [x] Create Executive Dashboard template with ApexCharts
|
|
- [x] Add URL routing and sidebar navigation
|
|
- [x] Implement AI Narrative Service using LiteLLM
|
|
- [x] Build Predictive Analytics Service (trend analysis, risk detection)
|
|
- [x] Create Recommendation Engine
|
|
- [x] Integrate AI-generated briefs into dashboard
|
|
- [x] Implement Natural Language Q&A interface
|
|
- [x] Build Predictive Insights view and risk scoring
|
|
- [x] Create Hospital comparison and leaderboard views
|
|
- [x] Implement PDF report generation with WeasyPrint
|
|
- [x] Create Celery scheduled tasks for automated reports
|
|
- [-] Build mobile-optimized responsive view
|
|
- [x] Add email delivery system for scheduled reports
|
|
- [ ] Run migrations and verify setup
|
|
- [ ] Create initial data migration for Executive role
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Need to run migrations to create the database tables for the new executive_summary models and verify the setup works.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me run migrations and create the initial data migration for the Executive role:
|
|
|
|
|
|
### Tool: Shell: cd /home/ismail/projects/HH && uv run python manage.py makemigrations executive_summary (Create migrations for executive_summary app)
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"command": "cd /home/ismail/projects/HH && uv run python manage.py makemigrations executive_summary",
|
|
"description": "Create migrations for executive_summary app"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Command: cd /home/ismail/projects/HH && uv run python manage.py makemigrations executive_summary
|
|
Directory: (root)
|
|
Output: Traceback (most recent call last):
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/apps/config.py", line 210, in create
|
|
app_module = import_module(app_name)
|
|
^^^^^^^^^^^^^^^^^^^^^^^
|
|
File "/usr/lib/python3.12/importlib/__init__.py", line 90, in import_module
|
|
return _bootstrap._gcd_import(name[level:], package, level)
|
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
File "<frozen importlib._bootstrap>", line 1387, in _gcd_import
|
|
File "<frozen importlib._bootstrap>", line 1360, in _find_and_load
|
|
File "<frozen importlib._bootstrap>", line 1324, in _find_and_load_unlocked
|
|
ModuleNotFoundError: No module named 'executive_summary'
|
|
|
|
During handling of the above exception, another exception occurred:
|
|
|
|
Traceback (most recent call last):
|
|
File "/home/ismail/projects/HH/manage.py", line 22, in <module>
|
|
main()
|
|
File "/home/ismail/projects/HH/manage.py", line 18, in main
|
|
execute_from_command_line(sys.argv)
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/core/management/__init__.py", line 443, in execute_from_command_line
|
|
utility.execute()
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/core/management/__init__.py", line 417, in execute
|
|
django.setup()
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/__init__.py", line 24, in setup
|
|
apps.populate(settings.INSTALLED_APPS)
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/apps/registry.py", line 91, in populate
|
|
app_config = AppConfig.create(entry)
|
|
^^^^^^^^^^^^^^^^^^^^^^^
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/apps/config.py", line 212, in create
|
|
raise ImproperlyConfigured(
|
|
django.core.exceptions.ImproperlyConfigured: Cannot import 'executive_summary'. Check that 'apps.executive_summary.apps.ExecutiveSummaryConfig.name' is correct.
|
|
Error: (none)
|
|
Exit Code: 1
|
|
Signal: 0
|
|
Process Group PGID: 297873
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
The migration command failed because the app config name needs to be verified and corrected.
|
|
|
|
|
|
## Assistant
|
|
|
|
I need to fix the app configuration. Let me check the apps.py file:
|
|
|
|
|
|
### Tool: ReadFile: apps/executive_summary/apps.py
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/executive_summary/apps.py"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/executive_summary/apps.py`
|
|
|
|
**Output:**
|
|
```python
|
|
from django.apps import AppConfig
|
|
|
|
|
|
class ExecutiveSummaryConfig(AppConfig):
|
|
name = 'executive_summary'
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
The app config name needs to be corrected to 'apps.executive_summary' to match the INSTALLED_APPS entry.
|
|
|
|
|
|
### Tool: Edit: apps/executive_summary/apps.py: from django.apps import AppCon... => from django.apps import AppCon...
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"file_path": "/home/ismail/projects/HH/apps/executive_summary/apps.py",
|
|
"new_string": "from django.apps import AppConfig\n\n\nclass ExecutiveSummaryConfig(AppConfig):\n default_auto_field = 'django.db.models.BigAutoField'\n name = 'apps.executive_summary'\n verbose_name = 'Executive Summary'",
|
|
"old_string": "from django.apps import AppConfig\n\n\nclass ExecutiveSummaryConfig(AppConfig):\n name = 'executive_summary'"
|
|
}
|
|
```
|
|
|
|
**Affected Files:**
|
|
- `/home/ismail/projects/HH/apps/executive_summary/apps.py`
|
|
|
|
**Output:**
|
|
|
|
*Diff for `apps.py`:*
|
|
```diff
|
|
from django.apps import AppConfig
|
|
|
|
|
|
class ExecutiveSummaryConfig(AppConfig):
|
|
default_auto_field = 'django.db.models.BigAutoField'
|
|
name = 'apps.executive_summary'
|
|
verbose_name = 'Executive Summary'
|
|
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
After fixing the app configuration, I need to retry the migration command to verify the fix works.
|
|
|
|
|
|
## Assistant
|
|
|
|
Now let me run migrations again:
|
|
|
|
|
|
### Tool: Shell: cd /home/ismail/projects/HH && uv run python manage.py makemigrations executive_summary (Create migrations for executive_summary app)
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"command": "cd /home/ismail/projects/HH && uv run python manage.py makemigrations executive_summary",
|
|
"description": "Create migrations for executive_summary app"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Command: cd /home/ismail/projects/HH && uv run python manage.py makemigrations executive_summary
|
|
Directory: (root)
|
|
Output: Migrations for 'executive_summary':
|
|
apps/executive_summary/migrations/0001_initial.py
|
|
+ Create model ExecutiveReport
|
|
+ Create model PredictiveInsight
|
|
+ Create model AIRecommendation
|
|
+ Create model ExecutiveMetric
|
|
+ Create index executive_s_status_35974a_idx on field(s) status, severity, -created_at of model predictiveinsight
|
|
+ Create index executive_s_hospita_18595e_idx on field(s) hospital, status of model predictiveinsight
|
|
+ Create index executive_s_status_f2a1e6_idx on field(s) status, priority, -created_at of model airecommendation
|
|
+ Create index executive_s_hospita_322e48_idx on field(s) hospital, status of model airecommendation
|
|
Error: (none)
|
|
Exit Code: 0
|
|
Signal: 0
|
|
Process Group PGID: 298316
|
|
```
|
|
|
|
|
|
## Assistant
|
|
|
|
Migrations created successfully, now need to apply them to the database to create the tables.
|
|
|
|
|
|
## Assistant
|
|
|
|
Perfect! Now let me apply the migrations:
|
|
|
|
|
|
### Tool: Shell: cd /home/ismail/projects/HH && uv run python manage.py migrate executive_summary (Apply executive_summary migrations)
|
|
**Status**: completed
|
|
|
|
**Input:**
|
|
```json
|
|
{
|
|
"command": "cd /home/ismail/projects/HH && uv run python manage.py migrate executive_summary",
|
|
"description": "Apply executive_summary migrations"
|
|
}
|
|
```
|
|
|
|
**Output:**
|
|
```
|
|
Command: cd /home/ismail/projects/HH && uv run python manage.py migrate executive_summary
|
|
Directory: (root)
|
|
Output: Operations to perform:
|
|
Apply all migrations: executive_summary
|
|
Running migrations:
|
|
Applying executive_summary.0001_initial... OK
|
|
Traceback (most recent call last):
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 105, in _execute
|
|
return self.cursor.execute(sql, params)
|
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint "django_content_type_pkey"
|
|
DETAIL: Key (id)=(156) already exists.
|
|
|
|
|
|
The above exception was the direct cause of the following exception:
|
|
|
|
Traceback (most recent call last):
|
|
File "/home/ismail/projects/HH/manage.py", line 22, in <module>
|
|
main()
|
|
File "/home/ismail/projects/HH/manage.py", line 18, in main
|
|
execute_from_command_line(sys.argv)
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/core/management/__init__.py", line 443, in execute_from_command_line
|
|
utility.execute()
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/core/management/__init__.py", line 437, in execute
|
|
self.fetch_command(subcommand).run_from_argv(self.argv)
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/core/management/base.py", line 420, in run_from_argv
|
|
self.execute(*args, **cmd_options)
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/core/management/base.py", line 464, in execute
|
|
output = self.handle(*args, **options)
|
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/core/management/base.py", line 111, in wrapper
|
|
res = handle_func(*args, **kwargs)
|
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/core/management/commands/migrate.py", line 381, in handle
|
|
emit_post_migrate_signal(
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/core/management/sql.py", line 52, in emit_post_migrate_signal
|
|
models.signals.post_migrate.send(
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/dispatch/dispatcher.py", line 209, in send
|
|
response = receiver(signal=self, sender=sender, **named)
|
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/contrib/auth/management/__init__.py", line 59, in create_permissions
|
|
create_contenttypes(
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/contrib/contenttypes/management/__init__.py", line 132, in create_contenttypes
|
|
ContentType.objects.using(using).bulk_create(cts)
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/db/models/query.py", line 833, in bulk_create
|
|
returned_columns = self._batched_insert(
|
|
^^^^^^^^^^^^^^^^^^^^^
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/db/models/query.py", line 1956, in _batched_insert
|
|
self._insert(
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/db/models/query.py", line 1918, in _insert
|
|
return query.get_compiler(using=using).execute_sql(returning_fields)
|
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/db/models/sql/compiler.py", line 1925, in execute_sql
|
|
cursor.execute(sql, params)
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 122, in execute
|
|
return super().execute(sql, params)
|
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 79, in execute
|
|
return self._execute_with_wrappers(
|
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 92, in _execute_with_wrappers
|
|
return executor(sql, params, many, context)
|
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 100, in _execute
|
|
with self.db.wrap_database_errors:
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/db/utils.py", line 94, in __exit__
|
|
raise dj_exc_value.with_traceback(traceback) from exc_value
|
|
File "/home/ismail/projects/HH/.venv/lib/python3.12/site-packages/django/db/backends/utils.py", line 105, in _execute
|
|
return self.cursor.execute(sql, params)
|
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
django.db.utils.IntegrityError: duplicate key value violates unique constraint "django_content_type_pkey"
|
|
DETAIL: Key (id)=(156) already exists.
|
|
Error: (none)
|
|
Exit Code: 1
|
|
Signal: 0
|
|
Process Group PGID: 298666
|
|
```
|
|
|