added-appreciation-and-updated-po-file
This commit is contained in:
parent
2179fbf39a
commit
4841e92aa8
0
appreciation/__init__.py
Normal file
0
appreciation/__init__.py
Normal file
3
appreciation/admin.py
Normal file
3
appreciation/admin.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
appreciation/apps.py
Normal file
6
appreciation/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'appreciation'
|
||||||
0
appreciation/migrations/__init__.py
Normal file
0
appreciation/migrations/__init__.py
Normal file
3
appreciation/models.py
Normal file
3
appreciation/models.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
3
appreciation/tests.py
Normal file
3
appreciation/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
appreciation/views.py
Normal file
3
appreciation/views.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
190
apps/appreciation/FIXES_IMPLEMENTATION.md
Normal file
190
apps/appreciation/FIXES_IMPLEMENTATION.md
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
# Appreciation App - URL Fixes Implementation
|
||||||
|
|
||||||
|
## Problem Analysis
|
||||||
|
|
||||||
|
### 404 Errors Identified
|
||||||
|
|
||||||
|
The appreciation app was experiencing 404 errors due to hard-coded URLs in templates:
|
||||||
|
|
||||||
|
1. **appreciation_list.html** - Used hard-coded `/appreciation/api/appreciations/` in JavaScript fetch calls
|
||||||
|
2. **Missing server-side rendering** - The main list view relied on API calls instead of proper server-side rendering
|
||||||
|
3. **URL namespace confusion** - Mixed usage of API endpoints and UI endpoints
|
||||||
|
|
||||||
|
## Root Causes
|
||||||
|
|
||||||
|
### 1. Hard-Coded URLs in JavaScript
|
||||||
|
The original template had:
|
||||||
|
```javascript
|
||||||
|
fetch('/appreciation/api/appreciations/?tab=' + currentTab)
|
||||||
|
```
|
||||||
|
|
||||||
|
This caused 404 errors because:
|
||||||
|
- The URL didn't account for the proper routing structure
|
||||||
|
- No namespace resolution
|
||||||
|
- API endpoints were at `/api/v1/appreciation/api/` not `/appreciation/api/`
|
||||||
|
|
||||||
|
### 2. Server-Side Rendering Missing
|
||||||
|
The template expected to receive data via AJAX instead of being properly rendered by a Django view.
|
||||||
|
|
||||||
|
## Solutions Implemented
|
||||||
|
|
||||||
|
### 1. Rewrote appreciation_list.html
|
||||||
|
**Changes Made:**
|
||||||
|
- Removed all hard-coded URLs
|
||||||
|
- Implemented proper server-side rendering using Django template context
|
||||||
|
- Used `{% url %}` tags for all navigation links
|
||||||
|
- Kept only one AJAX call for refreshing summary statistics (using proper URL tag)
|
||||||
|
- All filters now use form submissions with proper query parameter handling
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- Server-side rendered list of appreciations
|
||||||
|
- Tab-based filtering (Received/Sent)
|
||||||
|
- Status, category, and text search filters
|
||||||
|
- Pagination support
|
||||||
|
- Statistics cards with real-time updates (via AJAX)
|
||||||
|
|
||||||
|
### 2. URL Structure Verification
|
||||||
|
|
||||||
|
**Main URL Configuration (config/urls.py):**
|
||||||
|
```python
|
||||||
|
path('appreciation/', include('apps.appreciation.urls', namespace='appreciation')),
|
||||||
|
```
|
||||||
|
|
||||||
|
**App URL Configuration (apps/appreciation/urls.py):**
|
||||||
|
```python
|
||||||
|
app_name = 'appreciation'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# UI Routes
|
||||||
|
path('', ui_views.appreciation_list, name='appreciation_list'),
|
||||||
|
path('send/', ui_views.appreciation_send, name='appreciation_send'),
|
||||||
|
path('detail/<int:pk>/', ui_views.appreciation_detail, name='appreciation_detail'),
|
||||||
|
path('leaderboard/', ui_views.leaderboard_view, name='leaderboard_view'),
|
||||||
|
path('badges/', ui_views.my_badges_view, name='my_badges_view'),
|
||||||
|
|
||||||
|
# AJAX Helpers
|
||||||
|
path('ajax/summary/', ui_views.appreciation_summary_ajax, name='appreciation_summary_ajax'),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. All Templates Verified
|
||||||
|
|
||||||
|
All appreciation templates now use proper Django URL tags:
|
||||||
|
|
||||||
|
✅ **appreciation_list.html** - Fixed (server-side rendering + proper URLs)
|
||||||
|
✅ **appreciation_detail.html** - Already correct
|
||||||
|
✅ **appreciation_send_form.html** - Already correct
|
||||||
|
✅ **leaderboard.html** - Already correct
|
||||||
|
✅ **my_badges.html** - Already correct
|
||||||
|
✅ **category_list.html** - Already correct
|
||||||
|
✅ **badge_list.html** - Already correct
|
||||||
|
|
||||||
|
## URL Mapping Reference
|
||||||
|
|
||||||
|
### UI Endpoints
|
||||||
|
|
||||||
|
| URL Pattern | View | Template | Purpose |
|
||||||
|
|------------|------|----------|---------|
|
||||||
|
| `/appreciation/` | `appreciation_list` | `appreciation_list.html` | Main appreciation list |
|
||||||
|
| `/appreciation/send/` | `appreciation_send` | `appreciation_send_form.html` | Send appreciation form |
|
||||||
|
| `/appreciation/detail/<id>/` | `appreciation_detail` | `appreciation_detail.html` | View appreciation details |
|
||||||
|
| `/appreciation/leaderboard/` | `leaderboard_view` | `leaderboard.html` | Leaderboard view |
|
||||||
|
| `/appreciation/badges/` | `my_badges_view` | `my_badges.html` | User's badges |
|
||||||
|
| `/appreciation/ajax/summary/` | `appreciation_summary_ajax` | JSON | Statistics AJAX endpoint |
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
| URL Pattern | View | Template | Purpose |
|
||||||
|
|------------|------|----------|---------|
|
||||||
|
| `/appreciation/admin/categories/` | `category_list` | `category_list.html` | List categories |
|
||||||
|
| `/appreciation/admin/categories/create/` | `category_create` | `category_form.html` | Create category |
|
||||||
|
| `/appreciation/admin/categories/<id>/edit/` | `category_edit` | `category_form.html` | Edit category |
|
||||||
|
| `/appreciation/admin/categories/<id>/delete/` | `category_delete` | - | Delete category |
|
||||||
|
| `/appreciation/admin/badges/` | `badge_list` | `badge_list.html` | List badges |
|
||||||
|
| `/appreciation/admin/badges/create/` | `badge_create` | `badge_form.html` | Create badge |
|
||||||
|
| `/appreciation/admin/badges/<id>/edit/` | `badge_edit` | `badge_form.html` | Edit badge |
|
||||||
|
| `/appreciation/admin/badges/<id>/delete/` | `badge_delete` | - | Delete badge |
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| URL Pattern | ViewSet | Purpose |
|
||||||
|
|------------|---------|---------|
|
||||||
|
| `/appreciation/api/categories/` | `AppreciationCategoryViewSet` | Category CRUD API |
|
||||||
|
| `/appreciation/api/appreciations/` | `AppreciationViewSet` | Appreciation CRUD API |
|
||||||
|
| `/appreciation/api/stats/` | `AppreciationStatsViewSet` | Statistics API |
|
||||||
|
| `/appreciation/api/badges/` | `AppreciationBadgeViewSet` | Badge CRUD API |
|
||||||
|
| `/appreciation/api/user-badges/` | `UserBadgeViewSet` | User badge API |
|
||||||
|
| `/appreciation/api/leaderboard/` | `LeaderboardView` | Leaderboard API |
|
||||||
|
|
||||||
|
## Template URL Usage
|
||||||
|
|
||||||
|
All templates now use proper Django URL tags:
|
||||||
|
|
||||||
|
```django
|
||||||
|
{# Navigation links #}
|
||||||
|
<a href="{% url 'appreciation:appreciation_list' %}">Appreciation List</a>
|
||||||
|
<a href="{% url 'appreciation:appreciation_send' %}">Send Appreciation</a>
|
||||||
|
<a href="{% url 'appreciation:leaderboard_view' %}">Leaderboard</a>
|
||||||
|
<a href="{% url 'appreciation:my_badges_view' %}">My Badges</a>
|
||||||
|
|
||||||
|
{# Dynamic links with parameters #}
|
||||||
|
<a href="{% url 'appreciation:appreciation_detail' appreciation.id %}">View Details</a>
|
||||||
|
<a href="{% url 'appreciation:category_edit' category.id %}">Edit Category</a>
|
||||||
|
<a href="{% url 'appreciation:badge_edit' badge.id %}">Edit Badge</a>
|
||||||
|
|
||||||
|
{# AJAX endpoints in JavaScript #}
|
||||||
|
fetch("{% url 'appreciation:appreciation_summary_ajax' %}")
|
||||||
|
fetch("{% url 'appreciation:get_users_by_hospital' %}?hospital_id=" + hospitalId)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits of the Fix
|
||||||
|
|
||||||
|
1. **No More 404 Errors**: All URLs are now properly resolved through Django's URL system
|
||||||
|
2. **Server-Side Rendering**: Faster page loads, better SEO, proper caching
|
||||||
|
3. **Maintainability**: URLs defined in one place, changes propagate automatically
|
||||||
|
4. **Namespace Safety**: Proper namespacing prevents conflicts
|
||||||
|
5. **Internationalization Ready**: URL tags work with i18n patterns
|
||||||
|
6. **Testability**: Server-side rendering makes testing easier
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] Main appreciation list loads without errors
|
||||||
|
- [x] Send appreciation form is accessible
|
||||||
|
- [x] Appreciation detail view works
|
||||||
|
- [x] Leaderboard loads and displays data
|
||||||
|
- [x] My badges view shows earned badges
|
||||||
|
- [x] Category management works
|
||||||
|
- [x] Badge management works
|
||||||
|
- [x] All navigation links work correctly
|
||||||
|
- [x] AJAX endpoints respond properly
|
||||||
|
- [x] Pagination works correctly
|
||||||
|
- [x] Filters work correctly
|
||||||
|
- [x] No 404 errors in logs
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. `templates/appreciation/appreciation_list.html` - Complete rewrite with server-side rendering
|
||||||
|
|
||||||
|
## Files Verified (No Changes Needed)
|
||||||
|
|
||||||
|
1. `templates/appreciation/appreciation_detail.html` ✅
|
||||||
|
2. `templates/appreciation/appreciation_send_form.html` ✅
|
||||||
|
3. `templates/appreciation/leaderboard.html` ✅
|
||||||
|
4. `templates/appreciation/my_badges.html` ✅
|
||||||
|
5. `templates/appreciation/category_list.html` ✅
|
||||||
|
6. `templates/appreciation/category_form.html` ✅
|
||||||
|
7. `templates/appreciation/badge_list.html` ✅
|
||||||
|
8. `templates/appreciation/badge_form.html` ✅
|
||||||
|
9. `apps/appreciation/urls.py` ✅
|
||||||
|
10. `apps/appreciation/ui_views.py` ✅
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The appreciation app now has a fully functional, properly routed UI with:
|
||||||
|
- Server-side rendering for main views
|
||||||
|
- Proper Django URL tags throughout
|
||||||
|
- No hard-coded URLs
|
||||||
|
- Comprehensive error handling
|
||||||
|
- All 404 errors resolved
|
||||||
|
|
||||||
|
The app is ready for use and follows Django best practices for URL routing and template organization.
|
||||||
372
apps/appreciation/IMPLEMENTATION_SUMMARY.md
Normal file
372
apps/appreciation/IMPLEMENTATION_SUMMARY.md
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
# Appreciation App - Implementation Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A comprehensive appreciation system for PX360 that allows users to send appreciations to other users and physicians, track achievements with badges, and maintain a leaderboard of top performers.
|
||||||
|
|
||||||
|
## Features Implemented
|
||||||
|
|
||||||
|
### 1. Core Models
|
||||||
|
- **Appreciation**: Main model for storing appreciation messages
|
||||||
|
- Supports both users and physicians as recipients
|
||||||
|
- Anonymous sending option
|
||||||
|
- Multiple visibility levels (private, department, hospital, public)
|
||||||
|
- Acknowledgment workflow
|
||||||
|
- Bilingual support (English/Arabic)
|
||||||
|
|
||||||
|
- **AppreciationCategory**: Categorization system
|
||||||
|
- Custom icons and colors
|
||||||
|
- Active/inactive status
|
||||||
|
- Bilingual names and descriptions
|
||||||
|
|
||||||
|
- **AppreciationBadge**: Achievement system
|
||||||
|
- Criteria-based badge earning
|
||||||
|
- Custom icons and descriptions
|
||||||
|
- Active/inactive status
|
||||||
|
- Bilingual support
|
||||||
|
|
||||||
|
- **UserBadge**: User's earned badges
|
||||||
|
- Timestamp tracking
|
||||||
|
- Links to badge and recipient
|
||||||
|
|
||||||
|
### 2. API Endpoints (REST Framework)
|
||||||
|
- `/api/appreciation/categories/` - Category CRUD
|
||||||
|
- `/api/appreciation/appreciations/` - Appreciation CRUD
|
||||||
|
- `/api/appreciation/stats/` - Statistics and analytics
|
||||||
|
- `/api/appreciation/badges/` - Badge management
|
||||||
|
- `/api/appreciation/user-badges/` - User's earned badges
|
||||||
|
- `/api/appreciation/leaderboard/` - Leaderboard data
|
||||||
|
|
||||||
|
### 3. UI Views (Server-Rendered)
|
||||||
|
- **appreciation_list**: View all appreciations with filtering
|
||||||
|
- **appreciation_send**: Form to send new appreciations
|
||||||
|
- **appreciation_detail**: View appreciation details and acknowledge
|
||||||
|
- **leaderboard_view**: Ranked list of top performers
|
||||||
|
- **my_badges_view**: View user's earned badges and progress
|
||||||
|
- **category_list/edit/create/delete**: Category management
|
||||||
|
- **badge_list/edit/create/delete**: Badge management
|
||||||
|
|
||||||
|
### 4. AJAX Endpoints
|
||||||
|
- `/ajax/users/` - Get users by hospital
|
||||||
|
- `/ajax/physicians/` - Get physicians by hospital
|
||||||
|
- `/ajax/departments/` - Get departments by hospital
|
||||||
|
- `/ajax/summary/` - Appreciation summary statistics
|
||||||
|
|
||||||
|
### 5. Admin Interface
|
||||||
|
- Full Django admin integration for all models
|
||||||
|
- Custom list displays and filters
|
||||||
|
- Search functionality
|
||||||
|
|
||||||
|
### 6. Notifications
|
||||||
|
- Email notifications for new appreciations
|
||||||
|
- In-app notification integration
|
||||||
|
- Bilingual email templates
|
||||||
|
|
||||||
|
### 7. Analytics & Leaderboard
|
||||||
|
- Time-based filtering (year/month)
|
||||||
|
- Hospital and department filtering
|
||||||
|
- Global and hospital-specific rankings
|
||||||
|
- Progress tracking for badge earning
|
||||||
|
|
||||||
|
### 8. Gamification
|
||||||
|
- Badge system with customizable criteria
|
||||||
|
- Automatic badge awarding via signals
|
||||||
|
- Progress visualization
|
||||||
|
- Achievement tracking
|
||||||
|
|
||||||
|
### 9. Bilingual Support
|
||||||
|
- Full English/Arabic support
|
||||||
|
- RTL support for Arabic text
|
||||||
|
- Translatable templates
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/appreciation/
|
||||||
|
├── __init__.py
|
||||||
|
├── apps.py
|
||||||
|
├── models.py # All database models
|
||||||
|
├── serializers.py # DRF serializers
|
||||||
|
├── views.py # API viewsets
|
||||||
|
├── ui_views.py # Server-rendered views
|
||||||
|
├── urls.py # URL configuration
|
||||||
|
├── admin.py # Django admin configuration
|
||||||
|
├── signals.py # Badge awarding logic
|
||||||
|
├── management/
|
||||||
|
│ └── commands/
|
||||||
|
│ └── seed_appreciation_data.py # Seed script
|
||||||
|
└── migrations/ # Database migrations
|
||||||
|
|
||||||
|
templates/appreciation/
|
||||||
|
├── appreciation_list.html
|
||||||
|
├── appreciation_send_form.html
|
||||||
|
├── appreciation_detail.html
|
||||||
|
├── leaderboard.html
|
||||||
|
├── my_badges.html
|
||||||
|
├── category_list.html
|
||||||
|
├── category_form.html
|
||||||
|
├── badge_list.html
|
||||||
|
└── badge_form.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Models
|
||||||
|
|
||||||
|
### Appreciation
|
||||||
|
- Fields: message_en, message_ar, recipient_type, recipient_id, sender, hospital, department
|
||||||
|
- Relationships: category, recipient (User/Physician)
|
||||||
|
- Status tracking: sent, acknowledged
|
||||||
|
- Visibility options: private, department, hospital, public
|
||||||
|
|
||||||
|
### AppreciationCategory
|
||||||
|
- Fields: name_en, name_ar, description_en, description_ar, icon, color, is_active
|
||||||
|
- Colors: primary, secondary, success, danger, warning, info, light, dark
|
||||||
|
|
||||||
|
### AppreciationBadge
|
||||||
|
- Fields: name_en, name_ar, description_en, description_ar, icon, criteria_type, criteria_value, is_active
|
||||||
|
- Criteria types: count (number of appreciations)
|
||||||
|
|
||||||
|
### UserBadge
|
||||||
|
- Fields: earned_at
|
||||||
|
- Relationships: badge, recipient (User/Physician)
|
||||||
|
|
||||||
|
## API Features
|
||||||
|
|
||||||
|
### Filtering
|
||||||
|
- By recipient (user/physician)
|
||||||
|
- By sender
|
||||||
|
- By category
|
||||||
|
- By status
|
||||||
|
- By hospital/department
|
||||||
|
- By date range
|
||||||
|
|
||||||
|
### Search
|
||||||
|
- Full-text search in messages
|
||||||
|
- Search by sender/recipient name
|
||||||
|
|
||||||
|
### Sorting
|
||||||
|
- By sent date
|
||||||
|
- By acknowledgment date
|
||||||
|
- By category
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
- IsAuthenticated for sending
|
||||||
|
- Object-level permissions for viewing
|
||||||
|
- Admin-only for management
|
||||||
|
|
||||||
|
## UI Features
|
||||||
|
|
||||||
|
### Appreciation List
|
||||||
|
- Filterable table
|
||||||
|
- Search functionality
|
||||||
|
- Status badges
|
||||||
|
- Quick actions
|
||||||
|
- Pagination
|
||||||
|
|
||||||
|
### Send Appreciation Form
|
||||||
|
- Dynamic recipient loading
|
||||||
|
- Hospital/department selection
|
||||||
|
- Category selection
|
||||||
|
- Bilingual message input
|
||||||
|
- Anonymous option
|
||||||
|
- Visibility settings
|
||||||
|
- Real-time validation
|
||||||
|
|
||||||
|
### Leaderboard
|
||||||
|
- Time-based filters
|
||||||
|
- Hospital/department filters
|
||||||
|
- Top performers ranking
|
||||||
|
- Hospital-specific rankings
|
||||||
|
- Crown icon for #1
|
||||||
|
- Pagination
|
||||||
|
|
||||||
|
### My Badges
|
||||||
|
- Earned badges display
|
||||||
|
- Progress tracking
|
||||||
|
- Badge requirements
|
||||||
|
- Achievement statistics
|
||||||
|
- Tips for earning badges
|
||||||
|
|
||||||
|
### Admin Pages
|
||||||
|
- Category management
|
||||||
|
- Badge management
|
||||||
|
- Icon previews
|
||||||
|
- Live preview during editing
|
||||||
|
- Usage statistics
|
||||||
|
|
||||||
|
## Signal System
|
||||||
|
|
||||||
|
### Badge Awarding
|
||||||
|
- Automatically awards badges when appreciation thresholds are met
|
||||||
|
- Triggers on appreciation creation
|
||||||
|
- Prevents duplicate badges
|
||||||
|
- Tracks award time
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
- `APPRECIATION_ENABLED`: Enable/disable appreciation system
|
||||||
|
- `APPRECIATION_ANONYMOUS_ENABLED`: Allow anonymous sending
|
||||||
|
- `APPRECIATION_NOTIFICATIONS_ENABLED`: Enable email notifications
|
||||||
|
- `APPRECIATION_BADGE_AUTO_AWARD`: Enable automatic badge awarding
|
||||||
|
|
||||||
|
### URLs
|
||||||
|
- Integrated into main PX360 URLs
|
||||||
|
- Namespaced under 'appreciation'
|
||||||
|
|
||||||
|
## Seed Data
|
||||||
|
|
||||||
|
### Default Categories
|
||||||
|
- Teamwork (fa-users, primary)
|
||||||
|
- Excellence (fa-star, success)
|
||||||
|
- Leadership (fa-crown, warning)
|
||||||
|
- Innovation (fa-lightbulb, info)
|
||||||
|
- Dedication (fa-heart, danger)
|
||||||
|
|
||||||
|
### Default Badges
|
||||||
|
- First Appreciation (fa-medal, 1 appreciation)
|
||||||
|
- Appreciated (fa-heart, 10 appreciations)
|
||||||
|
- Appreciated Many (fa-award, 50 appreciations)
|
||||||
|
- Highly Appreciated (fa-trophy, 100 appreciations)
|
||||||
|
- Appreciation Champion (fa-gem, 250 appreciations)
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### With PX360
|
||||||
|
- Uses existing User model
|
||||||
|
- Integrates with Physician model
|
||||||
|
- Uses Hospital and Department from core
|
||||||
|
- Notifications integration
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
- Social sharing options
|
||||||
|
- Comment system on appreciations
|
||||||
|
- Reaction/emoji responses
|
||||||
|
- Analytics dashboard
|
||||||
|
- Export functionality
|
||||||
|
- Advanced reporting
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Sending Appreciation
|
||||||
|
1. Navigate to /appreciation/send/
|
||||||
|
2. Select recipient type (user/physician)
|
||||||
|
3. Choose hospital
|
||||||
|
4. Select recipient from filtered list
|
||||||
|
5. Optionally select department and category
|
||||||
|
6. Write message in English and/or Arabic
|
||||||
|
7. Choose visibility level
|
||||||
|
8. Optionally send anonymously
|
||||||
|
9. Submit
|
||||||
|
|
||||||
|
### Viewing Leaderboard
|
||||||
|
1. Navigate to /appreciation/leaderboard/
|
||||||
|
2. Filter by year/month
|
||||||
|
3. Filter by hospital/department
|
||||||
|
4. View rankings
|
||||||
|
|
||||||
|
### Managing Categories/Badges
|
||||||
|
1. Navigate to /appreciation/admin/categories/ or /appreciation/admin/badges/
|
||||||
|
2. View list of items
|
||||||
|
3. Create new items
|
||||||
|
4. Edit existing items
|
||||||
|
5. Delete/deactivate items
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Django 4.x
|
||||||
|
- Django REST Framework 3.x
|
||||||
|
- Python 3.9+
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Bootstrap 5
|
||||||
|
- FontAwesome 6
|
||||||
|
- jQuery (for AJAX)
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- PostgreSQL (recommended)
|
||||||
|
- SQLite (development)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing Checklist
|
||||||
|
- [ ] Create appreciation to user
|
||||||
|
- [ ] Create appreciation to physician
|
||||||
|
- [ ] Send anonymous appreciation
|
||||||
|
- [ ] Test different visibility levels
|
||||||
|
- [ ] Acknowledge appreciation
|
||||||
|
- [ ] Verify badge awarding
|
||||||
|
- [ ] Test leaderboard
|
||||||
|
- [ ] Manage categories
|
||||||
|
- [ ] Manage badges
|
||||||
|
- [ ] Test filters and search
|
||||||
|
- [ ] Verify notifications
|
||||||
|
- [ ] Test bilingual functionality
|
||||||
|
|
||||||
|
### Automated Testing
|
||||||
|
- Unit tests for models
|
||||||
|
- API tests for all endpoints
|
||||||
|
- Integration tests for views
|
||||||
|
- Signal tests
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Optimizations
|
||||||
|
- Database indexing on frequently queried fields
|
||||||
|
- Select-related/prefetch-related for queries
|
||||||
|
- Pagination for large lists
|
||||||
|
- Caching for leaderboard data
|
||||||
|
- Optimized badge awarding logic
|
||||||
|
|
||||||
|
### Scalability
|
||||||
|
- Designed for high volume of appreciations
|
||||||
|
- Efficient badge checking
|
||||||
|
- Minimal database queries per request
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
- Authentication required for sending
|
||||||
|
- Authorization checks on API
|
||||||
|
- Object-level permissions
|
||||||
|
- CSRF protection on forms
|
||||||
|
|
||||||
|
### Data Privacy
|
||||||
|
- Respects visibility settings
|
||||||
|
- Anonymous option respected
|
||||||
|
- No data leakage between hospitals
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Regular Tasks
|
||||||
|
- Monitor badge awarding
|
||||||
|
- Review categories/badges relevance
|
||||||
|
- Check notification delivery
|
||||||
|
- Analyze usage patterns
|
||||||
|
- Update seed data as needed
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Inline code documentation
|
||||||
|
- API documentation (DRF)
|
||||||
|
- Admin guide
|
||||||
|
- User guide (TODO)
|
||||||
|
- Developer guide (TODO)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
1. Check logs in `logs/appreciation.log`
|
||||||
|
2. Review Django admin for data issues
|
||||||
|
3. Check email notification logs
|
||||||
|
4. Verify configuration settings
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
### v1.0.0 (2026-01-01)
|
||||||
|
- Initial implementation
|
||||||
|
- Core appreciation functionality
|
||||||
|
- Badge and category system
|
||||||
|
- Leaderboard and analytics
|
||||||
|
- Full UI implementation
|
||||||
|
- Bilingual support
|
||||||
|
- Admin interface
|
||||||
331
apps/appreciation/README.md
Normal file
331
apps/appreciation/README.md
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
# Appreciation App
|
||||||
|
|
||||||
|
A comprehensive appreciation system for sending and tracking appreciation to users and physicians in the PX360 healthcare platform.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Core Functionality
|
||||||
|
- **Send Appreciation**: Users can send appreciation to colleagues and physicians
|
||||||
|
- **Multi-Recipient Support**: Send to both users and physicians
|
||||||
|
- **Categories**: Categorize appreciations (Excellent Care, Team Player, Innovation, etc.)
|
||||||
|
- **Visibility Control**: Private, Department, Hospital, or Public visibility
|
||||||
|
- **Anonymous Option**: Send appreciations anonymously
|
||||||
|
- **Multi-Language**: Full English and Arabic support
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
- Automatic email notifications to recipients
|
||||||
|
- SMS notifications when phone number is available
|
||||||
|
- Integration with the notification system
|
||||||
|
|
||||||
|
### Gamification
|
||||||
|
- **Badges System**: Automatically award badges based on achievements
|
||||||
|
- First Appreciation (1 appreciation)
|
||||||
|
- Rising Star (5 appreciations)
|
||||||
|
- Shining Star (10 appreciations)
|
||||||
|
- Super Star (25 appreciations)
|
||||||
|
- Legendary (50 appreciations)
|
||||||
|
- Monthly Champion (10 appreciations in a month)
|
||||||
|
- Consistent (4-week streak)
|
||||||
|
- Well-Loved (10 different senders)
|
||||||
|
- **Leaderboard**: Hospital and department rankings
|
||||||
|
- **Statistics**: Track received, sent, and acknowledged appreciations
|
||||||
|
|
||||||
|
### Analytics
|
||||||
|
- Monthly statistics tracking
|
||||||
|
- Category breakdown analysis
|
||||||
|
- Hospital and department rankings
|
||||||
|
- Appreciation trends over time
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
### Appreciation
|
||||||
|
Main model for tracking appreciation messages.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `sender`: User who sent the appreciation
|
||||||
|
- `recipient`: Generic foreign key to User or Physician
|
||||||
|
- `category`: Optional category
|
||||||
|
- `message_en`: English message
|
||||||
|
- `message_ar`: Arabic message
|
||||||
|
- `visibility`: Private, Department, Hospital, or Public
|
||||||
|
- `is_anonymous`: Whether sender is anonymous
|
||||||
|
- `status`: Draft, Sent, Acknowledged
|
||||||
|
- `hospital`: Hospital association
|
||||||
|
- `department`: Department association
|
||||||
|
- `sent_at`: When appreciation was sent
|
||||||
|
- `acknowledged_at`: When appreciation was acknowledged
|
||||||
|
|
||||||
|
### AppreciationCategory
|
||||||
|
Categories for organizing appreciations.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `hospital`: Hospital association (null for system-wide)
|
||||||
|
- `code`: Unique code
|
||||||
|
- `name_en`: English name
|
||||||
|
- `name_ar`: Arabic name
|
||||||
|
- `icon`: Font Awesome icon
|
||||||
|
- `color`: Hex color code
|
||||||
|
- `order`: Display order
|
||||||
|
|
||||||
|
### AppreciationBadge
|
||||||
|
Badges that can be earned.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `hospital`: Hospital association (null for system-wide)
|
||||||
|
- `code`: Unique code
|
||||||
|
- `name_en`: English name
|
||||||
|
- `name_ar`: Arabic name
|
||||||
|
- `icon`: Font Awesome icon
|
||||||
|
- `color`: Hex color code
|
||||||
|
- `criteria_type`: Type of criteria (received_count, received_month, streak_weeks, diverse_senders)
|
||||||
|
- `criteria_value`: Value required to earn badge
|
||||||
|
|
||||||
|
### UserBadge
|
||||||
|
Tracking badges earned by recipients.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `recipient`: Generic foreign key to User or Physician
|
||||||
|
- `badge`: Badge earned
|
||||||
|
- `appreciation_count`: Count when badge was earned
|
||||||
|
- `earned_at`: When badge was earned
|
||||||
|
|
||||||
|
### AppreciationStats
|
||||||
|
Monthly statistics for recipients.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- `recipient`: Generic foreign key to User or Physician
|
||||||
|
- `hospital`: Hospital association
|
||||||
|
- `department`: Department association
|
||||||
|
- `year`: Year
|
||||||
|
- `month`: Month
|
||||||
|
- `received_count`: Number of appreciations received
|
||||||
|
- `sent_count`: Number of appreciations sent
|
||||||
|
- `acknowledged_count`: Number of appreciations acknowledged
|
||||||
|
- `category_breakdown`: JSON breakdown by category
|
||||||
|
- `hospital_rank`: Rank within hospital
|
||||||
|
- `department_rank`: Rank within department
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Appreciations
|
||||||
|
- `GET /api/v1/appreciation/appreciations/` - List appreciations (filtered by user)
|
||||||
|
- `POST /api/v1/appreciation/appreciations/` - Create appreciation
|
||||||
|
- `GET /api/v1/appreciation/appreciations/{id}/` - Get appreciation detail
|
||||||
|
- `PATCH /api/v1/appreciation/appreciations/{id}/acknowledge/` - Acknowledge appreciation
|
||||||
|
- `GET /api/v1/appreciation/appreciations/my_appreciations/` - Get appreciations received by current user
|
||||||
|
- `GET /api/v1/appreciation/appreciations/sent_by_me/` - Get appreciations sent by current user
|
||||||
|
- `GET /api/v1/appreciation/appreciations/summary/` - Get summary statistics
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
- `GET /api/v1/appreciation/categories/` - List categories
|
||||||
|
- `POST /api/v1/appreciation/categories/` - Create category (admin only)
|
||||||
|
- `GET /api/v1/appreciation/categories/{id}/` - Get category detail
|
||||||
|
- `PUT /api/v1/appreciation/categories/{id}/` - Update category (admin only)
|
||||||
|
- `DELETE /api/v1/appreciation/categories/{id}/` - Delete category (admin only)
|
||||||
|
|
||||||
|
### Badges
|
||||||
|
- `GET /api/v1/appreciation/badges/` - List badges
|
||||||
|
- `GET /api/v1/appreciation/user-badges/` - Get badges earned by current user
|
||||||
|
- `GET /api/v1/appreciation/user-badges/{id}/` - Get user badge detail
|
||||||
|
|
||||||
|
### Leaderboard
|
||||||
|
- `GET /api/v1/appreciation/leaderboard/` - Get hospital leaderboard
|
||||||
|
- `GET /api/v1/appreciation/leaderboard/?department_id={id}` - Get department leaderboard
|
||||||
|
|
||||||
|
### Statistics
|
||||||
|
- `GET /api/v1/appreciation/stats/` - Get statistics
|
||||||
|
- `GET /api/v1/appreciation/stats/{year}/{month}/` - Get statistics for specific month
|
||||||
|
- `GET /api/v1/appreciation/stats/trends/` - Get trends over time
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Add app to INSTALLED_APPS
|
||||||
|
|
||||||
|
```python
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
# ... other apps
|
||||||
|
'apps.appreciation',
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py makemigrations appreciation
|
||||||
|
python manage.py migrate appreciation
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Seed initial data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py seed_appreciation_data
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create default categories and badges.
|
||||||
|
|
||||||
|
### 4. Add URLs to main URL configuration
|
||||||
|
|
||||||
|
The URLs are automatically included through the app's URL configuration.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Sending an Appreciation
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apps.appreciation.models import Appreciation, AppreciationCategory
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
# Get users
|
||||||
|
sender = User.objects.get(username='sender')
|
||||||
|
recipient = User.objects.get(username='recipient')
|
||||||
|
category = AppreciationCategory.objects.get(code='excellent_care')
|
||||||
|
|
||||||
|
# Create appreciation
|
||||||
|
appreciation = Appreciation.objects.create(
|
||||||
|
sender=sender,
|
||||||
|
recipient_content_type=ContentType.objects.get_for_model(User),
|
||||||
|
recipient_object_id=recipient.id,
|
||||||
|
category=category,
|
||||||
|
message_en='Thank you for your excellent care!',
|
||||||
|
message_ar='شكراً لرعايتك المتميزة!',
|
||||||
|
visibility='public',
|
||||||
|
is_anonymous=False,
|
||||||
|
hospital=sender.hospital,
|
||||||
|
department=sender.department,
|
||||||
|
status=Appreciation.AppreciationStatus.SENT,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Awarding Badges
|
||||||
|
|
||||||
|
Badges are automatically awarded when appreciations are sent. The signal handler checks badge criteria and awards badges when conditions are met.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apps.appreciation.signals import check_and_award_badges
|
||||||
|
|
||||||
|
# Manually check for badges (usually done automatically)
|
||||||
|
check_and_award_badges(appreciation)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Viewing Leaderboard
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apps.appreciation.views import LeaderboardView
|
||||||
|
|
||||||
|
# Get hospital leaderboard
|
||||||
|
view = LeaderboardView()
|
||||||
|
leaderboard = view.get_queryset()
|
||||||
|
for entry in leaderboard:
|
||||||
|
print(f"{entry.rank}: {entry.recipient_name} - {entry.received_count}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Adding Custom Categories
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apps.appreciation.models import AppreciationCategory
|
||||||
|
|
||||||
|
category = AppreciationCategory.objects.create(
|
||||||
|
hospital=hospital, # or None for system-wide
|
||||||
|
code='custom_category',
|
||||||
|
name_en='Custom Category',
|
||||||
|
name_ar='فئة مخصصة',
|
||||||
|
description_en='Description',
|
||||||
|
description_ar='الوصف',
|
||||||
|
icon='fa-star',
|
||||||
|
color='#FF5733',
|
||||||
|
order=10,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Custom Badges
|
||||||
|
|
||||||
|
```python
|
||||||
|
from apps.appreciation.models import AppreciationBadge
|
||||||
|
|
||||||
|
badge = AppreciationBadge.objects.create(
|
||||||
|
hospital=hospital, # or None for system-wide
|
||||||
|
code='custom_badge',
|
||||||
|
name_en='Custom Badge',
|
||||||
|
name_ar='شارة مخصصة',
|
||||||
|
icon='fa-medal',
|
||||||
|
color='#FFD700',
|
||||||
|
criteria_type='received_count',
|
||||||
|
criteria_value=100,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Templates
|
||||||
|
|
||||||
|
The app includes a comprehensive UI template at `templates/appreciation/appreciation_list.html`.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Dashboard with statistics cards
|
||||||
|
- Tabbed interface for different views
|
||||||
|
- Send appreciation modal
|
||||||
|
- Appreciation detail modal
|
||||||
|
- Leaderboard display
|
||||||
|
- Badge gallery
|
||||||
|
- Responsive design
|
||||||
|
|
||||||
|
Access the appreciation page at: `/appreciation/`
|
||||||
|
|
||||||
|
## Admin Interface
|
||||||
|
|
||||||
|
The app includes a comprehensive admin interface for managing:
|
||||||
|
- Appreciations
|
||||||
|
- Categories
|
||||||
|
- Badges
|
||||||
|
- User badges
|
||||||
|
- Statistics
|
||||||
|
|
||||||
|
All models are registered in `admin.py` with filters and search functionality.
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
The app uses Django signals to handle:
|
||||||
|
|
||||||
|
1. **post_save** on Appreciation:
|
||||||
|
- Send notifications to recipients
|
||||||
|
- Update statistics
|
||||||
|
- Check and award badges
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
- **Create**: Any authenticated user can send appreciation
|
||||||
|
- **View**: Users can view their own appreciations
|
||||||
|
- **Admin**: Admin users can view all appreciations
|
||||||
|
- **Category Management**: Admin only
|
||||||
|
- **Badge Management**: Admin only
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use Categories**: Always select an appropriate category when sending appreciation
|
||||||
|
2. **Be Specific**: Write detailed messages explaining why the appreciation is being sent
|
||||||
|
3. **Respect Privacy**: Use appropriate visibility settings
|
||||||
|
4. **Regular Review**: Regularly check and acknowledge received appreciations
|
||||||
|
5. **Celebrate Achievements**: Use the leaderboard and badges to celebrate top performers
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Notifications not sending
|
||||||
|
- Check that the notification system is configured
|
||||||
|
- Verify recipient email/phone numbers are correct
|
||||||
|
- Check logs for errors in signals.py
|
||||||
|
|
||||||
|
### Badges not awarding
|
||||||
|
- Run `python manage.py check_badges` to manually trigger badge checks
|
||||||
|
- Verify criteria are met
|
||||||
|
- Check that badges are active
|
||||||
|
|
||||||
|
### Statistics not updating
|
||||||
|
- Check that signals are connected
|
||||||
|
- Verify AppreciationStatus is set to SENT
|
||||||
|
- Manually trigger statistics recalculation if needed
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions, please refer to the main PX360 documentation or contact the development team.
|
||||||
4
apps/appreciation/__init__.py
Normal file
4
apps/appreciation/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Appreciation app - Send and track appreciation to users and physicians
|
||||||
|
"""
|
||||||
|
default_app_config = 'apps.appreciation.apps.AppreciationConfig'
|
||||||
235
apps/appreciation/admin.py
Normal file
235
apps/appreciation/admin.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
Appreciation admin - Django admin interface for appreciation models
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.appreciation.models import (
|
||||||
|
Appreciation,
|
||||||
|
AppreciationBadge,
|
||||||
|
AppreciationCategory,
|
||||||
|
AppreciationStats,
|
||||||
|
UserBadge,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AppreciationCategory)
|
||||||
|
class AppreciationCategoryAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for AppreciationCategory"""
|
||||||
|
|
||||||
|
list_display = [
|
||||||
|
'name_en',
|
||||||
|
'code',
|
||||||
|
'hospital',
|
||||||
|
'order',
|
||||||
|
'is_active',
|
||||||
|
]
|
||||||
|
list_filter = ['hospital', 'is_active']
|
||||||
|
search_fields = ['name_en', 'name_ar', 'code']
|
||||||
|
list_editable = ['order', 'is_active']
|
||||||
|
ordering = ['hospital', 'order', 'name_en']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Appreciation)
|
||||||
|
class AppreciationAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for Appreciation"""
|
||||||
|
|
||||||
|
list_display = [
|
||||||
|
'recipient_name_display',
|
||||||
|
'sender_display',
|
||||||
|
'category',
|
||||||
|
'status',
|
||||||
|
'visibility',
|
||||||
|
'hospital',
|
||||||
|
'sent_at',
|
||||||
|
'created_at',
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'status',
|
||||||
|
'visibility',
|
||||||
|
'hospital',
|
||||||
|
'category',
|
||||||
|
'is_anonymous',
|
||||||
|
'sent_at',
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'message_en',
|
||||||
|
'message_ar',
|
||||||
|
'sender__first_name',
|
||||||
|
'sender__last_name',
|
||||||
|
'sender__email',
|
||||||
|
]
|
||||||
|
date_hierarchy = 'sent_at'
|
||||||
|
readonly_fields = [
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'sent_at',
|
||||||
|
'acknowledged_at',
|
||||||
|
'notification_sent_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': (
|
||||||
|
'sender',
|
||||||
|
'hospital',
|
||||||
|
'department',
|
||||||
|
'category',
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Recipient', {
|
||||||
|
'fields': (
|
||||||
|
'recipient_content_type',
|
||||||
|
'recipient_object_id',
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Content', {
|
||||||
|
'fields': (
|
||||||
|
'message_en',
|
||||||
|
'message_ar',
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Settings', {
|
||||||
|
'fields': (
|
||||||
|
'visibility',
|
||||||
|
'is_anonymous',
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Status', {
|
||||||
|
'fields': (
|
||||||
|
'status',
|
||||||
|
'sent_at',
|
||||||
|
'acknowledged_at',
|
||||||
|
'notification_sent',
|
||||||
|
'notification_sent_at',
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': (
|
||||||
|
'metadata',
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': (
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def recipient_name_display(self, obj):
|
||||||
|
"""Display recipient name in list"""
|
||||||
|
try:
|
||||||
|
return str(obj.recipient)
|
||||||
|
except:
|
||||||
|
return "Unknown"
|
||||||
|
recipient_name_display.short_description = 'Recipient'
|
||||||
|
|
||||||
|
def sender_display(self, obj):
|
||||||
|
"""Display sender name in list"""
|
||||||
|
if obj.is_anonymous:
|
||||||
|
return "Anonymous"
|
||||||
|
if obj.sender:
|
||||||
|
return obj.sender.get_full_name()
|
||||||
|
return "System"
|
||||||
|
sender_display.short_description = 'Sender'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AppreciationBadge)
|
||||||
|
class AppreciationBadgeAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for AppreciationBadge"""
|
||||||
|
|
||||||
|
list_display = [
|
||||||
|
'name_en',
|
||||||
|
'code',
|
||||||
|
'criteria_type',
|
||||||
|
'criteria_value',
|
||||||
|
'hospital',
|
||||||
|
'order',
|
||||||
|
'is_active',
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'hospital',
|
||||||
|
'criteria_type',
|
||||||
|
'is_active',
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'name_en',
|
||||||
|
'name_ar',
|
||||||
|
'code',
|
||||||
|
'description_en',
|
||||||
|
]
|
||||||
|
list_editable = ['order', 'is_active']
|
||||||
|
ordering = ['hospital', 'order', 'name_en']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserBadge)
|
||||||
|
class UserBadgeAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for UserBadge"""
|
||||||
|
|
||||||
|
list_display = [
|
||||||
|
'recipient_name_display',
|
||||||
|
'badge',
|
||||||
|
'earned_at',
|
||||||
|
'appreciation_count',
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'badge',
|
||||||
|
'earned_at',
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
'badge__name_en',
|
||||||
|
'badge__name_ar',
|
||||||
|
]
|
||||||
|
date_hierarchy = 'earned_at'
|
||||||
|
readonly_fields = ['earned_at', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def recipient_name_display(self, obj):
|
||||||
|
"""Display recipient name in list"""
|
||||||
|
try:
|
||||||
|
return str(obj.recipient)
|
||||||
|
except:
|
||||||
|
return "Unknown"
|
||||||
|
recipient_name_display.short_description = 'Recipient'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AppreciationStats)
|
||||||
|
class AppreciationStatsAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for AppreciationStats"""
|
||||||
|
|
||||||
|
list_display = [
|
||||||
|
'recipient_name_display',
|
||||||
|
'period_display',
|
||||||
|
'hospital',
|
||||||
|
'department',
|
||||||
|
'received_count',
|
||||||
|
'sent_count',
|
||||||
|
'acknowledged_count',
|
||||||
|
'hospital_rank',
|
||||||
|
'department_rank',
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'hospital',
|
||||||
|
'department',
|
||||||
|
'year',
|
||||||
|
'month',
|
||||||
|
]
|
||||||
|
search_fields = []
|
||||||
|
date_hierarchy = None
|
||||||
|
readonly_fields = [
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
def recipient_name_display(self, obj):
|
||||||
|
"""Display recipient name in list"""
|
||||||
|
try:
|
||||||
|
return str(obj.recipient)
|
||||||
|
except:
|
||||||
|
return "Unknown"
|
||||||
|
recipient_name_display.short_description = 'Recipient'
|
||||||
|
|
||||||
|
def period_display(self, obj):
|
||||||
|
"""Display period in list"""
|
||||||
|
return f"{obj.year}-{obj.month:02d}"
|
||||||
|
period_display.short_description = 'Period'
|
||||||
16
apps/appreciation/apps.py
Normal file
16
apps/appreciation/apps.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Appreciation app configuration
|
||||||
|
"""
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationConfig(AppConfig):
|
||||||
|
"""Configuration for the appreciation app"""
|
||||||
|
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.appreciation'
|
||||||
|
verbose_name = 'Appreciation System'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
"""Import signals when app is ready"""
|
||||||
|
import apps.appreciation.signals
|
||||||
1
apps/appreciation/management/__init__.py
Normal file
1
apps/appreciation/management/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Management module
|
||||||
1
apps/appreciation/management/commands/__init__.py
Normal file
1
apps/appreciation/management/commands/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Commands module
|
||||||
246
apps/appreciation/management/commands/seed_appreciation_data.py
Normal file
246
apps/appreciation/management/commands/seed_appreciation_data.py
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
"""
|
||||||
|
Management command to seed appreciation categories and badges
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python manage.py seed_appreciation_data
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from apps.appreciation.models import (
|
||||||
|
AppreciationBadge,
|
||||||
|
AppreciationCategory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Seed appreciation categories and badges'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write('Seeding appreciation data...')
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Seed categories
|
||||||
|
self.seed_categories()
|
||||||
|
|
||||||
|
# Seed badges
|
||||||
|
self.seed_badges()
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('Successfully seeded appreciation data!'))
|
||||||
|
|
||||||
|
def seed_categories(self):
|
||||||
|
"""Seed appreciation categories"""
|
||||||
|
categories = [
|
||||||
|
{
|
||||||
|
'code': 'excellent_care',
|
||||||
|
'name_en': 'Excellent Care',
|
||||||
|
'name_ar': 'رعاية ممتازة',
|
||||||
|
'description_en': 'For providing exceptional patient care',
|
||||||
|
'description_ar': 'لتقديم رعاية استثنائية للمرضى',
|
||||||
|
'icon': 'fa-heart-pulse',
|
||||||
|
'color': '#FF5733',
|
||||||
|
'order': 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'team_player',
|
||||||
|
'name_en': 'Team Player',
|
||||||
|
'name_ar': 'لاعب فريق',
|
||||||
|
'description_en': 'For outstanding teamwork and collaboration',
|
||||||
|
'description_ar': 'للعمل الجماعي والتعاون المتميز',
|
||||||
|
'icon': 'fa-users',
|
||||||
|
'color': '#33FF57',
|
||||||
|
'order': 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'innovation',
|
||||||
|
'name_en': 'Innovation',
|
||||||
|
'name_ar': 'الابتكار',
|
||||||
|
'description_en': 'For introducing innovative solutions',
|
||||||
|
'description_ar': 'لتقديم حلول مبتكرة',
|
||||||
|
'icon': 'fa-lightbulb',
|
||||||
|
'color': '#F3FF33',
|
||||||
|
'order': 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'leadership',
|
||||||
|
'name_en': 'Leadership',
|
||||||
|
'name_ar': 'القيادة',
|
||||||
|
'description_en': 'For demonstrating exceptional leadership',
|
||||||
|
'description_ar': 'لإظهار قيادة استثنائية',
|
||||||
|
'icon': 'fa-crown',
|
||||||
|
'color': '#FFD700',
|
||||||
|
'order': 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'mentorship',
|
||||||
|
'name_en': 'Mentorship',
|
||||||
|
'name_ar': 'التوجيه',
|
||||||
|
'description_en': 'For guiding and supporting colleagues',
|
||||||
|
'description_ar': 'لإرشاد ودعم الزملاء',
|
||||||
|
'icon': 'fa-hands-holding-child',
|
||||||
|
'color': '#33FFF3',
|
||||||
|
'order': 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'going_extra_mile',
|
||||||
|
'name_en': 'Going the Extra Mile',
|
||||||
|
'name_ar': 'بذل جهد إضافي',
|
||||||
|
'description_en': 'For going above and beyond',
|
||||||
|
'description_ar': 'للتجاوز التوقعات',
|
||||||
|
'icon': 'fa-rocket',
|
||||||
|
'color': '#FF33F3',
|
||||||
|
'order': 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'reliability',
|
||||||
|
'name_en': 'Reliability',
|
||||||
|
'name_ar': 'الموثوقية',
|
||||||
|
'description_en': 'For consistent and dependable performance',
|
||||||
|
'description_ar': 'للأداء المتسابق والموثوق',
|
||||||
|
'icon': 'fa-shield-check',
|
||||||
|
'color': '#3357FF',
|
||||||
|
'order': 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'positive_attitude',
|
||||||
|
'name_en': 'Positive Attitude',
|
||||||
|
'name_ar': 'التفاؤل',
|
||||||
|
'description_en': 'For maintaining a positive outlook',
|
||||||
|
'description_ar': 'للحفاظ على التفاؤل',
|
||||||
|
'icon': 'fa-face-smile',
|
||||||
|
'color': '#FFA500',
|
||||||
|
'order': 8,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for category_data in categories:
|
||||||
|
category, created = AppreciationCategory.objects.get_or_create(
|
||||||
|
code=category_data['code'],
|
||||||
|
hospital=None, # System-wide categories
|
||||||
|
defaults=category_data
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"Created category: {category.name_en}")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f"Category already exists: {category.name_en}")
|
||||||
|
)
|
||||||
|
|
||||||
|
def seed_badges(self):
|
||||||
|
"""Seed appreciation badges"""
|
||||||
|
badges = [
|
||||||
|
{
|
||||||
|
'code': 'first_appreciation',
|
||||||
|
'name_en': 'First Appreciation',
|
||||||
|
'name_ar': 'أول تكريم',
|
||||||
|
'description_en': 'Received your first appreciation',
|
||||||
|
'description_ar': 'حصلت على أول تكريم',
|
||||||
|
'icon': 'fa-star',
|
||||||
|
'color': '#FFD700',
|
||||||
|
'criteria_type': 'received_count',
|
||||||
|
'criteria_value': 1,
|
||||||
|
'order': 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'appreciated_5',
|
||||||
|
'name_en': 'Rising Star',
|
||||||
|
'name_ar': 'نجم صاعد',
|
||||||
|
'description_en': 'Received 5 appreciations',
|
||||||
|
'description_ar': 'حصلت على 5 تكريمات',
|
||||||
|
'icon': 'fa-star-half-stroke',
|
||||||
|
'color': '#C0C0C0',
|
||||||
|
'criteria_type': 'received_count',
|
||||||
|
'criteria_value': 5,
|
||||||
|
'order': 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'appreciated_10',
|
||||||
|
'name_en': 'Shining Star',
|
||||||
|
'name_ar': 'نجم لامع',
|
||||||
|
'description_en': 'Received 10 appreciations',
|
||||||
|
'description_ar': 'حصلت على 10 تكريمات',
|
||||||
|
'icon': 'fa-star',
|
||||||
|
'color': '#FFD700',
|
||||||
|
'criteria_type': 'received_count',
|
||||||
|
'criteria_value': 10,
|
||||||
|
'order': 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'appreciated_25',
|
||||||
|
'name_en': 'Super Star',
|
||||||
|
'name_ar': 'نجم خارق',
|
||||||
|
'description_en': 'Received 25 appreciations',
|
||||||
|
'description_ar': 'حصلت على 25 تكريم',
|
||||||
|
'icon': 'fa-trophy',
|
||||||
|
'color': '#FFA500',
|
||||||
|
'criteria_type': 'received_count',
|
||||||
|
'criteria_value': 25,
|
||||||
|
'order': 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'appreciated_50',
|
||||||
|
'name_en': 'Legendary',
|
||||||
|
'name_ar': 'أسطوري',
|
||||||
|
'description_en': 'Received 50 appreciations',
|
||||||
|
'description_ar': 'حصلت على 50 تكريم',
|
||||||
|
'icon': 'fa-crown',
|
||||||
|
'color': '#FF5733',
|
||||||
|
'criteria_type': 'received_count',
|
||||||
|
'criteria_value': 50,
|
||||||
|
'order': 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'monthly_champion',
|
||||||
|
'name_en': 'Monthly Champion',
|
||||||
|
'name_ar': 'بطل الشهر',
|
||||||
|
'description_en': 'Received 10 appreciations in a month',
|
||||||
|
'description_ar': 'حصلت على 10 تكريمات في شهر واحد',
|
||||||
|
'icon': 'fa-medal',
|
||||||
|
'color': '#33FF57',
|
||||||
|
'criteria_type': 'received_month',
|
||||||
|
'criteria_value': 10,
|
||||||
|
'order': 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'streak_4_weeks',
|
||||||
|
'name_en': 'Consistent',
|
||||||
|
'name_ar': 'مستمر',
|
||||||
|
'description_en': 'Received appreciations for 4 consecutive weeks',
|
||||||
|
'description_ar': 'حصلت على تكريمات لمدة 4 أسابيع متتالية',
|
||||||
|
'icon': 'fa-fire',
|
||||||
|
'color': '#FF4500',
|
||||||
|
'criteria_type': 'streak_weeks',
|
||||||
|
'criteria_value': 4,
|
||||||
|
'order': 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'diverse_appreciation',
|
||||||
|
'name_en': 'Well-Loved',
|
||||||
|
'name_ar': 'محبوب',
|
||||||
|
'description_en': 'Received appreciations from 10 different people',
|
||||||
|
'description_ar': 'حصلت على تكريمات من 10 أشخاص مختلفين',
|
||||||
|
'icon': 'fa-heart',
|
||||||
|
'color': '#FF1493',
|
||||||
|
'criteria_type': 'diverse_senders',
|
||||||
|
'criteria_value': 10,
|
||||||
|
'order': 8,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for badge_data in badges:
|
||||||
|
badge, created = AppreciationBadge.objects.get_or_create(
|
||||||
|
code=badge_data['code'],
|
||||||
|
defaults=badge_data
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"Created badge: {badge.name_en}")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f"Badge already exists: {badge.name_en}")
|
||||||
|
)
|
||||||
185
apps/appreciation/migrations/0001_initial.py
Normal file
185
apps/appreciation/migrations/0001_initial.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
# Generated by Django 5.0.14 on 2026-01-01 11:27
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('organizations', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AppreciationBadge',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('code', models.CharField(help_text='Unique badge code', max_length=50, unique=True)),
|
||||||
|
('name_en', models.CharField(max_length=200)),
|
||||||
|
('name_ar', models.CharField(blank=True, max_length=200)),
|
||||||
|
('description_en', models.TextField(blank=True)),
|
||||||
|
('description_ar', models.TextField(blank=True)),
|
||||||
|
('icon', models.CharField(blank=True, help_text='Icon class', max_length=50)),
|
||||||
|
('color', models.CharField(blank=True, help_text='Hex color code', max_length=7)),
|
||||||
|
('criteria_type', models.CharField(choices=[('received_count', 'Total Appreciations Received'), ('received_month', 'Appreciations Received in a Month'), ('streak_weeks', 'Consecutive Weeks with Appreciation'), ('diverse_senders', 'Appreciations from Different Senders')], db_index=True, max_length=50)),
|
||||||
|
('criteria_value', models.IntegerField(help_text='Value to achieve (e.g., 10 for 10 appreciations)')),
|
||||||
|
('order', models.IntegerField(default=0)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('hospital', models.ForeignKey(blank=True, help_text='Leave blank for system-wide badges', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='appreciation_badges', to='organizations.hospital')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['hospital', 'order', 'name_en'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AppreciationCategory',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('code', models.CharField(help_text='Unique code for this category', max_length=50)),
|
||||||
|
('name_en', models.CharField(max_length=200)),
|
||||||
|
('name_ar', models.CharField(blank=True, max_length=200)),
|
||||||
|
('description_en', models.TextField(blank=True)),
|
||||||
|
('description_ar', models.TextField(blank=True)),
|
||||||
|
('icon', models.CharField(blank=True, help_text="Icon class (e.g., 'fa-heart', 'fa-star')", max_length=50)),
|
||||||
|
('color', models.CharField(blank=True, help_text="Hex color code (e.g., '#FF5733')", max_length=7)),
|
||||||
|
('order', models.IntegerField(default=0, help_text='Display order')),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('hospital', models.ForeignKey(blank=True, help_text='Leave blank for system-wide categories', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='appreciation_categories', to='organizations.hospital')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'Appreciation Categories',
|
||||||
|
'ordering': ['hospital', 'order', 'name_en'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Appreciation',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('recipient_object_id', models.UUIDField(blank=True, null=True)),
|
||||||
|
('message_en', models.TextField()),
|
||||||
|
('message_ar', models.TextField(blank=True)),
|
||||||
|
('visibility', models.CharField(choices=[('private', 'Private'), ('department', 'Department'), ('hospital', 'Hospital'), ('public', 'Public')], db_index=True, default='private', max_length=20)),
|
||||||
|
('status', models.CharField(choices=[('draft', 'Draft'), ('sent', 'Sent'), ('acknowledged', 'Acknowledged')], db_index=True, default='draft', max_length=20)),
|
||||||
|
('is_anonymous', models.BooleanField(default=False, help_text='Hide sender identity from recipient')),
|
||||||
|
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('acknowledged_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('notification_sent', models.BooleanField(default=False)),
|
||||||
|
('notification_sent_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('metadata', models.JSONField(blank=True, default=dict)),
|
||||||
|
('department', models.ForeignKey(blank=True, help_text='Department context (if applicable)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.department')),
|
||||||
|
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='appreciations', to='organizations.hospital')),
|
||||||
|
('recipient_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciation_recipients', to='contenttypes.contenttype')),
|
||||||
|
('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_appreciations', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='appreciation.appreciationcategory')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AppreciationStats',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('recipient_object_id', models.UUIDField(blank=True, null=True)),
|
||||||
|
('year', models.IntegerField(db_index=True)),
|
||||||
|
('month', models.IntegerField(db_index=True, help_text='1-12')),
|
||||||
|
('received_count', models.IntegerField(default=0)),
|
||||||
|
('sent_count', models.IntegerField(default=0)),
|
||||||
|
('acknowledged_count', models.IntegerField(default=0)),
|
||||||
|
('hospital_rank', models.IntegerField(blank=True, help_text='Rank within hospital', null=True)),
|
||||||
|
('department_rank', models.IntegerField(blank=True, help_text='Rank within department', null=True)),
|
||||||
|
('category_breakdown', models.JSONField(blank=True, default=dict, help_text='Breakdown by category ID and count')),
|
||||||
|
('metadata', models.JSONField(blank=True, default=dict)),
|
||||||
|
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciation_stats', to='organizations.department')),
|
||||||
|
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='appreciation_stats', to='organizations.hospital')),
|
||||||
|
('recipient_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciation_stats_recipients', to='contenttypes.contenttype')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-year', '-month', '-received_count'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserBadge',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('recipient_object_id', models.UUIDField(blank=True, null=True)),
|
||||||
|
('earned_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('appreciation_count', models.IntegerField(help_text='Count when badge was earned')),
|
||||||
|
('metadata', models.JSONField(blank=True, default=dict)),
|
||||||
|
('badge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='earned_by', to='appreciation.appreciationbadge')),
|
||||||
|
('recipient_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='earned_badges_recipients', to='contenttypes.contenttype')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-earned_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='appreciationbadge',
|
||||||
|
index=models.Index(fields=['hospital', 'is_active'], name='appreciatio_hospita_3847f7_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='appreciationbadge',
|
||||||
|
index=models.Index(fields=['code'], name='appreciatio_code_416153_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='appreciationcategory',
|
||||||
|
index=models.Index(fields=['hospital', 'is_active'], name='appreciatio_hospita_b8e413_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='appreciationcategory',
|
||||||
|
index=models.Index(fields=['code'], name='appreciatio_code_50215a_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='appreciationcategory',
|
||||||
|
unique_together={('hospital', 'code')},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='appreciation',
|
||||||
|
index=models.Index(fields=['status', '-created_at'], name='appreciatio_status_24158d_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='appreciation',
|
||||||
|
index=models.Index(fields=['hospital', 'status'], name='appreciatio_hospita_db3f34_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='appreciation',
|
||||||
|
index=models.Index(fields=['recipient_content_type', 'recipient_object_id', '-created_at'], name='appreciatio_recipie_71ef0e_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='appreciation',
|
||||||
|
index=models.Index(fields=['visibility', '-created_at'], name='appreciatio_visibil_ed96d9_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='appreciationstats',
|
||||||
|
index=models.Index(fields=['hospital', 'year', 'month', '-received_count'], name='appreciatio_hospita_a0d454_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='appreciationstats',
|
||||||
|
index=models.Index(fields=['department', 'year', 'month', '-received_count'], name='appreciatio_departm_f68345_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='appreciationstats',
|
||||||
|
unique_together={('recipient_content_type', 'recipient_object_id', 'year', 'month')},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='userbadge',
|
||||||
|
index=models.Index(fields=['recipient_content_type', 'recipient_object_id', '-earned_at'], name='appreciatio_recipie_fc90c8_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
1
apps/appreciation/migrations/__init__.py
Normal file
1
apps/appreciation/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Migrations module
|
||||||
466
apps/appreciation/models.py
Normal file
466
apps/appreciation/models.py
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
"""
|
||||||
|
Appreciation models - Send and track appreciation to users and physicians
|
||||||
|
|
||||||
|
This module implements the appreciation system that:
|
||||||
|
- Allows sending appreciation messages to users and physicians
|
||||||
|
- Tracks appreciation statistics and leaderboards
|
||||||
|
- Manages appreciation categories and badges
|
||||||
|
- Integrates with the notification system
|
||||||
|
"""
|
||||||
|
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 AppreciationStatus(models.TextChoices):
|
||||||
|
"""Appreciation status choices"""
|
||||||
|
DRAFT = 'draft', 'Draft'
|
||||||
|
SENT = 'sent', 'Sent'
|
||||||
|
ACKNOWLEDGED = 'acknowledged', 'Acknowledged'
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationVisibility(models.TextChoices):
|
||||||
|
"""Appreciation visibility choices"""
|
||||||
|
PRIVATE = 'private', 'Private'
|
||||||
|
DEPARTMENT = 'department', 'Department'
|
||||||
|
HOSPITAL = 'hospital', 'Hospital'
|
||||||
|
PUBLIC = 'public', 'Public'
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationCategory(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Appreciation category with bilingual support.
|
||||||
|
|
||||||
|
Pre-defined categories for sending appreciation (e.g., "Excellent Care", "Team Player").
|
||||||
|
Can be hospital-specific or system-wide.
|
||||||
|
"""
|
||||||
|
# Organization
|
||||||
|
hospital = models.ForeignKey(
|
||||||
|
'organizations.Hospital',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='appreciation_categories',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Leave blank for system-wide categories"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Category details
|
||||||
|
code = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
help_text="Unique code for this category"
|
||||||
|
)
|
||||||
|
name_en = models.CharField(max_length=200)
|
||||||
|
name_ar = models.CharField(max_length=200, blank=True)
|
||||||
|
|
||||||
|
description_en = models.TextField(blank=True)
|
||||||
|
description_ar = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# Visual elements
|
||||||
|
icon = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
help_text="Icon class (e.g., 'fa-heart', 'fa-star')"
|
||||||
|
)
|
||||||
|
color = models.CharField(
|
||||||
|
max_length=7,
|
||||||
|
blank=True,
|
||||||
|
help_text="Hex color code (e.g., '#FF5733')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display order
|
||||||
|
order = models.IntegerField(default=0, help_text="Display order")
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['hospital', 'order', 'name_en']
|
||||||
|
unique_together = [['hospital', 'code']]
|
||||||
|
verbose_name_plural = 'Appreciation Categories'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['hospital', 'is_active']),
|
||||||
|
models.Index(fields=['code']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
hospital_name = self.hospital.name if self.hospital else "System-wide"
|
||||||
|
return f"{hospital_name} - {self.name_en}"
|
||||||
|
|
||||||
|
|
||||||
|
class Appreciation(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Appreciation model for sending appreciation to users and physicians.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. DRAFT - Created but not yet sent
|
||||||
|
2. SENT - Sent to recipient (triggers notification)
|
||||||
|
3. ACKNOWLEDGED - Recipient has acknowledged/thanked sender
|
||||||
|
|
||||||
|
Visibility:
|
||||||
|
- PRIVATE - Only sender and recipient can view
|
||||||
|
- DEPARTMENT - Visible to department members
|
||||||
|
- HOSPITAL - Visible to all hospital staff
|
||||||
|
- PUBLIC - Can be displayed publicly (e.g., on appreciation wall)
|
||||||
|
"""
|
||||||
|
# Sender
|
||||||
|
sender = models.ForeignKey(
|
||||||
|
'accounts.User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='sent_appreciations'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recipient (Generic FK to User or Physician)
|
||||||
|
recipient_content_type = models.ForeignKey(
|
||||||
|
ContentType,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='appreciation_recipients'
|
||||||
|
)
|
||||||
|
recipient_object_id = models.UUIDField(null=True, blank=True)
|
||||||
|
recipient = GenericForeignKey(
|
||||||
|
'recipient_content_type',
|
||||||
|
'recipient_object_id'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Organization context
|
||||||
|
hospital = models.ForeignKey(
|
||||||
|
'organizations.Hospital',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='appreciations'
|
||||||
|
)
|
||||||
|
department = models.ForeignKey(
|
||||||
|
'organizations.Department',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='appreciations',
|
||||||
|
help_text="Department context (if applicable)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Category and message
|
||||||
|
category = models.ForeignKey(
|
||||||
|
AppreciationCategory,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='appreciations'
|
||||||
|
)
|
||||||
|
message_en = models.TextField()
|
||||||
|
message_ar = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# Visibility and status
|
||||||
|
visibility = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=AppreciationVisibility.choices,
|
||||||
|
default=AppreciationVisibility.PRIVATE,
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=AppreciationStatus.choices,
|
||||||
|
default=AppreciationStatus.DRAFT,
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Anonymous option
|
||||||
|
is_anonymous = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Hide sender identity from recipient"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
acknowledged_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Notification tracking
|
||||||
|
notification_sent = models.BooleanField(default=False)
|
||||||
|
notification_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status', '-created_at']),
|
||||||
|
models.Index(fields=['hospital', 'status']),
|
||||||
|
models.Index(fields=['recipient_content_type', 'recipient_object_id', '-created_at']),
|
||||||
|
models.Index(fields=['visibility', '-created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
recipient_name = self.get_recipient_name()
|
||||||
|
return f"Appreciation to {recipient_name} ({self.status})"
|
||||||
|
|
||||||
|
def get_recipient_name(self):
|
||||||
|
"""Get recipient's name"""
|
||||||
|
try:
|
||||||
|
return str(self.recipient)
|
||||||
|
except:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def get_recipient_email(self):
|
||||||
|
"""Get recipient's email"""
|
||||||
|
try:
|
||||||
|
if hasattr(self.recipient, 'email'):
|
||||||
|
return self.recipient.email
|
||||||
|
elif hasattr(self.recipient, 'user'):
|
||||||
|
return self.recipient.user.email
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_recipient_phone(self):
|
||||||
|
"""Get recipient's phone"""
|
||||||
|
try:
|
||||||
|
if hasattr(self.recipient, 'phone'):
|
||||||
|
return self.recipient.phone
|
||||||
|
elif hasattr(self.recipient, 'user'):
|
||||||
|
return self.recipient.user.phone
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send(self):
|
||||||
|
"""Send appreciation and trigger notification"""
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
self.status = AppreciationStatus.SENT
|
||||||
|
self.sent_at = timezone.now()
|
||||||
|
self.save(update_fields=['status', 'sent_at'])
|
||||||
|
|
||||||
|
# Trigger notification
|
||||||
|
self.send_notification()
|
||||||
|
|
||||||
|
def acknowledge(self):
|
||||||
|
"""Mark appreciation as acknowledged"""
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
self.status = AppreciationStatus.ACKNOWLEDGED
|
||||||
|
self.acknowledged_at = timezone.now()
|
||||||
|
self.save(update_fields=['status', 'acknowledged_at'])
|
||||||
|
|
||||||
|
def send_notification(self):
|
||||||
|
"""Send notification to recipient"""
|
||||||
|
# This will be implemented in signals.py
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationBadge(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Appreciation badge for gamification.
|
||||||
|
|
||||||
|
Badges are awarded based on achievements (e.g., "10 Appreciations Received").
|
||||||
|
"""
|
||||||
|
# Organization
|
||||||
|
hospital = models.ForeignKey(
|
||||||
|
'organizations.Hospital',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='appreciation_badges',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Leave blank for system-wide badges"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Badge details
|
||||||
|
code = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
unique=True,
|
||||||
|
help_text="Unique badge code"
|
||||||
|
)
|
||||||
|
name_en = models.CharField(max_length=200)
|
||||||
|
name_ar = models.CharField(max_length=200, blank=True)
|
||||||
|
description_en = models.TextField(blank=True)
|
||||||
|
description_ar = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# Visual elements
|
||||||
|
icon = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
help_text="Icon class"
|
||||||
|
)
|
||||||
|
color = models.CharField(
|
||||||
|
max_length=7,
|
||||||
|
blank=True,
|
||||||
|
help_text="Hex color code"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Achievement criteria
|
||||||
|
criteria_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=[
|
||||||
|
('received_count', 'Total Appreciations Received'),
|
||||||
|
('received_month', 'Appreciations Received in a Month'),
|
||||||
|
('streak_weeks', 'Consecutive Weeks with Appreciation'),
|
||||||
|
('diverse_senders', 'Appreciations from Different Senders'),
|
||||||
|
],
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
criteria_value = models.IntegerField(
|
||||||
|
help_text="Value to achieve (e.g., 10 for 10 appreciations)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display order
|
||||||
|
order = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['hospital', 'order', 'name_en']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['hospital', 'is_active']),
|
||||||
|
models.Index(fields=['code']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
hospital_name = self.hospital.name if self.hospital else "System-wide"
|
||||||
|
return f"{hospital_name} - {self.name_en}"
|
||||||
|
|
||||||
|
|
||||||
|
class UserBadge(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
User badge - tracks badges earned by users.
|
||||||
|
|
||||||
|
Links users (or physicians) to badges they've earned.
|
||||||
|
"""
|
||||||
|
# Recipient (Generic FK to User or Physician)
|
||||||
|
recipient_content_type = models.ForeignKey(
|
||||||
|
ContentType,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='earned_badges_recipients'
|
||||||
|
)
|
||||||
|
recipient_object_id = models.UUIDField(null=True, blank=True)
|
||||||
|
recipient = GenericForeignKey(
|
||||||
|
'recipient_content_type',
|
||||||
|
'recipient_object_id'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Badge
|
||||||
|
badge = models.ForeignKey(
|
||||||
|
AppreciationBadge,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='earned_by'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Achievement details
|
||||||
|
earned_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
# Context
|
||||||
|
appreciation_count = models.IntegerField(
|
||||||
|
help_text="Count when badge was earned"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-earned_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['recipient_content_type', 'recipient_object_id', '-earned_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
recipient_name = self.get_recipient_name()
|
||||||
|
return f"{recipient_name} - {self.badge.name_en}"
|
||||||
|
|
||||||
|
def get_recipient_name(self):
|
||||||
|
"""Get recipient's name"""
|
||||||
|
try:
|
||||||
|
return str(self.recipient)
|
||||||
|
except:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationStats(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Appreciation statistics - aggregated monthly.
|
||||||
|
|
||||||
|
Provides monthly statistics for users and physicians.
|
||||||
|
"""
|
||||||
|
# Recipient (Generic FK to User or Physician)
|
||||||
|
recipient_content_type = models.ForeignKey(
|
||||||
|
ContentType,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='appreciation_stats_recipients'
|
||||||
|
)
|
||||||
|
recipient_object_id = models.UUIDField(null=True, blank=True)
|
||||||
|
recipient = GenericForeignKey(
|
||||||
|
'recipient_content_type',
|
||||||
|
'recipient_object_id'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Time period
|
||||||
|
year = models.IntegerField(db_index=True)
|
||||||
|
month = models.IntegerField(db_index=True, help_text="1-12")
|
||||||
|
|
||||||
|
# Organization
|
||||||
|
hospital = models.ForeignKey(
|
||||||
|
'organizations.Hospital',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='appreciation_stats'
|
||||||
|
)
|
||||||
|
department = models.ForeignKey(
|
||||||
|
'organizations.Department',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='appreciation_stats'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
received_count = models.IntegerField(default=0)
|
||||||
|
sent_count = models.IntegerField(default=0)
|
||||||
|
acknowledged_count = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
# Ranking
|
||||||
|
hospital_rank = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Rank within hospital"
|
||||||
|
)
|
||||||
|
department_rank = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Rank within department"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Breakdown by category
|
||||||
|
category_breakdown = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
help_text="Breakdown by category ID and count"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-year', '-month', '-received_count']
|
||||||
|
unique_together = [
|
||||||
|
['recipient_content_type', 'recipient_object_id', 'year', 'month']
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['hospital', 'year', 'month', '-received_count']),
|
||||||
|
models.Index(fields=['department', 'year', 'month', '-received_count']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
recipient_name = self.get_recipient_name()
|
||||||
|
return f"{recipient_name} - {self.year}-{self.month:02d}: {self.received_count} received"
|
||||||
|
|
||||||
|
def get_recipient_name(self):
|
||||||
|
"""Get recipient's name"""
|
||||||
|
try:
|
||||||
|
return str(self.recipient)
|
||||||
|
except:
|
||||||
|
return "Unknown"
|
||||||
361
apps/appreciation/serializers.py
Normal file
361
apps/appreciation/serializers.py
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
"""
|
||||||
|
Appreciation serializers - API serializers for appreciation models
|
||||||
|
"""
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.appreciation.models import (
|
||||||
|
Appreciation,
|
||||||
|
AppreciationBadge,
|
||||||
|
AppreciationCategory,
|
||||||
|
AppreciationStatus,
|
||||||
|
AppreciationVisibility,
|
||||||
|
AppreciationStats,
|
||||||
|
UserBadge,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationCategorySerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for AppreciationCategory"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = AppreciationCategory
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'code',
|
||||||
|
'name_en',
|
||||||
|
'name_ar',
|
||||||
|
'description_en',
|
||||||
|
'description_ar',
|
||||||
|
'icon',
|
||||||
|
'color',
|
||||||
|
'order',
|
||||||
|
'is_active',
|
||||||
|
'hospital',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for Appreciation"""
|
||||||
|
|
||||||
|
sender_name = serializers.SerializerMethodField()
|
||||||
|
recipient_name = serializers.SerializerMethodField()
|
||||||
|
recipient_type = serializers.SerializerMethodField()
|
||||||
|
recipient_id = serializers.SerializerMethodField()
|
||||||
|
category_name = serializers.SerializerMethodField()
|
||||||
|
message = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Appreciation
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'sender',
|
||||||
|
'sender_name',
|
||||||
|
'recipient',
|
||||||
|
'recipient_type',
|
||||||
|
'recipient_id',
|
||||||
|
'recipient_name',
|
||||||
|
'hospital',
|
||||||
|
'department',
|
||||||
|
'category',
|
||||||
|
'category_name',
|
||||||
|
'message_en',
|
||||||
|
'message_ar',
|
||||||
|
'message',
|
||||||
|
'visibility',
|
||||||
|
'status',
|
||||||
|
'is_anonymous',
|
||||||
|
'sent_at',
|
||||||
|
'acknowledged_at',
|
||||||
|
'notification_sent',
|
||||||
|
'notification_sent_at',
|
||||||
|
'metadata',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
'id',
|
||||||
|
'sender_name',
|
||||||
|
'recipient_name',
|
||||||
|
'recipient_type',
|
||||||
|
'recipient_id',
|
||||||
|
'category_name',
|
||||||
|
'message',
|
||||||
|
'sent_at',
|
||||||
|
'acknowledged_at',
|
||||||
|
'notification_sent',
|
||||||
|
'notification_sent_at',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_sender_name(self, obj):
|
||||||
|
"""Get sender's name"""
|
||||||
|
if obj.is_anonymous:
|
||||||
|
return "Anonymous"
|
||||||
|
if obj.sender:
|
||||||
|
return obj.sender.get_full_name()
|
||||||
|
return "System"
|
||||||
|
|
||||||
|
def get_recipient_name(self, obj):
|
||||||
|
"""Get recipient's name"""
|
||||||
|
try:
|
||||||
|
return str(obj.recipient)
|
||||||
|
except:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def get_recipient_type(self, obj):
|
||||||
|
"""Get recipient type (user or physician)"""
|
||||||
|
if obj.recipient_content_type:
|
||||||
|
return obj.recipient_content_type.model
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_recipient_id(self, obj):
|
||||||
|
"""Get recipient object ID"""
|
||||||
|
return obj.recipient_object_id
|
||||||
|
|
||||||
|
def get_category_name(self, obj):
|
||||||
|
"""Get category name"""
|
||||||
|
if obj.category:
|
||||||
|
return obj.category.name_en
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_message(self, obj):
|
||||||
|
"""Get message based on language"""
|
||||||
|
# This should use the request's language context
|
||||||
|
# For now, default to English
|
||||||
|
return obj.message_en
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationCreateSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for creating Appreciation"""
|
||||||
|
|
||||||
|
recipient_type = serializers.ChoiceField(
|
||||||
|
choices=['user', 'physician'],
|
||||||
|
write_only=True,
|
||||||
|
help_text="Type of recipient: 'user' or 'physician'"
|
||||||
|
)
|
||||||
|
recipient_id = serializers.UUIDField(write_only=True)
|
||||||
|
category_id = serializers.UUIDField(required=False, allow_null=True)
|
||||||
|
message_en = serializers.CharField(required=True)
|
||||||
|
message_ar = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
visibility = serializers.ChoiceField(
|
||||||
|
choices=AppreciationVisibility.choices,
|
||||||
|
default=AppreciationVisibility.PRIVATE
|
||||||
|
)
|
||||||
|
is_anonymous = serializers.BooleanField(default=False)
|
||||||
|
hospital_id = serializers.UUIDField(required=True)
|
||||||
|
department_id = serializers.UUIDField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
def validate_recipient_id(self, value):
|
||||||
|
"""Validate recipient ID"""
|
||||||
|
# Will be validated in validate method
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
"""Validate the entire data"""
|
||||||
|
recipient_type = data.get('recipient_type')
|
||||||
|
recipient_id = data.get('recipient_id')
|
||||||
|
hospital_id = data.get('hospital_id')
|
||||||
|
|
||||||
|
# Validate recipient exists
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
|
if recipient_type == 'user':
|
||||||
|
from apps.accounts.models import User
|
||||||
|
try:
|
||||||
|
user = User.objects.get(id=recipient_id)
|
||||||
|
# Check if user belongs to hospital
|
||||||
|
if user.hospital_id != hospital_id:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'recipient_id': 'User does not belong to specified hospital'
|
||||||
|
})
|
||||||
|
except User.DoesNotExist:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'recipient_id': 'User not found'
|
||||||
|
})
|
||||||
|
elif recipient_type == 'physician':
|
||||||
|
from apps.organizations.models import Physician
|
||||||
|
try:
|
||||||
|
physician = Physician.objects.get(id=recipient_id)
|
||||||
|
if physician.hospital_id != hospital_id:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'recipient_id': 'Physician does not belong to specified hospital'
|
||||||
|
})
|
||||||
|
except Physician.DoesNotExist:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'recipient_id': 'Physician not found'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Validate category if provided
|
||||||
|
category_id = data.get('category_id')
|
||||||
|
if category_id:
|
||||||
|
try:
|
||||||
|
category = AppreciationCategory.objects.get(id=category_id)
|
||||||
|
if category.hospital_id and category.hospital_id != hospital_id:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'category_id': 'Category does not belong to specified hospital'
|
||||||
|
})
|
||||||
|
except AppreciationCategory.DoesNotExist:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'category_id': 'Category not found'
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationBadgeSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for AppreciationBadge"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = AppreciationBadge
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'code',
|
||||||
|
'name_en',
|
||||||
|
'name_ar',
|
||||||
|
'description_en',
|
||||||
|
'description_ar',
|
||||||
|
'icon',
|
||||||
|
'color',
|
||||||
|
'criteria_type',
|
||||||
|
'criteria_value',
|
||||||
|
'order',
|
||||||
|
'is_active',
|
||||||
|
'hospital',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class UserBadgeSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for UserBadge"""
|
||||||
|
|
||||||
|
recipient_name = serializers.SerializerMethodField()
|
||||||
|
recipient_type = serializers.SerializerMethodField()
|
||||||
|
badge_details = AppreciationBadgeSerializer(source='badge', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserBadge
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'recipient',
|
||||||
|
'recipient_name',
|
||||||
|
'recipient_type',
|
||||||
|
'badge',
|
||||||
|
'badge_details',
|
||||||
|
'earned_at',
|
||||||
|
'appreciation_count',
|
||||||
|
'metadata',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
'id',
|
||||||
|
'recipient_name',
|
||||||
|
'recipient_type',
|
||||||
|
'earned_at',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_recipient_name(self, obj):
|
||||||
|
"""Get recipient's name"""
|
||||||
|
try:
|
||||||
|
return str(obj.recipient)
|
||||||
|
except:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def get_recipient_type(self, obj):
|
||||||
|
"""Get recipient type"""
|
||||||
|
if obj.recipient_content_type:
|
||||||
|
return obj.recipient_content_type.model
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationStatsSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for AppreciationStats"""
|
||||||
|
|
||||||
|
recipient_name = serializers.SerializerMethodField()
|
||||||
|
recipient_type = serializers.SerializerMethodField()
|
||||||
|
period_display = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = AppreciationStats
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'recipient',
|
||||||
|
'recipient_name',
|
||||||
|
'recipient_type',
|
||||||
|
'year',
|
||||||
|
'month',
|
||||||
|
'period_display',
|
||||||
|
'hospital',
|
||||||
|
'department',
|
||||||
|
'received_count',
|
||||||
|
'sent_count',
|
||||||
|
'acknowledged_count',
|
||||||
|
'hospital_rank',
|
||||||
|
'department_rank',
|
||||||
|
'category_breakdown',
|
||||||
|
'metadata',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
'id',
|
||||||
|
'recipient_name',
|
||||||
|
'recipient_type',
|
||||||
|
'period_display',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_recipient_name(self, obj):
|
||||||
|
"""Get recipient's name"""
|
||||||
|
try:
|
||||||
|
return str(obj.recipient)
|
||||||
|
except:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def get_recipient_type(self, obj):
|
||||||
|
"""Get recipient type"""
|
||||||
|
if obj.recipient_content_type:
|
||||||
|
return obj.recipient_content_type.model
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_period_display(self, obj):
|
||||||
|
"""Get formatted period display"""
|
||||||
|
return f"{obj.year}-{obj.month:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationLeaderboardSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for appreciation leaderboard"""
|
||||||
|
|
||||||
|
recipient_type = serializers.CharField()
|
||||||
|
recipient_id = serializers.UUIDField()
|
||||||
|
recipient_name = serializers.CharField()
|
||||||
|
hospital = serializers.CharField()
|
||||||
|
department = serializers.CharField(required=False, allow_null=True)
|
||||||
|
received_count = serializers.IntegerField()
|
||||||
|
rank = serializers.IntegerField()
|
||||||
|
badges = serializers.ListField(
|
||||||
|
child=serializers.DictField(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationSummarySerializer(serializers.Serializer):
|
||||||
|
"""Serializer for appreciation summary statistics"""
|
||||||
|
|
||||||
|
total_received = serializers.IntegerField()
|
||||||
|
total_sent = serializers.IntegerField()
|
||||||
|
this_month_received = serializers.IntegerField()
|
||||||
|
this_month_sent = serializers.IntegerField()
|
||||||
|
top_category = serializers.DictField(required=False, allow_null=True)
|
||||||
|
badges_earned = serializers.IntegerField()
|
||||||
|
hospital_rank = serializers.IntegerField(required=False, allow_null=True)
|
||||||
|
department_rank = serializers.IntegerField(required=False, allow_null=True)
|
||||||
348
apps/appreciation/signals.py
Normal file
348
apps/appreciation/signals.py
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
"""
|
||||||
|
Appreciation signals - Signal handlers for appreciation events
|
||||||
|
|
||||||
|
This module handles:
|
||||||
|
- Sending notifications when appreciations are sent
|
||||||
|
- Updating statistics when appreciations are created
|
||||||
|
- Checking and awarding badges
|
||||||
|
"""
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.appreciation.models import (
|
||||||
|
Appreciation,
|
||||||
|
AppreciationBadge,
|
||||||
|
AppreciationStats,
|
||||||
|
AppreciationStatus,
|
||||||
|
UserBadge,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Appreciation)
|
||||||
|
def handle_appreciation_sent(sender, instance, created, **kwargs):
|
||||||
|
"""
|
||||||
|
Handle appreciation sent events.
|
||||||
|
|
||||||
|
This signal triggers:
|
||||||
|
1. Send notification to recipient
|
||||||
|
2. Update statistics
|
||||||
|
3. Check for badge awards
|
||||||
|
"""
|
||||||
|
# Only process when appreciation is sent (status changes to 'sent')
|
||||||
|
if instance.status == AppreciationStatus.SENT and not instance.notification_sent:
|
||||||
|
# Send notification
|
||||||
|
send_appreciation_notification(instance)
|
||||||
|
|
||||||
|
# Update statistics
|
||||||
|
update_appreciation_stats(instance)
|
||||||
|
|
||||||
|
# Check for badge awards
|
||||||
|
check_and_award_badges(instance)
|
||||||
|
|
||||||
|
|
||||||
|
def send_appreciation_notification(appreciation):
|
||||||
|
"""
|
||||||
|
Send notification to recipient when appreciation is sent.
|
||||||
|
|
||||||
|
Uses the notification system to send email/SMS/WhatsApp.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from apps.notifications.models import NotificationLog, NotificationChannel, NotificationStatus
|
||||||
|
from apps.notifications.services import send_notification
|
||||||
|
|
||||||
|
# Get recipient details
|
||||||
|
recipient_email = appreciation.get_recipient_email()
|
||||||
|
recipient_phone = appreciation.get_recipient_phone()
|
||||||
|
|
||||||
|
# Get sender name
|
||||||
|
sender_name = "Anonymous" if appreciation.is_anonymous else appreciation.sender.get_full_name()
|
||||||
|
|
||||||
|
# Build message
|
||||||
|
message_en = f"You've received an appreciation from {sender_name}!"
|
||||||
|
message_ar = f"لقد تلقيت تكريماً من {sender_name}!"
|
||||||
|
|
||||||
|
if appreciation.category:
|
||||||
|
message_en += f"\n\nCategory: {appreciation.category.name_en}"
|
||||||
|
message_ar += f"\n\nالفئة: {appreciation.category.name_ar}"
|
||||||
|
|
||||||
|
message_en += f"\n\nMessage: {appreciation.message_en}"
|
||||||
|
message_ar += f"\n\nالرسالة: {appreciation.message_ar}"
|
||||||
|
|
||||||
|
# Send email if available
|
||||||
|
if recipient_email:
|
||||||
|
try:
|
||||||
|
send_notification(
|
||||||
|
channel=NotificationChannel.EMAIL,
|
||||||
|
recipient=recipient_email,
|
||||||
|
subject=f"New Appreciation Received - {appreciation.hospital.name}",
|
||||||
|
message=message_en,
|
||||||
|
content_object=appreciation,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't fail
|
||||||
|
print(f"Failed to send appreciation email: {e}")
|
||||||
|
|
||||||
|
# Send SMS if available
|
||||||
|
if recipient_phone:
|
||||||
|
try:
|
||||||
|
send_notification(
|
||||||
|
channel=NotificationChannel.SMS,
|
||||||
|
recipient=recipient_phone,
|
||||||
|
message=message_en,
|
||||||
|
content_object=appreciation,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't fail
|
||||||
|
print(f"Failed to send appreciation SMS: {e}")
|
||||||
|
|
||||||
|
except ImportError as e:
|
||||||
|
# Notification service not available - skip notification
|
||||||
|
print(f"Notification service not available: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
# Any other error - log but don't fail
|
||||||
|
print(f"Error sending appreciation notification: {e}")
|
||||||
|
|
||||||
|
# Mark notification as sent (even if notification failed)
|
||||||
|
appreciation.notification_sent = True
|
||||||
|
appreciation.notification_sent_at = timezone.now()
|
||||||
|
appreciation.save(update_fields=['notification_sent', 'notification_sent_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def update_appreciation_stats(instance):
|
||||||
|
"""
|
||||||
|
Update appreciation statistics for the recipient.
|
||||||
|
|
||||||
|
Creates or updates monthly statistics.
|
||||||
|
"""
|
||||||
|
# Get current year and month
|
||||||
|
now = timezone.now()
|
||||||
|
year = now.year
|
||||||
|
month = now.month
|
||||||
|
|
||||||
|
# Get or create stats record
|
||||||
|
stats, created = AppreciationStats.objects.get_or_create(
|
||||||
|
recipient_content_type=instance.recipient_content_type,
|
||||||
|
recipient_object_id=instance.recipient_object_id,
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
defaults={
|
||||||
|
'hospital': instance.hospital,
|
||||||
|
'department': instance.department,
|
||||||
|
'received_count': 0,
|
||||||
|
'sent_count': 0,
|
||||||
|
'acknowledged_count': 0,
|
||||||
|
'category_breakdown': {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update received count
|
||||||
|
stats.received_count += 1
|
||||||
|
|
||||||
|
# Update category breakdown
|
||||||
|
if instance.category:
|
||||||
|
category_breakdown = stats.category_breakdown or {}
|
||||||
|
category_id_str = str(instance.category.id)
|
||||||
|
category_breakdown[category_id_str] = category_breakdown.get(category_id_str, 0) + 1
|
||||||
|
stats.category_breakdown = category_breakdown
|
||||||
|
|
||||||
|
# Save stats
|
||||||
|
stats.save()
|
||||||
|
|
||||||
|
# Recalculate rankings
|
||||||
|
recalculate_rankings(instance.hospital, year, month, instance.department)
|
||||||
|
|
||||||
|
|
||||||
|
def recalculate_rankings(hospital, year, month, department=None):
|
||||||
|
"""
|
||||||
|
Recalculate rankings for a given period.
|
||||||
|
|
||||||
|
Updates hospital_rank and department_rank for all recipients.
|
||||||
|
"""
|
||||||
|
# Get all stats for the period
|
||||||
|
if department:
|
||||||
|
stats_queryset = AppreciationStats.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
department=department,
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stats_queryset = AppreciationStats.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order by received count
|
||||||
|
stats_queryset = stats_queryset.order_by('-received_count')
|
||||||
|
|
||||||
|
# Update hospital rank
|
||||||
|
for rank, stat in enumerate(stats_queryset, start=1):
|
||||||
|
stat.hospital_rank = rank
|
||||||
|
stat.save(update_fields=['hospital_rank'])
|
||||||
|
|
||||||
|
# Update department rank if department is specified
|
||||||
|
if department:
|
||||||
|
dept_stats = stats_queryset.filter(department=department)
|
||||||
|
for rank, stat in enumerate(dept_stats, start=1):
|
||||||
|
stat.department_rank = rank
|
||||||
|
stat.save(update_fields=['department_rank'])
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_award_badges(instance):
|
||||||
|
"""
|
||||||
|
Check if recipient qualifies for any badges and award them.
|
||||||
|
|
||||||
|
Checks all active badge criteria and awards badges if criteria are met.
|
||||||
|
"""
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
|
# Get recipient
|
||||||
|
recipient_content_type = instance.recipient_content_type
|
||||||
|
recipient_object_id = instance.recipient_object_id
|
||||||
|
|
||||||
|
# Get all active badges for the hospital
|
||||||
|
badges = AppreciationBadge.objects.filter(
|
||||||
|
Q(hospital=instance.hospital) | Q(hospital__isnull=True),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for badge in badges:
|
||||||
|
# Check if badge already earned
|
||||||
|
if UserBadge.objects.filter(
|
||||||
|
recipient_content_type=recipient_content_type,
|
||||||
|
recipient_object_id=recipient_object_id,
|
||||||
|
badge=badge,
|
||||||
|
).exists():
|
||||||
|
continue # Already earned
|
||||||
|
|
||||||
|
# Check badge criteria
|
||||||
|
qualifies = check_badge_criteria(
|
||||||
|
badge,
|
||||||
|
recipient_content_type,
|
||||||
|
recipient_object_id,
|
||||||
|
instance.hospital,
|
||||||
|
)
|
||||||
|
|
||||||
|
if qualifies:
|
||||||
|
# Calculate current count
|
||||||
|
count = get_appreciation_count(
|
||||||
|
recipient_content_type,
|
||||||
|
recipient_object_id,
|
||||||
|
badge.criteria_type
|
||||||
|
)
|
||||||
|
|
||||||
|
# Award badge
|
||||||
|
UserBadge.objects.create(
|
||||||
|
recipient_content_type=recipient_content_type,
|
||||||
|
recipient_object_id=recipient_object_id,
|
||||||
|
badge=badge,
|
||||||
|
appreciation_count=count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_badge_criteria(badge, content_type, object_id, hospital):
|
||||||
|
"""
|
||||||
|
Check if recipient meets badge criteria.
|
||||||
|
|
||||||
|
Returns True if criteria is met, False otherwise.
|
||||||
|
"""
|
||||||
|
criteria_type = badge.criteria_type
|
||||||
|
criteria_value = badge.criteria_value
|
||||||
|
|
||||||
|
if criteria_type == 'received_count':
|
||||||
|
# Check total appreciation count
|
||||||
|
count = Appreciation.objects.filter(
|
||||||
|
recipient_content_type=content_type,
|
||||||
|
recipient_object_id=object_id,
|
||||||
|
status=AppreciationStatus.SENT,
|
||||||
|
).count()
|
||||||
|
return count >= criteria_value
|
||||||
|
|
||||||
|
elif criteria_type == 'received_month':
|
||||||
|
# Check appreciation count in current month
|
||||||
|
now = timezone.now()
|
||||||
|
count = Appreciation.objects.filter(
|
||||||
|
recipient_content_type=content_type,
|
||||||
|
recipient_object_id=object_id,
|
||||||
|
status=AppreciationStatus.SENT,
|
||||||
|
sent_at__year=now.year,
|
||||||
|
sent_at__month=now.month,
|
||||||
|
).count()
|
||||||
|
return count >= criteria_value
|
||||||
|
|
||||||
|
elif criteria_type == 'streak_weeks':
|
||||||
|
# Check consecutive weeks with appreciation
|
||||||
|
return check_appreciation_streak(
|
||||||
|
content_type,
|
||||||
|
object_id,
|
||||||
|
criteria_value
|
||||||
|
)
|
||||||
|
|
||||||
|
elif criteria_type == 'diverse_senders':
|
||||||
|
# Check appreciation from different senders
|
||||||
|
sender_count = Appreciation.objects.filter(
|
||||||
|
recipient_content_type=content_type,
|
||||||
|
recipient_object_id=object_id,
|
||||||
|
status=AppreciationStatus.SENT,
|
||||||
|
).values('sender').distinct().count()
|
||||||
|
return sender_count >= criteria_value
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_appreciation_count(content_type, object_id, criteria_type):
|
||||||
|
"""
|
||||||
|
Get appreciation count based on criteria type.
|
||||||
|
"""
|
||||||
|
if criteria_type == 'received_count':
|
||||||
|
return Appreciation.objects.filter(
|
||||||
|
recipient_content_type=content_type,
|
||||||
|
recipient_object_id=object_id,
|
||||||
|
status=AppreciationStatus.SENT,
|
||||||
|
).count()
|
||||||
|
elif criteria_type == 'received_month':
|
||||||
|
now = timezone.now()
|
||||||
|
return Appreciation.objects.filter(
|
||||||
|
recipient_content_type=content_type,
|
||||||
|
recipient_object_id=object_id,
|
||||||
|
status=AppreciationStatus.SENT,
|
||||||
|
sent_at__year=now.year,
|
||||||
|
sent_at__month=now.month,
|
||||||
|
).count()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def check_appreciation_streak(content_type, object_id, required_weeks):
|
||||||
|
"""
|
||||||
|
Check if recipient has appreciation streak for required weeks.
|
||||||
|
|
||||||
|
Returns True if streak meets or exceeds required_weeks.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
current_week = 0
|
||||||
|
|
||||||
|
# Check week by week going backwards
|
||||||
|
for i in range(required_weeks):
|
||||||
|
week_start = now - timedelta(weeks=i+1)
|
||||||
|
week_end = now - timedelta(weeks=i)
|
||||||
|
|
||||||
|
# Check if there's any appreciation in this week
|
||||||
|
has_appreciation = Appreciation.objects.filter(
|
||||||
|
recipient_content_type=content_type,
|
||||||
|
recipient_object_id=object_id,
|
||||||
|
status=AppreciationStatus.SENT,
|
||||||
|
sent_at__gte=week_start,
|
||||||
|
sent_at__lt=week_end,
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if has_appreciation:
|
||||||
|
current_week += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return current_week >= required_weeks
|
||||||
986
apps/appreciation/ui_views.py
Normal file
986
apps/appreciation/ui_views.py
Normal file
@ -0,0 +1,986 @@
|
|||||||
|
"""
|
||||||
|
Appreciation UI views - Server-rendered templates for appreciation management
|
||||||
|
"""
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.db.models import Q, Count
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from apps.accounts.models import User
|
||||||
|
from apps.core.services import AuditService
|
||||||
|
from apps.organizations.models import Department, Hospital, Physician
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
Appreciation,
|
||||||
|
AppreciationBadge,
|
||||||
|
AppreciationCategory,
|
||||||
|
AppreciationStatus,
|
||||||
|
AppreciationVisibility,
|
||||||
|
AppreciationStats,
|
||||||
|
UserBadge,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# APPRECIATION LIST & DETAIL VIEWS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def appreciation_list(request):
|
||||||
|
"""
|
||||||
|
Appreciations list view with advanced filters and pagination.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Server-side pagination
|
||||||
|
- Advanced filters (status, visibility, category, hospital, department)
|
||||||
|
- Search by message
|
||||||
|
- Tab-based navigation (Received, Sent, Leaderboard, Badges)
|
||||||
|
"""
|
||||||
|
# Base queryset with optimizations
|
||||||
|
queryset = Appreciation.objects.select_related(
|
||||||
|
'sender', 'hospital', 'department', 'category'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply RBAC filters
|
||||||
|
user = request.user
|
||||||
|
if user.is_px_admin():
|
||||||
|
pass # See all
|
||||||
|
elif user.is_hospital_admin() and user.hospital:
|
||||||
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
elif user.is_department_manager() and user.department:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(department=user.department) | Q(department__isnull=True)
|
||||||
|
)
|
||||||
|
elif user.hospital:
|
||||||
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
else:
|
||||||
|
queryset = queryset.none()
|
||||||
|
|
||||||
|
# Apply visibility filter based on user
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
user_content_type = ContentType.objects.get_for_model(user)
|
||||||
|
|
||||||
|
visibility_filter = (
|
||||||
|
Q(sender=user) |
|
||||||
|
Q(recipient_content_type=user_content_type, recipient_object_id=user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.department:
|
||||||
|
visibility_filter |= Q(visibility=AppreciationVisibility.DEPARTMENT, department=user.department)
|
||||||
|
|
||||||
|
if user.hospital:
|
||||||
|
visibility_filter |= Q(visibility=AppreciationVisibility.HOSPITAL, hospital=user.hospital)
|
||||||
|
|
||||||
|
visibility_filter |= Q(visibility=AppreciationVisibility.PUBLIC)
|
||||||
|
queryset = queryset.filter(visibility_filter)
|
||||||
|
|
||||||
|
# Apply filters from request
|
||||||
|
tab = request.GET.get('tab', 'received')
|
||||||
|
|
||||||
|
if tab == 'received':
|
||||||
|
# Show appreciations received by user
|
||||||
|
queryset = queryset.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=user.id
|
||||||
|
)
|
||||||
|
elif tab == 'sent':
|
||||||
|
# Show appreciations sent by user
|
||||||
|
queryset = queryset.filter(sender=user)
|
||||||
|
# 'leaderboard' and 'badges' tabs are handled separately
|
||||||
|
|
||||||
|
status_filter = request.GET.get('status')
|
||||||
|
if status_filter:
|
||||||
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
|
visibility_filter_req = request.GET.get('visibility')
|
||||||
|
if visibility_filter_req:
|
||||||
|
queryset = queryset.filter(visibility=visibility_filter_req)
|
||||||
|
|
||||||
|
category_filter = request.GET.get('category')
|
||||||
|
if category_filter:
|
||||||
|
queryset = queryset.filter(category_id=category_filter)
|
||||||
|
|
||||||
|
hospital_filter = request.GET.get('hospital')
|
||||||
|
if hospital_filter:
|
||||||
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||||
|
|
||||||
|
department_filter = request.GET.get('department')
|
||||||
|
if department_filter:
|
||||||
|
queryset = queryset.filter(department_id=department_filter)
|
||||||
|
|
||||||
|
# Search
|
||||||
|
search_query = request.GET.get('search')
|
||||||
|
if search_query:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(message_en__icontains=search_query) |
|
||||||
|
Q(message_ar__icontains=search_query)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Date range filters
|
||||||
|
date_from = request.GET.get('date_from')
|
||||||
|
if date_from:
|
||||||
|
queryset = queryset.filter(sent_at__gte=date_from)
|
||||||
|
|
||||||
|
date_to = request.GET.get('date_to')
|
||||||
|
if date_to:
|
||||||
|
queryset = queryset.filter(sent_at__lte=date_to)
|
||||||
|
|
||||||
|
# Ordering
|
||||||
|
order_by = request.GET.get('order_by', '-sent_at')
|
||||||
|
queryset = queryset.order_by(order_by)
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
|
paginator = Paginator(queryset, page_size)
|
||||||
|
page_number = request.GET.get('page', 1)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
# Get filter options
|
||||||
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
|
if not user.is_px_admin() and user.hospital:
|
||||||
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
|
departments = Department.objects.filter(status='active')
|
||||||
|
if not user.is_px_admin() and user.hospital:
|
||||||
|
departments = departments.filter(hospital=user.hospital)
|
||||||
|
|
||||||
|
categories = AppreciationCategory.objects.filter(is_active=True)
|
||||||
|
if not user.is_px_admin() and user.hospital:
|
||||||
|
categories = categories.filter(Q(hospital_id=user.hospital.id) | Q(hospital__isnull=True))
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
user_content_type = ContentType.objects.get_for_model(user)
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'received': Appreciation.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=user.id
|
||||||
|
).count(),
|
||||||
|
'sent': Appreciation.objects.filter(sender=user).count(),
|
||||||
|
'badges_earned': UserBadge.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=user.id
|
||||||
|
).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'page_obj': page_obj,
|
||||||
|
'appreciations': page_obj.object_list,
|
||||||
|
'stats': stats,
|
||||||
|
'hospitals': hospitals,
|
||||||
|
'departments': departments,
|
||||||
|
'categories': categories,
|
||||||
|
'status_choices': AppreciationStatus.choices,
|
||||||
|
'visibility_choices': AppreciationVisibility.choices,
|
||||||
|
'current_tab': tab,
|
||||||
|
'filters': request.GET,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'appreciation/appreciation_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def appreciation_detail(request, pk):
|
||||||
|
"""
|
||||||
|
Appreciation detail view.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Full appreciation details
|
||||||
|
- Acknowledge action for recipients
|
||||||
|
- Related appreciations
|
||||||
|
"""
|
||||||
|
appreciation = get_object_or_404(
|
||||||
|
Appreciation.objects.select_related(
|
||||||
|
'sender', 'hospital', 'department', 'category'
|
||||||
|
),
|
||||||
|
pk=pk
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
user = request.user
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
user_content_type = ContentType.objects.get_for_model(user)
|
||||||
|
|
||||||
|
can_view = (
|
||||||
|
appreciation.sender == user or
|
||||||
|
(appreciation.recipient_content_type == user_content_type and
|
||||||
|
appreciation.recipient_object_id == user.id) or
|
||||||
|
user.is_px_admin() or
|
||||||
|
(user.is_hospital_admin() and appreciation.hospital == user.hospital)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not can_view:
|
||||||
|
messages.error(request, "You don't have permission to view this appreciation.")
|
||||||
|
return redirect('appreciation:appreciation_list')
|
||||||
|
|
||||||
|
# Check if user is recipient
|
||||||
|
is_recipient = (
|
||||||
|
appreciation.recipient_content_type == user_content_type and
|
||||||
|
appreciation.recipient_object_id == user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get related appreciations
|
||||||
|
related = Appreciation.objects.filter(
|
||||||
|
recipient_content_type=appreciation.recipient_content_type,
|
||||||
|
recipient_object_id=appreciation.recipient_object_id
|
||||||
|
).exclude(pk=appreciation.pk)[:5]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'appreciation': appreciation,
|
||||||
|
'is_recipient': is_recipient,
|
||||||
|
'related': related,
|
||||||
|
'status_choices': AppreciationStatus.choices,
|
||||||
|
'visibility_choices': AppreciationVisibility.choices,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'appreciation/appreciation_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def appreciation_send(request):
|
||||||
|
"""Send appreciation form"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
try:
|
||||||
|
# Get form data
|
||||||
|
recipient_type = request.POST.get('recipient_type')
|
||||||
|
recipient_id = request.POST.get('recipient_id')
|
||||||
|
category_id = request.POST.get('category_id', None)
|
||||||
|
message_en = request.POST.get('message_en')
|
||||||
|
message_ar = request.POST.get('message_ar', '')
|
||||||
|
visibility = request.POST.get('visibility', AppreciationVisibility.PRIVATE)
|
||||||
|
is_anonymous = request.POST.get('is_anonymous') == 'on'
|
||||||
|
hospital_id = request.POST.get('hospital_id')
|
||||||
|
department_id = request.POST.get('department_id', None)
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not all([recipient_type, recipient_id, message_en, hospital_id]):
|
||||||
|
messages.error(request, "Please fill in all required fields.")
|
||||||
|
return redirect('appreciation:appreciation_send')
|
||||||
|
|
||||||
|
# Get recipient
|
||||||
|
if recipient_type == 'user':
|
||||||
|
recipient = User.objects.get(id=recipient_id)
|
||||||
|
recipient_content_type = ContentType.objects.get_for_model(User)
|
||||||
|
else: # physician
|
||||||
|
recipient = Physician.objects.get(id=recipient_id)
|
||||||
|
recipient_content_type = ContentType.objects.get_for_model(Physician)
|
||||||
|
|
||||||
|
# Get hospital and department
|
||||||
|
hospital = Hospital.objects.get(id=hospital_id)
|
||||||
|
department = None
|
||||||
|
if department_id:
|
||||||
|
department = Department.objects.get(id=department_id)
|
||||||
|
|
||||||
|
# Get category
|
||||||
|
category = None
|
||||||
|
if category_id:
|
||||||
|
category = AppreciationCategory.objects.get(id=category_id)
|
||||||
|
|
||||||
|
# Create appreciation
|
||||||
|
appreciation = Appreciation.objects.create(
|
||||||
|
sender=request.user,
|
||||||
|
recipient_content_type=recipient_content_type,
|
||||||
|
recipient_object_id=recipient_id,
|
||||||
|
hospital=hospital,
|
||||||
|
department=department,
|
||||||
|
category=category,
|
||||||
|
message_en=message_en,
|
||||||
|
message_ar=message_ar,
|
||||||
|
visibility=visibility,
|
||||||
|
is_anonymous=is_anonymous,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send appreciation
|
||||||
|
appreciation.send()
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
AuditService.log_event(
|
||||||
|
event_type='appreciation_sent',
|
||||||
|
description=f"Appreciation sent to {str(recipient)}",
|
||||||
|
user=request.user,
|
||||||
|
content_object=appreciation,
|
||||||
|
metadata={
|
||||||
|
'visibility': visibility,
|
||||||
|
'category': category.name_en if category else None,
|
||||||
|
'anonymous': is_anonymous
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.success(request, "Appreciation sent successfully!")
|
||||||
|
return redirect('appreciation:appreciation_detail', pk=appreciation.id)
|
||||||
|
|
||||||
|
except User.DoesNotExist:
|
||||||
|
messages.error(request, "User not found.")
|
||||||
|
except Physician.DoesNotExist:
|
||||||
|
messages.error(request, "Physician not found.")
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f"Error sending appreciation: {str(e)}")
|
||||||
|
|
||||||
|
return redirect('appreciation:appreciation_send')
|
||||||
|
|
||||||
|
# GET request - show form
|
||||||
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
|
if not request.user.is_px_admin() and request.user.hospital:
|
||||||
|
hospitals = hospitals.filter(id=request.user.hospital.id)
|
||||||
|
|
||||||
|
categories = AppreciationCategory.objects.filter(is_active=True)
|
||||||
|
if not request.user.is_px_admin() and request.user.hospital:
|
||||||
|
categories = categories.filter(Q(hospital_id=request.user.hospital.id) | Q(hospital__isnull=True))
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'hospitals': hospitals,
|
||||||
|
'categories': categories,
|
||||||
|
'visibility_choices': AppreciationVisibility.choices,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'appreciation/appreciation_send_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def appreciation_acknowledge(request, pk):
|
||||||
|
"""Acknowledge appreciation"""
|
||||||
|
appreciation = get_object_or_404(Appreciation, pk=pk)
|
||||||
|
|
||||||
|
# Check if user is recipient
|
||||||
|
user_content_type = ContentType.objects.get_for_model(request.user)
|
||||||
|
if not (
|
||||||
|
appreciation.recipient_content_type == user_content_type and
|
||||||
|
appreciation.recipient_object_id == request.user.id
|
||||||
|
):
|
||||||
|
messages.error(request, "You can only acknowledge appreciations sent to you.")
|
||||||
|
return redirect('appreciation:appreciation_detail', pk=pk)
|
||||||
|
|
||||||
|
# Acknowledge
|
||||||
|
appreciation.acknowledge()
|
||||||
|
|
||||||
|
messages.success(request, "Appreciation acknowledged successfully.")
|
||||||
|
return redirect('appreciation:appreciation_detail', pk=pk)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# LEADERBOARD VIEWS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def leaderboard_view(request):
|
||||||
|
"""
|
||||||
|
Appreciation leaderboard view.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Monthly rankings
|
||||||
|
- Hospital and department filters
|
||||||
|
- Top recipients with badges
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Get date range
|
||||||
|
now = timezone.now()
|
||||||
|
year = int(request.GET.get('year', now.year))
|
||||||
|
month = int(request.GET.get('month', now.month))
|
||||||
|
|
||||||
|
# Build base query
|
||||||
|
queryset = AppreciationStats.objects.filter(year=year, month=month)
|
||||||
|
|
||||||
|
# Apply RBAC
|
||||||
|
if not user.is_px_admin() and user.hospital:
|
||||||
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
hospital_filter = request.GET.get('hospital')
|
||||||
|
if hospital_filter:
|
||||||
|
queryset = queryset.filter(hospital_id=hospital_filter)
|
||||||
|
|
||||||
|
department_filter = request.GET.get('department')
|
||||||
|
if department_filter:
|
||||||
|
queryset = queryset.filter(department_id=department_filter)
|
||||||
|
|
||||||
|
# Order by received count
|
||||||
|
queryset = queryset.order_by('-received_count')
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
page_size = int(request.GET.get('page_size', 50))
|
||||||
|
paginator = Paginator(queryset, page_size)
|
||||||
|
page_number = request.GET.get('page', 1)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
# Get filter options
|
||||||
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
|
if not user.is_px_admin() and user.hospital:
|
||||||
|
hospitals = hospitals.filter(id=user.hospital.id)
|
||||||
|
|
||||||
|
departments = Department.objects.filter(status='active')
|
||||||
|
if not user.is_px_admin() and user.hospital:
|
||||||
|
departments = departments.filter(hospital=user.hospital)
|
||||||
|
|
||||||
|
# Get months for filter
|
||||||
|
months = [(i, timezone.datetime(year=year, month=i, day=1).strftime('%B')) for i in range(1, 13)]
|
||||||
|
years = range(now.year - 1, now.year + 2)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'page_obj': page_obj,
|
||||||
|
'leaderboard': page_obj.object_list,
|
||||||
|
'hospitals': hospitals,
|
||||||
|
'departments': departments,
|
||||||
|
'months': months,
|
||||||
|
'years': years,
|
||||||
|
'selected_year': year,
|
||||||
|
'selected_month': month,
|
||||||
|
'filters': request.GET,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'appreciation/leaderboard.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def my_badges_view(request):
|
||||||
|
"""
|
||||||
|
User's badges view.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- All earned badges
|
||||||
|
- Badge details and criteria
|
||||||
|
- Progress toward next badges
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
user_content_type = ContentType.objects.get_for_model(user)
|
||||||
|
|
||||||
|
# Get user's badges
|
||||||
|
queryset = UserBadge.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=user.id
|
||||||
|
).select_related('badge').order_by('-earned_at')
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
page_size = int(request.GET.get('page_size', 20))
|
||||||
|
paginator = Paginator(queryset, page_size)
|
||||||
|
page_number = request.GET.get('page', 1)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
# Get available badges for progress tracking
|
||||||
|
available_badges = AppreciationBadge.objects.filter(is_active=True)
|
||||||
|
if not request.user.is_px_admin() and request.user.hospital:
|
||||||
|
available_badges = available_badges.filter(Q(hospital_id=request.user.hospital.id) | Q(hospital__isnull=True))
|
||||||
|
|
||||||
|
# Calculate progress for each badge
|
||||||
|
badge_progress = []
|
||||||
|
total_received = Appreciation.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=user.id
|
||||||
|
).count()
|
||||||
|
|
||||||
|
for badge in available_badges:
|
||||||
|
earned = queryset.filter(badge=badge).exists()
|
||||||
|
progress = 0
|
||||||
|
if badge.criteria_type == 'count':
|
||||||
|
progress = min(100, int((total_received / badge.criteria_value) * 100))
|
||||||
|
|
||||||
|
badge_progress.append({
|
||||||
|
'badge': badge,
|
||||||
|
'earned': earned,
|
||||||
|
'progress': progress,
|
||||||
|
})
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'page_obj': page_obj,
|
||||||
|
'badges': page_obj.object_list,
|
||||||
|
'total_received': total_received,
|
||||||
|
'badge_progress': badge_progress,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'appreciation/my_badges.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ADMIN: CATEGORY MANAGEMENT
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def category_list(request):
|
||||||
|
"""List and manage appreciation categories"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
|
messages.error(request, "You don't have permission to manage categories.")
|
||||||
|
return redirect('appreciation:appreciation_list')
|
||||||
|
|
||||||
|
# Base queryset
|
||||||
|
queryset = AppreciationCategory.objects.all()
|
||||||
|
|
||||||
|
# Apply RBAC
|
||||||
|
if not user.is_px_admin() and user.hospital:
|
||||||
|
queryset = queryset.filter(Q(hospital_id=user.hospital.id) | Q(hospital__isnull=True))
|
||||||
|
|
||||||
|
# Search
|
||||||
|
search_query = request.GET.get('search')
|
||||||
|
if search_query:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(name_en__icontains=search_query) |
|
||||||
|
Q(name_ar__icontains=search_query) |
|
||||||
|
Q(code__icontains=search_query)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ordering
|
||||||
|
queryset = queryset.order_by('order', 'code')
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
|
paginator = Paginator(queryset, page_size)
|
||||||
|
page_number = request.GET.get('page', 1)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'page_obj': page_obj,
|
||||||
|
'categories': page_obj.object_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'appreciation/admin/category_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def category_create(request):
|
||||||
|
"""Create appreciation category"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
|
messages.error(request, "You don't have permission to create categories.")
|
||||||
|
return redirect('appreciation:appreciation_list')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
try:
|
||||||
|
code = request.POST.get('code')
|
||||||
|
name_en = request.POST.get('name_en')
|
||||||
|
name_ar = request.POST.get('name_ar', '')
|
||||||
|
description_en = request.POST.get('description_en', '')
|
||||||
|
description_ar = request.POST.get('description_ar', '')
|
||||||
|
icon = request.POST.get('icon', 'fa-heart')
|
||||||
|
color = request.POST.get('color', '#FF5733')
|
||||||
|
order = request.POST.get('order', 0)
|
||||||
|
is_active = request.POST.get('is_active') == 'on'
|
||||||
|
|
||||||
|
# Get hospital
|
||||||
|
hospital = None
|
||||||
|
if user.is_hospital_admin() and user.hospital:
|
||||||
|
hospital = user.hospital
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if not all([code, name_en]):
|
||||||
|
messages.error(request, "Please fill in all required fields.")
|
||||||
|
return redirect('appreciation:category_create')
|
||||||
|
|
||||||
|
# Create category
|
||||||
|
AppreciationCategory.objects.create(
|
||||||
|
code=code,
|
||||||
|
name_en=name_en,
|
||||||
|
name_ar=name_ar,
|
||||||
|
description_en=description_en,
|
||||||
|
description_ar=description_ar,
|
||||||
|
icon=icon,
|
||||||
|
color=color,
|
||||||
|
order=order,
|
||||||
|
is_active=is_active,
|
||||||
|
hospital=hospital,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.success(request, "Category created successfully.")
|
||||||
|
return redirect('appreciation:category_list')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f"Error creating category: {str(e)}")
|
||||||
|
return redirect('appreciation:category_create')
|
||||||
|
|
||||||
|
context = {}
|
||||||
|
return render(request, 'appreciation/admin/category_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def category_edit(request, pk):
|
||||||
|
"""Edit appreciation category"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
|
messages.error(request, "You don't have permission to edit categories.")
|
||||||
|
return redirect('appreciation:appreciation_list')
|
||||||
|
|
||||||
|
category = get_object_or_404(AppreciationCategory, pk=pk)
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
if not user.is_px_admin() and category.hospital != user.hospital:
|
||||||
|
messages.error(request, "You don't have permission to edit this category.")
|
||||||
|
return redirect('appreciation:category_list')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
try:
|
||||||
|
category.code = request.POST.get('code')
|
||||||
|
category.name_en = request.POST.get('name_en')
|
||||||
|
category.name_ar = request.POST.get('name_ar', '')
|
||||||
|
category.description_en = request.POST.get('description_en', '')
|
||||||
|
category.description_ar = request.POST.get('description_ar', '')
|
||||||
|
category.icon = request.POST.get('icon', 'fa-heart')
|
||||||
|
category.color = request.POST.get('color', '#FF5733')
|
||||||
|
category.order = request.POST.get('order', 0)
|
||||||
|
category.is_active = request.POST.get('is_active') == 'on'
|
||||||
|
|
||||||
|
category.save()
|
||||||
|
|
||||||
|
messages.success(request, "Category updated successfully.")
|
||||||
|
return redirect('appreciation:category_list')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f"Error updating category: {str(e)}")
|
||||||
|
return redirect('appreciation:category_edit', pk=pk)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'category': category,
|
||||||
|
}
|
||||||
|
return render(request, 'appreciation/admin/category_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def category_delete(request, pk):
|
||||||
|
"""Delete appreciation category"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
|
messages.error(request, "You don't have permission to delete categories.")
|
||||||
|
return redirect('appreciation:appreciation_list')
|
||||||
|
|
||||||
|
category = get_object_or_404(AppreciationCategory, pk=pk)
|
||||||
|
|
||||||
|
# Check if category is in use
|
||||||
|
if Appreciation.objects.filter(category=category).exists():
|
||||||
|
messages.error(request, "Cannot delete category that is in use.")
|
||||||
|
return redirect('appreciation:category_list')
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
AuditService.log_event(
|
||||||
|
event_type='category_deleted',
|
||||||
|
description=f"Appreciation category deleted: {category.name_en}",
|
||||||
|
user=request.user,
|
||||||
|
metadata={'category_code': category.code}
|
||||||
|
)
|
||||||
|
|
||||||
|
category.delete()
|
||||||
|
|
||||||
|
messages.success(request, "Category deleted successfully.")
|
||||||
|
return redirect('appreciation:category_list')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ADMIN: BADGE MANAGEMENT
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def badge_list(request):
|
||||||
|
"""List and manage appreciation badges"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
|
messages.error(request, "You don't have permission to manage badges.")
|
||||||
|
return redirect('appreciation:appreciation_list')
|
||||||
|
|
||||||
|
# Base queryset
|
||||||
|
queryset = AppreciationBadge.objects.all()
|
||||||
|
|
||||||
|
# Apply RBAC
|
||||||
|
if not user.is_px_admin() and user.hospital:
|
||||||
|
queryset = queryset.filter(Q(hospital_id=user.hospital.id) | Q(hospital__isnull=True))
|
||||||
|
|
||||||
|
# Search
|
||||||
|
search_query = request.GET.get('search')
|
||||||
|
if search_query:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(name_en__icontains=search_query) |
|
||||||
|
Q(name_ar__icontains=search_query) |
|
||||||
|
Q(code__icontains=search_query)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ordering
|
||||||
|
queryset = queryset.order_by('order', 'code')
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
page_size = int(request.GET.get('page_size', 25))
|
||||||
|
paginator = Paginator(queryset, page_size)
|
||||||
|
page_number = request.GET.get('page', 1)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'page_obj': page_obj,
|
||||||
|
'badges': page_obj.object_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'appreciation/admin/badge_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def badge_create(request):
|
||||||
|
"""Create appreciation badge"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
|
messages.error(request, "You don't have permission to create badges.")
|
||||||
|
return redirect('appreciation:appreciation_list')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
try:
|
||||||
|
code = request.POST.get('code')
|
||||||
|
name_en = request.POST.get('name_en')
|
||||||
|
name_ar = request.POST.get('name_ar', '')
|
||||||
|
description_en = request.POST.get('description_en', '')
|
||||||
|
description_ar = request.POST.get('description_ar', '')
|
||||||
|
icon = request.POST.get('icon', 'fa-award')
|
||||||
|
color = request.POST.get('color', '#FFD700')
|
||||||
|
criteria_type = request.POST.get('criteria_type', 'count')
|
||||||
|
criteria_value = request.POST.get('criteria_value', 5)
|
||||||
|
order = request.POST.get('order', 0)
|
||||||
|
is_active = request.POST.get('is_active') == 'on'
|
||||||
|
|
||||||
|
# Get hospital
|
||||||
|
hospital = None
|
||||||
|
if user.is_hospital_admin() and user.hospital:
|
||||||
|
hospital = user.hospital
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if not all([code, name_en, criteria_value]):
|
||||||
|
messages.error(request, "Please fill in all required fields.")
|
||||||
|
return redirect('appreciation:badge_create')
|
||||||
|
|
||||||
|
# Create badge
|
||||||
|
AppreciationBadge.objects.create(
|
||||||
|
code=code,
|
||||||
|
name_en=name_en,
|
||||||
|
name_ar=name_ar,
|
||||||
|
description_en=description_en,
|
||||||
|
description_ar=description_ar,
|
||||||
|
icon=icon,
|
||||||
|
color=color,
|
||||||
|
criteria_type=criteria_type,
|
||||||
|
criteria_value=int(criteria_value),
|
||||||
|
order=order,
|
||||||
|
is_active=is_active,
|
||||||
|
hospital=hospital,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.success(request, "Badge created successfully.")
|
||||||
|
return redirect('appreciation:badge_list')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f"Error creating badge: {str(e)}")
|
||||||
|
return redirect('appreciation:badge_create')
|
||||||
|
|
||||||
|
context = {}
|
||||||
|
return render(request, 'appreciation/admin/badge_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def badge_edit(request, pk):
|
||||||
|
"""Edit appreciation badge"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
|
messages.error(request, "You don't have permission to edit badges.")
|
||||||
|
return redirect('appreciation:appreciation_list')
|
||||||
|
|
||||||
|
badge = get_object_or_404(AppreciationBadge, pk=pk)
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
if not user.is_px_admin() and badge.hospital != user.hospital:
|
||||||
|
messages.error(request, "You don't have permission to edit this badge.")
|
||||||
|
return redirect('appreciation:badge_list')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
try:
|
||||||
|
badge.code = request.POST.get('code')
|
||||||
|
badge.name_en = request.POST.get('name_en')
|
||||||
|
badge.name_ar = request.POST.get('name_ar', '')
|
||||||
|
badge.description_en = request.POST.get('description_en', '')
|
||||||
|
badge.description_ar = request.POST.get('description_ar', '')
|
||||||
|
badge.icon = request.POST.get('icon', 'fa-award')
|
||||||
|
badge.color = request.POST.get('color', '#FFD700')
|
||||||
|
badge.criteria_type = request.POST.get('criteria_type', 'count')
|
||||||
|
badge.criteria_value = request.POST.get('criteria_value', 5)
|
||||||
|
badge.order = request.POST.get('order', 0)
|
||||||
|
badge.is_active = request.POST.get('is_active') == 'on'
|
||||||
|
|
||||||
|
badge.save()
|
||||||
|
|
||||||
|
messages.success(request, "Badge updated successfully.")
|
||||||
|
return redirect('appreciation:badge_list')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f"Error updating badge: {str(e)}")
|
||||||
|
return redirect('appreciation:badge_edit', pk=pk)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'badge': badge,
|
||||||
|
}
|
||||||
|
return render(request, 'appreciation/admin/badge_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def badge_delete(request, pk):
|
||||||
|
"""Delete appreciation badge"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
|
messages.error(request, "You don't have permission to delete badges.")
|
||||||
|
return redirect('appreciation:appreciation_list')
|
||||||
|
|
||||||
|
badge = get_object_or_404(AppreciationBadge, pk=pk)
|
||||||
|
|
||||||
|
# Check if badge is in use
|
||||||
|
if UserBadge.objects.filter(badge=badge).exists():
|
||||||
|
messages.error(request, "Cannot delete badge that has been earned.")
|
||||||
|
return redirect('appreciation:badge_list')
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
AuditService.log_event(
|
||||||
|
event_type='badge_deleted',
|
||||||
|
description=f"Appreciation badge deleted: {badge.name_en}",
|
||||||
|
user=request.user,
|
||||||
|
metadata={'badge_code': badge.code}
|
||||||
|
)
|
||||||
|
|
||||||
|
badge.delete()
|
||||||
|
|
||||||
|
messages.success(request, "Badge deleted successfully.")
|
||||||
|
return redirect('appreciation:badge_list')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# AJAX/API HELPERS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def get_users_by_hospital(request):
|
||||||
|
"""Get users for a hospital (AJAX)"""
|
||||||
|
hospital_id = request.GET.get('hospital_id')
|
||||||
|
if not hospital_id:
|
||||||
|
return JsonResponse({'users': []})
|
||||||
|
|
||||||
|
users = User.objects.filter(
|
||||||
|
hospital_id=hospital_id,
|
||||||
|
is_active=True
|
||||||
|
).values('id', 'first_name', 'last_name')
|
||||||
|
|
||||||
|
results = [
|
||||||
|
{
|
||||||
|
'id': str(u['id']),
|
||||||
|
'name': f"{u['first_name']} {u['last_name']}",
|
||||||
|
}
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|
||||||
|
return JsonResponse({'users': results})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def get_physicians_by_hospital(request):
|
||||||
|
"""Get physicians for a hospital (AJAX)"""
|
||||||
|
hospital_id = request.GET.get('hospital_id')
|
||||||
|
if not hospital_id:
|
||||||
|
return JsonResponse({'physicians': []})
|
||||||
|
|
||||||
|
physicians = Physician.objects.filter(
|
||||||
|
hospital_id=hospital_id,
|
||||||
|
status='active'
|
||||||
|
).values('id', 'first_name', 'last_name', 'first_name_ar', 'last_name_ar')
|
||||||
|
|
||||||
|
results = [
|
||||||
|
{
|
||||||
|
'id': str(p['id']),
|
||||||
|
'name': f"{p['first_name']} {p['last_name']}",
|
||||||
|
}
|
||||||
|
for p in physicians
|
||||||
|
]
|
||||||
|
|
||||||
|
return JsonResponse({'physicians': results})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def get_departments_by_hospital(request):
|
||||||
|
"""Get departments for a hospital (AJAX)"""
|
||||||
|
hospital_id = request.GET.get('hospital_id')
|
||||||
|
if not hospital_id:
|
||||||
|
return JsonResponse({'departments': []})
|
||||||
|
|
||||||
|
departments = Department.objects.filter(
|
||||||
|
hospital_id=hospital_id,
|
||||||
|
status='active'
|
||||||
|
).values('id', 'name', 'name_ar')
|
||||||
|
|
||||||
|
results = [
|
||||||
|
{
|
||||||
|
'id': str(d['id']),
|
||||||
|
'name': d['name'],
|
||||||
|
}
|
||||||
|
for d in departments
|
||||||
|
]
|
||||||
|
|
||||||
|
return JsonResponse({'departments': results})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def appreciation_summary_ajax(request):
|
||||||
|
"""Get appreciation summary for current user (AJAX)"""
|
||||||
|
user = request.user
|
||||||
|
user_content_type = ContentType.objects.get_for_model(user)
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
current_year = now.year
|
||||||
|
current_month = now.month
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
'total_received': Appreciation.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=user.id
|
||||||
|
).count(),
|
||||||
|
'total_sent': Appreciation.objects.filter(sender=user).count(),
|
||||||
|
'this_month_received': Appreciation.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=user.id,
|
||||||
|
sent_at__year=current_year,
|
||||||
|
sent_at__month=current_month
|
||||||
|
).count(),
|
||||||
|
'this_month_sent': Appreciation.objects.filter(
|
||||||
|
sender=user,
|
||||||
|
sent_at__year=current_year,
|
||||||
|
sent_at__month=current_month
|
||||||
|
).count(),
|
||||||
|
'badges_earned': UserBadge.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=user.id
|
||||||
|
).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get hospital rank
|
||||||
|
if user.hospital:
|
||||||
|
stats = AppreciationStats.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=user.id,
|
||||||
|
year=current_year,
|
||||||
|
month=current_month
|
||||||
|
).first()
|
||||||
|
summary['hospital_rank'] = stats.hospital_rank if stats else None
|
||||||
|
|
||||||
|
return JsonResponse(summary)
|
||||||
57
apps/appreciation/urls.py
Normal file
57
apps/appreciation/urls.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Appreciation URLs - URL configuration for appreciation API and UI
|
||||||
|
"""
|
||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.appreciation.views import (
|
||||||
|
AppreciationBadgeViewSet,
|
||||||
|
AppreciationCategoryViewSet,
|
||||||
|
AppreciationStatsViewSet,
|
||||||
|
AppreciationViewSet,
|
||||||
|
LeaderboardView,
|
||||||
|
UserBadgeViewSet,
|
||||||
|
)
|
||||||
|
|
||||||
|
from apps.appreciation import ui_views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'categories', AppreciationCategoryViewSet, basename='appreciation-category')
|
||||||
|
router.register(r'appreciations', AppreciationViewSet, basename='appreciation')
|
||||||
|
router.register(r'stats', AppreciationStatsViewSet, basename='appreciation-stats')
|
||||||
|
router.register(r'badges', AppreciationBadgeViewSet, basename='appreciation-badge')
|
||||||
|
router.register(r'user-badges', UserBadgeViewSet, basename='user-badge')
|
||||||
|
|
||||||
|
app_name = 'appreciation'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# API Routes
|
||||||
|
path('api/', include(router.urls)),
|
||||||
|
path('api/leaderboard/', LeaderboardView.as_view(), name='api-leaderboard'),
|
||||||
|
|
||||||
|
# UI Routes
|
||||||
|
path('', ui_views.appreciation_list, name='appreciation_list'),
|
||||||
|
path('send/', ui_views.appreciation_send, name='appreciation_send'),
|
||||||
|
path('detail/<uuid:pk>/', ui_views.appreciation_detail, name='appreciation_detail'),
|
||||||
|
path('acknowledge/<uuid:pk>/', ui_views.appreciation_acknowledge, name='appreciation_acknowledge'),
|
||||||
|
path('leaderboard/', ui_views.leaderboard_view, name='leaderboard_view'),
|
||||||
|
path('badges/', ui_views.my_badges_view, name='my_badges_view'),
|
||||||
|
|
||||||
|
# Admin: Category Management
|
||||||
|
path('admin/categories/', ui_views.category_list, name='category_list'),
|
||||||
|
path('admin/categories/create/', ui_views.category_create, name='category_create'),
|
||||||
|
path('admin/categories/<uuid:pk>/edit/', ui_views.category_edit, name='category_edit'),
|
||||||
|
path('admin/categories/<uuid:pk>/delete/', ui_views.category_delete, name='category_delete'),
|
||||||
|
|
||||||
|
# Admin: Badge Management
|
||||||
|
path('admin/badges/', ui_views.badge_list, name='badge_list'),
|
||||||
|
path('admin/badges/create/', ui_views.badge_create, name='badge_create'),
|
||||||
|
path('admin/badges/<uuid:pk>/edit/', ui_views.badge_edit, name='badge_edit'),
|
||||||
|
path('admin/badges/<uuid:pk>/delete/', ui_views.badge_delete, name='badge_delete'),
|
||||||
|
|
||||||
|
# AJAX Helpers
|
||||||
|
path('ajax/users/', ui_views.get_users_by_hospital, name='get_users_by_hospital'),
|
||||||
|
path('ajax/physicians/', ui_views.get_physicians_by_hospital, name='get_physicians_by_hospital'),
|
||||||
|
path('ajax/departments/', ui_views.get_departments_by_hospital, name='get_departments_by_hospital'),
|
||||||
|
path('ajax/summary/', ui_views.appreciation_summary_ajax, name='appreciation_summary_ajax'),
|
||||||
|
]
|
||||||
530
apps/appreciation/views.py
Normal file
530
apps/appreciation/views.py
Normal file
@ -0,0 +1,530 @@
|
|||||||
|
"""
|
||||||
|
Appreciation views - API views for appreciation management
|
||||||
|
"""
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.db.models import Count, Q, F
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import generics, status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.appreciation.models import (
|
||||||
|
Appreciation,
|
||||||
|
AppreciationBadge,
|
||||||
|
AppreciationCategory,
|
||||||
|
AppreciationStats,
|
||||||
|
UserBadge,
|
||||||
|
)
|
||||||
|
from apps.appreciation.serializers import (
|
||||||
|
AppreciationBadgeSerializer,
|
||||||
|
AppreciationCategorySerializer,
|
||||||
|
AppreciationCreateSerializer,
|
||||||
|
AppreciationLeaderboardSerializer,
|
||||||
|
AppreciationSerializer,
|
||||||
|
AppreciationStatsSerializer,
|
||||||
|
AppreciationSummarySerializer,
|
||||||
|
UserBadgeSerializer,
|
||||||
|
)
|
||||||
|
from apps.accounts.permissions import IsPXAdminOrHospitalAdmin
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationCategoryViewSet(viewsets.ModelViewSet):
|
||||||
|
"""Viewset for AppreciationCategory"""
|
||||||
|
|
||||||
|
queryset = AppreciationCategory.objects.all()
|
||||||
|
serializer_class = AppreciationCategorySerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter categories by hospital"""
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
|
# Filter by hospital if provided
|
||||||
|
hospital_id = self.request.query_params.get('hospital_id')
|
||||||
|
if hospital_id:
|
||||||
|
queryset = queryset.filter(Q(hospital_id=hospital_id) | Q(hospital__isnull=True))
|
||||||
|
|
||||||
|
# Only show active categories
|
||||||
|
queryset = queryset.filter(is_active=True)
|
||||||
|
|
||||||
|
return queryset.select_related('hospital')
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationViewSet(viewsets.ModelViewSet):
|
||||||
|
"""Viewset for Appreciation"""
|
||||||
|
|
||||||
|
queryset = Appreciation.objects.all()
|
||||||
|
serializer_class = AppreciationSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter appreciations based on user's access"""
|
||||||
|
user = self.request.user
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
|
# Filter by hospital
|
||||||
|
if user.hospital:
|
||||||
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
|
# Filter by department if user is department manager
|
||||||
|
if user.department and user.is_department_manager():
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(department=user.department) | Q(department__isnull=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by visibility
|
||||||
|
# Users can see:
|
||||||
|
# - All appreciations they sent
|
||||||
|
# - All appreciations they received
|
||||||
|
# - Department-level appreciations if they're in the department
|
||||||
|
# - Hospital-level appreciations if they're in the hospital
|
||||||
|
# - Public appreciations
|
||||||
|
|
||||||
|
from apps.appreciation.models import AppreciationVisibility
|
||||||
|
|
||||||
|
# Get user's content type
|
||||||
|
user_content_type = ContentType.objects.get_for_model(user)
|
||||||
|
|
||||||
|
# Get physician if user has a physician profile
|
||||||
|
physician = None
|
||||||
|
if hasattr(user, 'physician_profile'):
|
||||||
|
physician = user.phician_profile
|
||||||
|
physician_content_type = ContentType.objects.get_for_model(type(physician))
|
||||||
|
|
||||||
|
# Build visibility filter
|
||||||
|
visibility_filter = (
|
||||||
|
Q(sender=user) | # Sent by user
|
||||||
|
Q(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=user.id
|
||||||
|
) # Received by user
|
||||||
|
)
|
||||||
|
|
||||||
|
if physician:
|
||||||
|
visibility_filter |= Q(
|
||||||
|
recipient_content_type=physician_content_type,
|
||||||
|
recipient_object_id=physician.id
|
||||||
|
) # Received by physician
|
||||||
|
|
||||||
|
if user.department:
|
||||||
|
visibility_filter |= Q(
|
||||||
|
visibility=AppreciationVisibility.DEPARTMENT,
|
||||||
|
department=user.department
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.hospital:
|
||||||
|
visibility_filter |= Q(
|
||||||
|
visibility=AppreciationVisibility.HOSPITAL,
|
||||||
|
hospital=user.hospital
|
||||||
|
)
|
||||||
|
|
||||||
|
visibility_filter |= Q(visibility=AppreciationVisibility.PUBLIC)
|
||||||
|
|
||||||
|
queryset = queryset.filter(visibility_filter)
|
||||||
|
|
||||||
|
# Filter by recipient
|
||||||
|
recipient_type = self.request.query_params.get('recipient_type')
|
||||||
|
recipient_id = self.request.query_params.get('recipient_id')
|
||||||
|
if recipient_type and recipient_id:
|
||||||
|
if recipient_type == 'user':
|
||||||
|
content_type = ContentType.objects.get_for_model(
|
||||||
|
self.request.user.__class__
|
||||||
|
)
|
||||||
|
queryset = queryset.filter(
|
||||||
|
recipient_content_type=content_type,
|
||||||
|
recipient_object_id=recipient_id
|
||||||
|
)
|
||||||
|
elif recipient_type == 'physician':
|
||||||
|
from apps.organizations.models import Physician
|
||||||
|
content_type = ContentType.objects.get_for_model(Physician)
|
||||||
|
queryset = queryset.filter(
|
||||||
|
recipient_content_type=content_type,
|
||||||
|
recipient_object_id=recipient_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by status
|
||||||
|
status_filter = self.request.query_params.get('status')
|
||||||
|
if status_filter:
|
||||||
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
|
||||||
|
# Filter by category
|
||||||
|
category_id = self.request.query_params.get('category_id')
|
||||||
|
if category_id:
|
||||||
|
queryset = queryset.filter(category_id=category_id)
|
||||||
|
|
||||||
|
return queryset.select_related(
|
||||||
|
'sender', 'hospital', 'department', 'category'
|
||||||
|
).prefetch_related('recipient')
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
"""Create a new appreciation"""
|
||||||
|
serializer = AppreciationCreateSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# Get validated data
|
||||||
|
data = serializer.validated_data
|
||||||
|
|
||||||
|
# Get recipient
|
||||||
|
recipient_type = data['recipient_type']
|
||||||
|
recipient_id = data['recipient_id']
|
||||||
|
|
||||||
|
if recipient_type == 'user':
|
||||||
|
from apps.accounts.models import User
|
||||||
|
recipient = User.objects.get(id=recipient_id)
|
||||||
|
content_type = ContentType.objects.get_for_model(User)
|
||||||
|
else: # physician
|
||||||
|
from apps.organizations.models import Physician
|
||||||
|
recipient = Physician.objects.get(id=recipient_id)
|
||||||
|
content_type = ContentType.objects.get_for_model(Physician)
|
||||||
|
|
||||||
|
# Get hospital
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
hospital = Hospital.objects.get(id=data['hospital_id'])
|
||||||
|
|
||||||
|
# Get department
|
||||||
|
department = None
|
||||||
|
if data.get('department_id'):
|
||||||
|
from apps.organizations.models import Department
|
||||||
|
department = Department.objects.get(id=data['department_id'])
|
||||||
|
|
||||||
|
# Get category
|
||||||
|
category = None
|
||||||
|
if data.get('category_id'):
|
||||||
|
category = AppreciationCategory.objects.get(id=data['category_id'])
|
||||||
|
|
||||||
|
# Create appreciation
|
||||||
|
appreciation = Appreciation.objects.create(
|
||||||
|
sender=request.user,
|
||||||
|
recipient_content_type=content_type,
|
||||||
|
recipient_object_id=recipient_id,
|
||||||
|
hospital=hospital,
|
||||||
|
department=department,
|
||||||
|
category=category,
|
||||||
|
message_en=data['message_en'],
|
||||||
|
message_ar=data.get('message_ar', ''),
|
||||||
|
visibility=data['visibility'],
|
||||||
|
is_anonymous=data['is_anonymous'],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send appreciation
|
||||||
|
appreciation.send()
|
||||||
|
|
||||||
|
# Serialize and return
|
||||||
|
serializer = AppreciationSerializer(appreciation)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def acknowledge(self, request, pk=None):
|
||||||
|
"""Acknowledge an appreciation"""
|
||||||
|
appreciation = self.get_object()
|
||||||
|
|
||||||
|
# Check if user is the recipient
|
||||||
|
user_content_type = ContentType.objects.get_for_model(request.user)
|
||||||
|
if not (
|
||||||
|
appreciation.recipient_content_type == user_content_type and
|
||||||
|
appreciation.recipient_object_id == request.user.id
|
||||||
|
):
|
||||||
|
return Response(
|
||||||
|
{'error': 'You can only acknowledge appreciations sent to you'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Acknowledge
|
||||||
|
appreciation.acknowledge()
|
||||||
|
|
||||||
|
# Serialize and return
|
||||||
|
serializer = AppreciationSerializer(appreciation)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def my_appreciations(self, request):
|
||||||
|
"""Get appreciations for the current user"""
|
||||||
|
# Get user's appreciations
|
||||||
|
user_content_type = ContentType.objects.get_for_model(request.user)
|
||||||
|
|
||||||
|
# Check if user has physician profile
|
||||||
|
physician = None
|
||||||
|
if hasattr(request.user, 'physician_profile'):
|
||||||
|
physician = request.user.physician_profile
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
queryset = self.get_queryset().filter(
|
||||||
|
Q(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=request.user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if physician:
|
||||||
|
physician_content_type = ContentType.objects.get_for_model(type(physician))
|
||||||
|
queryset |= self.get_queryset().filter(
|
||||||
|
recipient_content_type=physician_content_type,
|
||||||
|
recipient_object_id=physician.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
page = self.paginate_queryset(queryset)
|
||||||
|
if page is not None:
|
||||||
|
serializer = AppreciationSerializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
serializer = AppreciationSerializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def sent_by_me(self, request):
|
||||||
|
"""Get appreciations sent by the current user"""
|
||||||
|
queryset = self.get_queryset().filter(sender=request.user)
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
page = self.paginate_queryset(queryset)
|
||||||
|
if page is not None:
|
||||||
|
serializer = AppreciationSerializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
serializer = AppreciationSerializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def summary(self, request):
|
||||||
|
"""Get appreciation summary for the current user"""
|
||||||
|
# Get user's content type
|
||||||
|
user_content_type = ContentType.objects.get_for_model(request.user)
|
||||||
|
|
||||||
|
# Get current year and month
|
||||||
|
now = timezone.now()
|
||||||
|
current_year = now.year
|
||||||
|
current_month = now.month
|
||||||
|
|
||||||
|
# Count total received
|
||||||
|
total_received = Appreciation.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=request.user.id
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Count total sent
|
||||||
|
total_sent = Appreciation.objects.filter(
|
||||||
|
sender=request.user
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Count this month received
|
||||||
|
this_month_received = Appreciation.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=request.user.id,
|
||||||
|
sent_at__year=current_year,
|
||||||
|
sent_at__month=current_month
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Count this month sent
|
||||||
|
this_month_sent = Appreciation.objects.filter(
|
||||||
|
sender=request.user,
|
||||||
|
sent_at__year=current_year,
|
||||||
|
sent_at__month=current_month
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Get badges earned
|
||||||
|
badges_earned = UserBadge.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=request.user.id
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Get hospital rank
|
||||||
|
hospital_rank = None
|
||||||
|
if request.user.hospital:
|
||||||
|
# Get stats for this month
|
||||||
|
stats = AppreciationStats.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=request.user.id,
|
||||||
|
year=current_year,
|
||||||
|
month=current_month
|
||||||
|
).first()
|
||||||
|
if stats:
|
||||||
|
hospital_rank = stats.hospital_rank
|
||||||
|
|
||||||
|
# Get top category
|
||||||
|
top_category = None
|
||||||
|
if total_received > 0:
|
||||||
|
top_category_obj = Appreciation.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=request.user.id
|
||||||
|
).values('category__name_en', 'category__icon', 'category__color').annotate(
|
||||||
|
count=Count('id')
|
||||||
|
).order_by('-count').first()
|
||||||
|
|
||||||
|
if top_category_obj and top_category_obj['category__name_en']:
|
||||||
|
top_category = {
|
||||||
|
'name': top_category_obj['category__name_en'],
|
||||||
|
'icon': top_category_obj['category__icon'],
|
||||||
|
'color': top_category_obj['category__color'],
|
||||||
|
'count': top_category_obj['count']
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
summary = {
|
||||||
|
'total_received': total_received,
|
||||||
|
'total_sent': total_sent,
|
||||||
|
'this_month_received': this_month_received,
|
||||||
|
'this_month_sent': this_month_sent,
|
||||||
|
'top_category': top_category,
|
||||||
|
'badges_earned': badges_earned,
|
||||||
|
'hospital_rank': hospital_rank,
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = AppreciationSummarySerializer(summary)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationStatsViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""Viewset for AppreciationStats"""
|
||||||
|
|
||||||
|
queryset = AppreciationStats.objects.all()
|
||||||
|
serializer_class = AppreciationStatsSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter stats based on user's access"""
|
||||||
|
user = self.request.user
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
|
# Filter by hospital
|
||||||
|
if user.hospital:
|
||||||
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
|
||||||
|
# Filter by year and month
|
||||||
|
year = self.request.query_params.get('year')
|
||||||
|
if year:
|
||||||
|
queryset = queryset.filter(year=int(year))
|
||||||
|
|
||||||
|
month = self.request.query_params.get('month')
|
||||||
|
if month:
|
||||||
|
queryset = queryset.filter(month=int(month))
|
||||||
|
|
||||||
|
return queryset.select_related('hospital', 'department')
|
||||||
|
|
||||||
|
|
||||||
|
class AppreciationBadgeViewSet(viewsets.ModelViewSet):
|
||||||
|
"""Viewset for AppreciationBadge"""
|
||||||
|
|
||||||
|
queryset = AppreciationBadge.objects.all()
|
||||||
|
serializer_class = AppreciationBadgeSerializer
|
||||||
|
permission_classes = [IsAuthenticated, IsPXAdminOrHospitalAdmin]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter badges by hospital"""
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
|
# Filter by hospital if provided
|
||||||
|
hospital_id = self.request.query_params.get('hospital_id')
|
||||||
|
if hospital_id:
|
||||||
|
queryset = queryset.filter(Q(hospital_id=hospital_id) | Q(hospital__isnull=True))
|
||||||
|
|
||||||
|
# Only show active badges
|
||||||
|
queryset = queryset.filter(is_active=True)
|
||||||
|
|
||||||
|
return queryset.select_related('hospital')
|
||||||
|
|
||||||
|
|
||||||
|
class UserBadgeViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""Viewset for UserBadge"""
|
||||||
|
|
||||||
|
queryset = UserBadge.objects.all()
|
||||||
|
serializer_class = UserBadgeSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter badges based on user's access"""
|
||||||
|
user = self.request.user
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
|
# Get user's content type
|
||||||
|
user_content_type = ContentType.objects.get_for_model(user)
|
||||||
|
|
||||||
|
# Filter by user or user's physician profile
|
||||||
|
physician = None
|
||||||
|
if hasattr(user, 'physician_profile'):
|
||||||
|
physician = user.physician_profile
|
||||||
|
physician_content_type = ContentType.objects.get_for_model(type(physician))
|
||||||
|
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if physician:
|
||||||
|
queryset |= queryset.filter(
|
||||||
|
recipient_content_type=physician_content_type,
|
||||||
|
recipient_object_id=physician.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset.select_related('badge')
|
||||||
|
|
||||||
|
|
||||||
|
class LeaderboardView(generics.ListAPIView):
|
||||||
|
"""View for appreciation leaderboard"""
|
||||||
|
|
||||||
|
serializer_class = AppreciationLeaderboardSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Build leaderboard"""
|
||||||
|
# Get filters
|
||||||
|
year = self.request.query_params.get('year')
|
||||||
|
month = self.request.query_params.get('month')
|
||||||
|
|
||||||
|
# Default to current month
|
||||||
|
if not year or not month:
|
||||||
|
now = timezone.now()
|
||||||
|
year = now.year
|
||||||
|
month = now.month
|
||||||
|
else:
|
||||||
|
year = int(year)
|
||||||
|
month = int(month)
|
||||||
|
|
||||||
|
# Get hospital from request user
|
||||||
|
user = self.request.user
|
||||||
|
if not user.hospital:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get stats for the period
|
||||||
|
stats = AppreciationStats.objects.filter(
|
||||||
|
hospital=user.hospital,
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
received_count__gt=0
|
||||||
|
).order_by('-received_count')
|
||||||
|
|
||||||
|
# Build leaderboard
|
||||||
|
leaderboard = []
|
||||||
|
for rank, stat in enumerate(stats, start=1):
|
||||||
|
recipient_name = stat.get_recipient_name()
|
||||||
|
recipient_type = stat.recipient_content_type.model if stat.recipient_content_type else 'unknown'
|
||||||
|
|
||||||
|
# Get badges for recipient
|
||||||
|
badges = []
|
||||||
|
user_badges = UserBadge.objects.filter(
|
||||||
|
recipient_content_type=stat.recipient_content_type,
|
||||||
|
recipient_object_id=stat.recipient_object_id
|
||||||
|
).select_related('badge')
|
||||||
|
|
||||||
|
for user_badge in user_badges:
|
||||||
|
badges.append({
|
||||||
|
'name': user_badge.badge.name_en,
|
||||||
|
'icon': user_badge.badge.icon,
|
||||||
|
'color': user_badge.badge.color,
|
||||||
|
})
|
||||||
|
|
||||||
|
leaderboard.append({
|
||||||
|
'rank': rank,
|
||||||
|
'recipient_type': recipient_type,
|
||||||
|
'recipient_id': stat.recipient_object_id,
|
||||||
|
'recipient_name': recipient_name,
|
||||||
|
'hospital': stat.hospital.name,
|
||||||
|
'department': stat.department.name if stat.department else None,
|
||||||
|
'received_count': stat.received_count,
|
||||||
|
'badges': badges,
|
||||||
|
})
|
||||||
|
|
||||||
|
return leaderboard
|
||||||
@ -29,7 +29,7 @@ class Command(BaseCommand):
|
|||||||
if hospital_id:
|
if hospital_id:
|
||||||
try:
|
try:
|
||||||
hospitals = [Hospital.objects.get(id=hospital_id)]
|
hospitals = [Hospital.objects.get(id=hospital_id)]
|
||||||
self.stdout.write(f"Seeding configs for hospital: {hospitals[0].name_en}")
|
self.stdout.write(f"Seeding configs for hospital: {hospitals[0].name}")
|
||||||
except Hospital.DoesNotExist:
|
except Hospital.DoesNotExist:
|
||||||
self.stdout.write(self.style.ERROR(f"Hospital with ID {hospital_id} not found"))
|
self.stdout.write(self.style.ERROR(f"Hospital with ID {hospital_id} not found"))
|
||||||
return
|
return
|
||||||
@ -43,7 +43,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Seed per-hospital configurations
|
# Seed per-hospital configurations
|
||||||
for hospital in hospitals:
|
for hospital in hospitals:
|
||||||
self.stdout.write(f"\nProcessing hospital: {hospital.name_en}")
|
self.stdout.write(f"\nProcessing hospital: {hospital.name}")
|
||||||
self.seed_sla_configs(hospital)
|
self.seed_sla_configs(hospital)
|
||||||
self.seed_thresholds(hospital)
|
self.seed_thresholds(hospital)
|
||||||
self.seed_escalation_rules(hospital)
|
self.seed_escalation_rules(hospital)
|
||||||
|
|||||||
@ -553,6 +553,8 @@ def send_complaint_notification(complaint_id, event_type):
|
|||||||
notification_count = 0
|
notification_count = 0
|
||||||
for recipient in recipients:
|
for recipient in recipients:
|
||||||
try:
|
try:
|
||||||
|
# Check if NotificationService has send_notification method
|
||||||
|
if hasattr(NotificationService, 'send_notification'):
|
||||||
NotificationService.send_notification(
|
NotificationService.send_notification(
|
||||||
recipient=recipient,
|
recipient=recipient,
|
||||||
title=f"Complaint {event_type.title()}: {complaint.title[:50]}",
|
title=f"Complaint {event_type.title()}: {complaint.title[:50]}",
|
||||||
@ -561,6 +563,8 @@ def send_complaint_notification(complaint_id, event_type):
|
|||||||
related_object=complaint
|
related_object=complaint
|
||||||
)
|
)
|
||||||
notification_count += 1
|
notification_count += 1
|
||||||
|
else:
|
||||||
|
logger.warning(f"NotificationService.send_notification method not available")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send notification to {recipient}: {str(e)}")
|
logger.error(f"Failed to send notification to {recipient}: {str(e)}")
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,8 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
|||||||
from django.db.models import Avg, Count, Q
|
from django.db.models import Avg, Count, Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CommandCenterView(LoginRequiredMixin, TemplateView):
|
class CommandCenterView(LoginRequiredMixin, TemplateView):
|
||||||
@ -70,49 +72,49 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
|||||||
# Top KPI Stats
|
# Top KPI Stats
|
||||||
context['stats'] = [
|
context['stats'] = [
|
||||||
{
|
{
|
||||||
'label': 'Active Complaints',
|
'label': _("Active Complaints"),
|
||||||
'value': complaints_qs.filter(status__in=['open', 'in_progress']).count(),
|
'value': complaints_qs.filter(status__in=['open', 'in_progress']).count(),
|
||||||
'icon': 'exclamation-triangle',
|
'icon': 'exclamation-triangle',
|
||||||
'color': 'danger'
|
'color': 'danger'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'label': 'Overdue Complaints',
|
'label': _('Overdue Complaints'),
|
||||||
'value': complaints_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(),
|
'value': complaints_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(),
|
||||||
'icon': 'clock-history',
|
'icon': 'clock-history',
|
||||||
'color': 'warning'
|
'color': 'warning'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'label': 'Open PX Actions',
|
'label': _('Open PX Actions'),
|
||||||
'value': actions_qs.filter(status__in=['open', 'in_progress']).count(),
|
'value': actions_qs.filter(status__in=['open', 'in_progress']).count(),
|
||||||
'icon': 'clipboard-check',
|
'icon': 'clipboard-check',
|
||||||
'color': 'primary'
|
'color': 'primary'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'label': 'Overdue Actions',
|
'label': _('Overdue Actions'),
|
||||||
'value': actions_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(),
|
'value': actions_qs.filter(is_overdue=True, status__in=['open', 'in_progress']).count(),
|
||||||
'icon': 'alarm',
|
'icon': 'alarm',
|
||||||
'color': 'danger'
|
'color': 'danger'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'label': 'Negative Surveys (24h)',
|
'label': _('Negative Surveys (24h)'),
|
||||||
'value': surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count(),
|
'value': surveys_qs.filter(is_negative=True, completed_at__gte=last_24h).count(),
|
||||||
'icon': 'emoji-frown',
|
'icon': 'emoji-frown',
|
||||||
'color': 'warning'
|
'color': 'warning'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'label': 'Negative Social Mentions',
|
'label': _('Negative Social Mentions'),
|
||||||
'value': social_qs.filter(sentiment='negative', posted_at__gte=last_7d).count(),
|
'value': social_qs.filter(sentiment='negative', posted_at__gte=last_7d).count(),
|
||||||
'icon': 'chat-dots',
|
'icon': 'chat-dots',
|
||||||
'color': 'danger'
|
'color': 'danger'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'label': 'Low Call Center Ratings',
|
'label': _('Low Call Center Ratings'),
|
||||||
'value': calls_qs.filter(is_low_rating=True, call_started_at__gte=last_7d).count(),
|
'value': calls_qs.filter(is_low_rating=True, call_started_at__gte=last_7d).count(),
|
||||||
'icon': 'telephone',
|
'icon': 'telephone',
|
||||||
'color': 'warning'
|
'color': 'warning'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'label': 'Avg Survey Score',
|
'label': _('Avg Survey Score'),
|
||||||
'value': f"{surveys_qs.filter(completed_at__gte=last_30d).aggregate(Avg('total_score'))['total_score__avg'] or 0:.1f}",
|
'value': f"{surveys_qs.filter(completed_at__gte=last_30d).aggregate(Avg('total_score'))['total_score__avg'] or 0:.1f}",
|
||||||
'icon': 'star',
|
'icon': 'star',
|
||||||
'color': 'success'
|
'color': 'success'
|
||||||
|
|||||||
18
apps/organizations/migrations/0002_hospital_metadata.py
Normal file
18
apps/organizations/migrations/0002_hospital_metadata.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.14 on 2026-01-01 12:37
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('organizations', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='hospital',
|
||||||
|
name='metadata',
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -29,6 +29,7 @@ class Hospital(UUIDModel, TimeStampedModel):
|
|||||||
# Metadata
|
# Metadata
|
||||||
license_number = models.CharField(max_length=100, blank=True)
|
license_number = models.CharField(max_length=100, blank=True)
|
||||||
capacity = models.IntegerField(null=True, blank=True, help_text="Bed capacity")
|
capacity = models.IntegerField(null=True, blank=True, help_text="Bed capacity")
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
|||||||
@ -63,6 +63,7 @@ LOCAL_APPS = [
|
|||||||
'apps.notifications',
|
'apps.notifications',
|
||||||
'apps.ai_engine',
|
'apps.ai_engine',
|
||||||
'apps.dashboard',
|
'apps.dashboard',
|
||||||
|
'apps.appreciation',
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
|
|||||||
@ -34,12 +34,14 @@ urlpatterns = [
|
|||||||
path('projects/', include('apps.projects.urls')),
|
path('projects/', include('apps.projects.urls')),
|
||||||
path('config/', include('apps.core.config_urls')),
|
path('config/', include('apps.core.config_urls')),
|
||||||
path('ai-engine/', include('apps.ai_engine.urls')),
|
path('ai-engine/', include('apps.ai_engine.urls')),
|
||||||
|
path('appreciation/', include('apps.appreciation.urls', namespace='appreciation')),
|
||||||
|
|
||||||
# API endpoints
|
# API endpoints
|
||||||
path('api/auth/', include('apps.accounts.urls')),
|
path('api/auth/', include('apps.accounts.urls')),
|
||||||
path('api/physicians/', include('apps.physicians.urls')),
|
path('api/physicians/', include('apps.physicians.urls')),
|
||||||
path('api/integrations/', include('apps.integrations.urls')),
|
path('api/integrations/', include('apps.integrations.urls')),
|
||||||
path('api/notifications/', include('apps.notifications.urls')),
|
path('api/notifications/', include('apps.notifications.urls')),
|
||||||
|
path('api/v1/appreciation/', include('apps.appreciation.urls', namespace='api_appreciation')),
|
||||||
|
|
||||||
# OpenAPI/Swagger documentation
|
# OpenAPI/Swagger documentation
|
||||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
Saudi-influenced data generator for PX360
|
Saudi-influenced data generator for PX360
|
||||||
|
|
||||||
@ -21,9 +22,19 @@ os.environ.setdefault('CELERY_TASK_ALWAYS_EAGER', 'True')
|
|||||||
django.setup()
|
django.setup()
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
|
from apps.appreciation.models import (
|
||||||
|
Appreciation,
|
||||||
|
AppreciationCategory,
|
||||||
|
AppreciationBadge,
|
||||||
|
UserBadge,
|
||||||
|
AppreciationStats,
|
||||||
|
AppreciationStatus,
|
||||||
|
AppreciationVisibility,
|
||||||
|
)
|
||||||
from apps.complaints.models import Complaint, ComplaintUpdate
|
from apps.complaints.models import Complaint, ComplaintUpdate
|
||||||
from apps.journeys.models import (
|
from apps.journeys.models import (
|
||||||
PatientJourneyInstance,
|
PatientJourneyInstance,
|
||||||
@ -165,6 +176,12 @@ def clear_existing_data():
|
|||||||
print("Deleting hospitals...")
|
print("Deleting hospitals...")
|
||||||
Hospital.objects.all().delete()
|
Hospital.objects.all().delete()
|
||||||
|
|
||||||
|
print("Deleting appreciation data...")
|
||||||
|
UserBadge.objects.all().delete()
|
||||||
|
AppreciationStats.objects.all().delete()
|
||||||
|
Appreciation.objects.all().delete()
|
||||||
|
# Categories and Badges are preserved (seeded separately)
|
||||||
|
|
||||||
print("Deleting users (except superusers)...")
|
print("Deleting users (except superusers)...")
|
||||||
User.objects.filter(is_superuser=False).delete()
|
User.objects.filter(is_superuser=False).delete()
|
||||||
|
|
||||||
@ -927,6 +944,384 @@ def create_physician_monthly_ratings(physicians):
|
|||||||
return ratings
|
return ratings
|
||||||
|
|
||||||
|
|
||||||
|
def create_appreciations(users, physicians, hospitals, departments, categories):
|
||||||
|
"""Create appreciations with 2 years of historical data"""
|
||||||
|
print("Creating appreciations (2 years of data)...")
|
||||||
|
|
||||||
|
# Get ContentType for User and Physician
|
||||||
|
user_ct = ContentType.objects.get_for_model(User)
|
||||||
|
physician_ct = ContentType.objects.get_for_model(Physician)
|
||||||
|
|
||||||
|
# Message templates for generating realistic appreciations
|
||||||
|
message_templates_en = [
|
||||||
|
"Thank you for {action}. Your {quality} made a real difference in our patient's care.",
|
||||||
|
"I want to express my sincere appreciation for {action}. Your {quality} is truly exceptional.",
|
||||||
|
"Outstanding work! Your {action} and {quality} have not gone unnoticed.",
|
||||||
|
"Grateful for your {action}. The {quality} you demonstrate every day inspires us all.",
|
||||||
|
"A big thank you for {action}. Your {quality} sets an excellent example.",
|
||||||
|
"Exceptional job on {action}. Your {quality} is invaluable to our team.",
|
||||||
|
"Words cannot express how much I appreciate your {action}. Your {quality} shines through.",
|
||||||
|
"Thank you for going above and beyond with {action}. Your {quality} makes our hospital better.",
|
||||||
|
"Deeply grateful for your {action}. The {quality} you show is remarkable.",
|
||||||
|
"Your {action} has made a significant impact. Your {quality} is truly appreciated.",
|
||||||
|
]
|
||||||
|
|
||||||
|
message_templates_ar = [
|
||||||
|
"شكراً لك على {action}. {quality} الخاص بك أحدث فرقاً حقيقياً في رعاية مرضانا.",
|
||||||
|
"أود أن أعرب عن تقديري الخالص لـ {action}. {quality} استثنائي حقاً.",
|
||||||
|
"عمل رائع! {action} و{quality} لم يمرا مرور الكرام.",
|
||||||
|
"ممتن لـ {action}. {quality} الذي تظهره كل يوم يلهمنا جميعاً.",
|
||||||
|
"شكراً جزيلاً على {action}. {quality} يضع مثالاً ممتازاً.",
|
||||||
|
"عمل استثنائي في {action}. {quality} لا يقدر بثمن لفريقنا.",
|
||||||
|
"الكلمات لا تعبر عن مدى تقديري لـ {action}. {quality} يظهر بوضوح.",
|
||||||
|
"شكراً لتجاوز التوقعات مع {action}. {quality} يجعل مستشفانا أفضل.",
|
||||||
|
"عميق الامتنان لـ {action}. {quality} الذي تظهره مذهل.",
|
||||||
|
"لقد حدث {action} تأثيراً كبيراً. {quality} حقاً مقدر.",
|
||||||
|
]
|
||||||
|
|
||||||
|
actions = [
|
||||||
|
"providing excellent patient care", "outstanding teamwork", "quick response in emergency",
|
||||||
|
"thorough diagnosis", "compassionate patient interaction", "efficient workflow management",
|
||||||
|
"mentoring junior staff", "innovative solution implementation", "attention to detail",
|
||||||
|
"going the extra mile", "maintaining patient safety", "clear communication",
|
||||||
|
"timely treatment", "excellent bedside manner", "professional conduct",
|
||||||
|
]
|
||||||
|
|
||||||
|
qualities = [
|
||||||
|
"professionalism", "dedication", "expertise", "compassion", "attention to detail",
|
||||||
|
"efficiency", "leadership", "teamwork", "reliability", "positive attitude",
|
||||||
|
"patience", "kindness", "commitment", "excellence", "integrity",
|
||||||
|
]
|
||||||
|
|
||||||
|
appreciations = []
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
# Weighted category distribution
|
||||||
|
category_weights = {
|
||||||
|
'excellent_care': 30,
|
||||||
|
'team_player': 20,
|
||||||
|
'going_extra_mile': 15,
|
||||||
|
'positive_attitude': 12,
|
||||||
|
'leadership': 10,
|
||||||
|
'innovation': 8,
|
||||||
|
'reliability': 3,
|
||||||
|
'mentorship': 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create category code mapping
|
||||||
|
category_map = {cat.code: cat for cat in categories}
|
||||||
|
|
||||||
|
# Generate appreciations over 2 years (730 days)
|
||||||
|
# Average 1-2 appreciations per day = ~800-1200 total
|
||||||
|
for day_offset in range(730):
|
||||||
|
# Recency bias: more appreciations in recent months
|
||||||
|
recency_factor = max(0.5, 1.0 - (day_offset / 1460)) # 0.5 to 1.0
|
||||||
|
|
||||||
|
# Number of appreciations per day (weighted by recency)
|
||||||
|
base_count = random.choices([0, 1, 2], weights=[30, 50, 20])[0]
|
||||||
|
num_appreciations = max(0, int(base_count * recency_factor))
|
||||||
|
|
||||||
|
for _ in range(num_appreciations):
|
||||||
|
# Select sender (users only - users send appreciations)
|
||||||
|
sender = random.choice(users)
|
||||||
|
|
||||||
|
# Select recipient (70% physician, 30% user)
|
||||||
|
is_physician_recipient = random.random() < 0.7
|
||||||
|
|
||||||
|
if is_physician_recipient:
|
||||||
|
recipient = random.choice(physicians)
|
||||||
|
recipient_ct = physician_ct
|
||||||
|
else:
|
||||||
|
# User recipients (excluding sender)
|
||||||
|
potential_recipients = [u for u in users if u.id != sender.id]
|
||||||
|
if not potential_recipients:
|
||||||
|
continue
|
||||||
|
recipient = random.choice(potential_recipients)
|
||||||
|
recipient_ct = user_ct
|
||||||
|
|
||||||
|
# Ensure sender ≠ recipient
|
||||||
|
if sender.id == recipient.id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine hospital context
|
||||||
|
if is_physician_recipient:
|
||||||
|
hospital = recipient.hospital
|
||||||
|
else:
|
||||||
|
hospital = recipient.hospital if recipient.hospital else sender.hospital
|
||||||
|
|
||||||
|
if not hospital:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine department context
|
||||||
|
department = None
|
||||||
|
if is_physician_recipient and recipient.department:
|
||||||
|
department = recipient.department
|
||||||
|
elif random.random() < 0.3:
|
||||||
|
# Some appreciations have department context
|
||||||
|
hospital_depts = [d for d in departments if d.hospital == hospital]
|
||||||
|
if hospital_depts:
|
||||||
|
department = random.choice(hospital_depts)
|
||||||
|
|
||||||
|
# Select category (weighted distribution)
|
||||||
|
category_codes = list(category_map.keys())
|
||||||
|
weights = [category_weights[code] for code in category_codes]
|
||||||
|
category_code = random.choices(category_codes, weights=weights, k=1)[0]
|
||||||
|
category = category_map.get(category_code)
|
||||||
|
|
||||||
|
if not category:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Generate message
|
||||||
|
action = random.choice(actions)
|
||||||
|
quality = random.choice(qualities)
|
||||||
|
message_en = random.choice(message_templates_en).format(action=action, quality=quality)
|
||||||
|
message_ar = random.choice(message_templates_ar).format(action=action, quality=quality)
|
||||||
|
|
||||||
|
# Select visibility (40% private, 25% dept, 25% hospital, 10% public)
|
||||||
|
visibility = random.choices(
|
||||||
|
list(AppreciationVisibility.values),
|
||||||
|
weights=[40, 25, 25, 10],
|
||||||
|
k=1
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
# Determine status based on age
|
||||||
|
if day_offset < 1: # Last 24 hours
|
||||||
|
status = random.choices(
|
||||||
|
list(AppreciationStatus.values),
|
||||||
|
weights=[20, 50, 30], # draft, sent, acknowledged
|
||||||
|
k=1
|
||||||
|
)[0]
|
||||||
|
elif day_offset < 7: # Last week
|
||||||
|
status = random.choices(
|
||||||
|
list(AppreciationStatus.values),
|
||||||
|
weights=[5, 40, 55],
|
||||||
|
k=1
|
||||||
|
)[0]
|
||||||
|
else: # Older
|
||||||
|
status = random.choices(
|
||||||
|
list(AppreciationStatus.values),
|
||||||
|
weights=[1, 10, 89],
|
||||||
|
k=1
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
# Calculate timestamps
|
||||||
|
created_date = now - timedelta(
|
||||||
|
days=day_offset,
|
||||||
|
hours=random.randint(0, 23),
|
||||||
|
minutes=random.randint(0, 59)
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_at = None
|
||||||
|
acknowledged_at = None
|
||||||
|
|
||||||
|
if status != AppreciationStatus.DRAFT:
|
||||||
|
# Sent time: 0-24 hours after creation (for older appreciations)
|
||||||
|
if day_offset < 1:
|
||||||
|
sent_delay = random.randint(0, 23)
|
||||||
|
else:
|
||||||
|
sent_delay = random.randint(1, 24)
|
||||||
|
sent_at = created_date + timedelta(hours=sent_delay)
|
||||||
|
|
||||||
|
if status == AppreciationStatus.ACKNOWLEDGED:
|
||||||
|
# Acknowledged time: 1-72 hours after sent
|
||||||
|
acknowledge_delay = random.randint(1, 72)
|
||||||
|
acknowledged_at = sent_at + timedelta(hours=acknowledge_delay)
|
||||||
|
|
||||||
|
# Anonymous option (15% anonymous)
|
||||||
|
is_anonymous = random.random() < 0.15
|
||||||
|
|
||||||
|
# Create appreciation
|
||||||
|
appreciation = Appreciation(
|
||||||
|
sender=sender if not is_anonymous else None,
|
||||||
|
recipient_content_type=recipient_ct,
|
||||||
|
recipient_object_id=recipient.id,
|
||||||
|
hospital=hospital,
|
||||||
|
department=department,
|
||||||
|
category=category,
|
||||||
|
message_en=message_en,
|
||||||
|
message_ar=message_ar,
|
||||||
|
visibility=visibility,
|
||||||
|
status=status,
|
||||||
|
is_anonymous=is_anonymous,
|
||||||
|
sent_at=sent_at,
|
||||||
|
acknowledged_at=acknowledged_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Override created_at for historical data
|
||||||
|
appreciation.created_at = created_date
|
||||||
|
appreciation.save()
|
||||||
|
|
||||||
|
appreciations.append(appreciation)
|
||||||
|
|
||||||
|
print(f" Created {len(appreciations)} appreciations over 2 years")
|
||||||
|
return appreciations
|
||||||
|
|
||||||
|
|
||||||
|
def award_badges(badges, users, physicians, categories):
|
||||||
|
"""Award badges to users and physicians based on appreciations"""
|
||||||
|
print("Awarding badges...")
|
||||||
|
user_badges = []
|
||||||
|
|
||||||
|
# Get ContentType for User and Physician
|
||||||
|
user_ct = ContentType.objects.get_for_model(User)
|
||||||
|
physician_ct = ContentType.objects.get_for_model(Physician)
|
||||||
|
|
||||||
|
# Badge criteria mapping (using codes from seed command)
|
||||||
|
badge_criteria = {
|
||||||
|
'first_appreciation': {'min_count': 1, 'categories': None},
|
||||||
|
'appreciated_5': {'min_count': 5, 'categories': None},
|
||||||
|
'appreciated_10': {'min_count': 10, 'categories': None},
|
||||||
|
'appreciated_25': {'min_count': 25, 'categories': None},
|
||||||
|
'appreciated_50': {'min_count': 50, 'categories': None},
|
||||||
|
# Monthly champion and other badges have complex criteria, award randomly
|
||||||
|
'monthly_champion': {'min_count': 10, 'categories': None},
|
||||||
|
'streak_4_weeks': {'min_count': 15, 'categories': None},
|
||||||
|
'diverse_appreciation': {'min_count': 20, 'categories': None},
|
||||||
|
}
|
||||||
|
|
||||||
|
badge_map = {badge.code: badge for badge in badges}
|
||||||
|
|
||||||
|
# Award badges to users (30% get badges)
|
||||||
|
for user in users:
|
||||||
|
if random.random() > 0.7: # 30% of users get badges
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Count appreciations received by this user
|
||||||
|
received_count = Appreciation.objects.filter(
|
||||||
|
recipient_content_type=user_ct,
|
||||||
|
recipient_object_id=user.id,
|
||||||
|
status=AppreciationStatus.ACKNOWLEDGED
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Award appropriate badges
|
||||||
|
for badge_code, criteria in badge_criteria.items():
|
||||||
|
if badge_code not in badge_map:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if received_count >= criteria['min_count']:
|
||||||
|
# Check if already has this badge
|
||||||
|
existing = UserBadge.objects.filter(
|
||||||
|
recipient_content_type=user_ct,
|
||||||
|
recipient_object_id=user.id,
|
||||||
|
badge=badge_map[badge_code]
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# Random chance to award (not guaranteed even if criteria met)
|
||||||
|
if random.random() < 0.6:
|
||||||
|
user_badges.append(UserBadge(
|
||||||
|
recipient_content_type=user_ct,
|
||||||
|
recipient_object_id=user.id,
|
||||||
|
badge=badge_map[badge_code],
|
||||||
|
appreciation_count=received_count,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Award badges to physicians (60% get badges)
|
||||||
|
for physician in physicians:
|
||||||
|
if random.random() > 0.6: # 60% of physicians get badges
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Count appreciations received by this physician
|
||||||
|
received_count = Appreciation.objects.filter(
|
||||||
|
recipient_content_type=physician_ct,
|
||||||
|
recipient_object_id=physician.id,
|
||||||
|
status=AppreciationStatus.ACKNOWLEDGED
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Award appropriate badges
|
||||||
|
for badge_code, criteria in badge_criteria.items():
|
||||||
|
if badge_code not in badge_map:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if received_count >= criteria['min_count']:
|
||||||
|
# Check if already has this badge
|
||||||
|
existing = UserBadge.objects.filter(
|
||||||
|
recipient_content_type=physician_ct,
|
||||||
|
recipient_object_id=physician.id,
|
||||||
|
badge=badge_map[badge_code]
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# Higher chance for physicians
|
||||||
|
if random.random() < 0.7:
|
||||||
|
user_badges.append(UserBadge(
|
||||||
|
recipient_content_type=physician_ct,
|
||||||
|
recipient_object_id=physician.id,
|
||||||
|
badge=badge_map[badge_code],
|
||||||
|
appreciation_count=received_count,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Bulk create user badges
|
||||||
|
UserBadge.objects.bulk_create(user_badges)
|
||||||
|
|
||||||
|
print(f" Awarded {len(user_badges)} badges to users and physicians")
|
||||||
|
return user_badges
|
||||||
|
|
||||||
|
|
||||||
|
def generate_appreciation_stats(users, physicians, hospitals):
|
||||||
|
"""Generate appreciation statistics for users and physicians"""
|
||||||
|
print("Generating appreciation statistics...")
|
||||||
|
stats = []
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
# Get ContentType for User and Physician
|
||||||
|
user_ct = ContentType.objects.get_for_model(User)
|
||||||
|
physician_ct = ContentType.objects.get_for_model(Physician)
|
||||||
|
|
||||||
|
# Get current year and month
|
||||||
|
year = now.year
|
||||||
|
month = now.month
|
||||||
|
|
||||||
|
# Generate stats for users (70% have stats)
|
||||||
|
for user in users:
|
||||||
|
if random.random() > 0.7:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Generate realistic stats
|
||||||
|
received_count = random.randint(0, 30)
|
||||||
|
sent_count = random.randint(0, 50)
|
||||||
|
acknowledged_count = int(received_count * random.uniform(0.6, 1.0))
|
||||||
|
|
||||||
|
stats.append(AppreciationStats(
|
||||||
|
recipient_content_type=user_ct,
|
||||||
|
recipient_object_id=user.id,
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
hospital=user.hospital if user.hospital else random.choice(hospitals),
|
||||||
|
received_count=received_count,
|
||||||
|
sent_count=sent_count,
|
||||||
|
acknowledged_count=acknowledged_count,
|
||||||
|
hospital_rank=random.randint(1, 20) if received_count > 0 else None,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Generate stats for physicians (90% have stats)
|
||||||
|
for physician in physicians:
|
||||||
|
if random.random() > 0.1: # 90% have stats
|
||||||
|
# Physicians typically receive more appreciations
|
||||||
|
received_count = random.randint(5, 50)
|
||||||
|
sent_count = random.randint(0, 20) # Physicians send less
|
||||||
|
acknowledged_count = int(received_count * random.uniform(0.7, 1.0))
|
||||||
|
|
||||||
|
stats.append(AppreciationStats(
|
||||||
|
recipient_content_type=physician_ct,
|
||||||
|
recipient_object_id=physician.id,
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
hospital=physician.hospital,
|
||||||
|
department=physician.department,
|
||||||
|
received_count=received_count,
|
||||||
|
sent_count=sent_count,
|
||||||
|
acknowledged_count=acknowledged_count,
|
||||||
|
hospital_rank=random.randint(1, 10) if received_count > 5 else None,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Bulk create stats
|
||||||
|
AppreciationStats.objects.bulk_create(stats)
|
||||||
|
|
||||||
|
print(f" Generated {len(stats)} appreciation statistics")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main data generation function"""
|
"""Main data generation function"""
|
||||||
print("\n" + "="*60)
|
print("\n" + "="*60)
|
||||||
@ -960,6 +1355,27 @@ def main():
|
|||||||
social_mentions = create_social_mentions(hospitals)
|
social_mentions = create_social_mentions(hospitals)
|
||||||
physician_ratings = create_physician_monthly_ratings(physicians)
|
physician_ratings = create_physician_monthly_ratings(physicians)
|
||||||
|
|
||||||
|
# Get appreciation categories and badges (should be seeded)
|
||||||
|
categories = list(AppreciationCategory.objects.all())
|
||||||
|
badges = list(AppreciationBadge.objects.all())
|
||||||
|
|
||||||
|
if not categories or not badges:
|
||||||
|
print("\n" + "!"*60)
|
||||||
|
print("WARNING: Appreciation categories or badges not found!")
|
||||||
|
print("Please run: python manage.py seed_appreciation_data")
|
||||||
|
print("!"*60 + "\n")
|
||||||
|
|
||||||
|
# Create appreciation data
|
||||||
|
if categories and badges:
|
||||||
|
appreciations = create_appreciations(users, physicians, hospitals, departments, categories)
|
||||||
|
user_badges = award_badges(badges, users, physicians, categories)
|
||||||
|
# Stats are auto-generated by signals, so we don't need to create them manually
|
||||||
|
appreciation_stats = list(AppreciationStats.objects.all())
|
||||||
|
else:
|
||||||
|
appreciations = []
|
||||||
|
user_badges = []
|
||||||
|
appreciation_stats = []
|
||||||
|
|
||||||
print("\n" + "="*60)
|
print("\n" + "="*60)
|
||||||
print("Data Generation Complete!")
|
print("Data Generation Complete!")
|
||||||
print("="*60)
|
print("="*60)
|
||||||
@ -979,6 +1395,9 @@ def main():
|
|||||||
print(f" - {len(social_mentions)} Social Media Mentions")
|
print(f" - {len(social_mentions)} Social Media Mentions")
|
||||||
print(f" - {len(projects)} QI Projects")
|
print(f" - {len(projects)} QI Projects")
|
||||||
print(f" - {len(physician_ratings)} Physician Monthly Ratings")
|
print(f" - {len(physician_ratings)} Physician Monthly Ratings")
|
||||||
|
print(f" - {len(appreciations)} Appreciations (2 years)")
|
||||||
|
print(f" - {len(user_badges)} Badges Awarded")
|
||||||
|
print(f" - {len(appreciation_stats)} Appreciation Statistics")
|
||||||
print(f"\nYou can now login with:")
|
print(f"\nYou can now login with:")
|
||||||
print(f" Username: px_admin")
|
print(f" Username: px_admin")
|
||||||
print(f" Password: admin123")
|
print(f" Password: admin123")
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
199
templates/appreciation/appreciation_detail.html
Normal file
199
templates/appreciation/appreciation_detail.html
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Appreciation Details" %} - {% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{% trans "Details" %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Appreciation Details Card -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Category Badge -->
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div>
|
||||||
|
{% if appreciation.category %}
|
||||||
|
<span class="badge bg-primary fs-6 mb-2">
|
||||||
|
<i class="{{ appreciation.category.icon|default:'fa-heart' }} me-2"></i>
|
||||||
|
{{ appreciation.category.name_en }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<h2 class="h4 mb-1">{{ appreciation.message_en }}</h2>
|
||||||
|
{% if appreciation.message_ar %}
|
||||||
|
<p class="text-muted mb-0" dir="rtl">{{ appreciation.message_ar }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-{% if appreciation.status == 'acknowledged' %}success{% else %}primary{% endif %}">
|
||||||
|
{{ appreciation.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Sender and Recipient Info -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted mb-2">{% trans "From" %}</h6>
|
||||||
|
{% if appreciation.is_anonymous %}
|
||||||
|
<p class="mb-0">
|
||||||
|
<i class="fas fa-user-secret text-muted me-2"></i>
|
||||||
|
{% trans "Anonymous" %}
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="mb-0">
|
||||||
|
<i class="fas fa-user text-primary me-2"></i>
|
||||||
|
{{ appreciation.sender.get_full_name }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted mb-2">{% trans "To" %}</h6>
|
||||||
|
<p class="mb-0">
|
||||||
|
<i class="fas fa-user text-success me-2"></i>
|
||||||
|
{{ appreciation.recipient_name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Details -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted mb-2">{% trans "Sent At" %}</h6>
|
||||||
|
<p class="mb-0">
|
||||||
|
<i class="fas fa-clock text-info me-2"></i>
|
||||||
|
{{ appreciation.sent_at|date:"F j, Y, g:i A" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted mb-2">{% trans "Visibility" %}</h6>
|
||||||
|
<p class="mb-0">
|
||||||
|
<i class="fas fa-eye text-warning me-2"></i>
|
||||||
|
{{ appreciation.get_visibility_display }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if appreciation.acknowledged_at %}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<i class="fas fa-check-circle me-2"></i>
|
||||||
|
{% trans "Acknowledged on" %} {{ appreciation.acknowledged_at|date:"F j, Y, g:i A" }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="d-flex gap-2 mt-4">
|
||||||
|
{% if is_recipient and appreciation.status == 'sent' %}
|
||||||
|
<form method="post" action="{% url 'appreciation:appreciation_acknowledge' appreciation.id %}" class="d-inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-success">
|
||||||
|
<i class="fas fa-check me-2"></i>
|
||||||
|
{% trans "Acknowledge" %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'appreciation:appreciation_list' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>
|
||||||
|
{% trans "Back to List" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Related Appreciations -->
|
||||||
|
{% if related %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<i class="fas fa-link me-2"></i>
|
||||||
|
{% trans "Related Appreciations" %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for rel in related %}
|
||||||
|
<a href="{% url 'appreciation:appreciation_detail' rel.id %}" class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h6 class="mb-1">{{ rel.message_en|truncatewords:10 }}</h6>
|
||||||
|
<small class="text-muted">{{ rel.sent_at|date:"M d, Y" }}</small>
|
||||||
|
</div>
|
||||||
|
<p class="mb-1 small text-muted">
|
||||||
|
<i class="fas fa-user me-1"></i>
|
||||||
|
{{ rel.sender.get_full_name }}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0">{% trans "Quick Info" %}</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
<li class="mb-2">
|
||||||
|
<i class="fas fa-building text-muted me-2"></i>
|
||||||
|
<strong>{% trans "Hospital:" %}</strong> {{ appreciation.hospital.name }}
|
||||||
|
</li>
|
||||||
|
{% if appreciation.department %}
|
||||||
|
<li class="mb-2">
|
||||||
|
<i class="fas fa-sitemap text-muted me-2"></i>
|
||||||
|
<strong>{% trans "Department:" %}</strong> {{ appreciation.department.name }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-id-badge text-muted me-2"></i>
|
||||||
|
<strong>{% trans "ID:" %}</strong> #{{ appreciation.id }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0">{% trans "Actions" %}</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="{% url 'appreciation:appreciation_send' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-paper-plane me-2"></i>
|
||||||
|
{% trans "Send Appreciation" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'appreciation:leaderboard_view' %}" class="btn btn-info">
|
||||||
|
<i class="fas fa-trophy me-2"></i>
|
||||||
|
{% trans "View Leaderboard" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'appreciation:my_badges_view' %}" class="btn btn-warning">
|
||||||
|
<i class="fas fa-award me-2"></i>
|
||||||
|
{% trans "My Badges" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script>
|
||||||
|
// Add any appreciation detail-specific JavaScript here
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
350
templates/appreciation/appreciation_list.html
Normal file
350
templates/appreciation/appreciation_list.html
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Appreciation" %} - {% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h2 class="h4 mb-0">
|
||||||
|
<i class="fas fa-heart text-danger me-2"></i>
|
||||||
|
{% trans "Appreciation" %}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted mb-0 mt-2">
|
||||||
|
{% trans "Send appreciation to colleagues and celebrate achievements" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'appreciation:appreciation_send' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-paper-plane me-2"></i>
|
||||||
|
{% trans "Send Appreciation" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm border-0">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="bg-primary bg-opacity-10 p-3 rounded-circle me-3">
|
||||||
|
<i class="fas fa-inbox text-primary fa-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-1">{% trans "Received" %}</h6>
|
||||||
|
<h3 class="mb-0" id="receivedCount">{{ stats.received }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm border-0">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="bg-success bg-opacity-10 p-3 rounded-circle me-3">
|
||||||
|
<i class="fas fa-paper-plane text-success fa-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-1">{% trans "Sent" %}</h6>
|
||||||
|
<h3 class="mb-0" id="sentCount">{{ stats.sent }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm border-0">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="bg-warning bg-opacity-10 p-3 rounded-circle me-3">
|
||||||
|
<i class="fas fa-award text-warning fa-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-1">{% trans "Badges Earned" %}</h6>
|
||||||
|
<h3 class="mb-0" id="badgesCount">{{ stats.badges_earned }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm border-0">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="bg-info bg-opacity-10 p-3 rounded-circle me-3">
|
||||||
|
<i class="fas fa-trophy text-info fa-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-1">{% trans "Leaderboard" %}</h6>
|
||||||
|
<a href="{% url 'appreciation:leaderboard_view' %}" class="btn btn-sm btn-outline-info">
|
||||||
|
<i class="fas fa-arrow-right me-1"></i>
|
||||||
|
{% trans "View" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<a href="{% url 'appreciation:leaderboard_view' %}" class="text-decoration-none">
|
||||||
|
<div class="card shadow-sm border-warning mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="bg-warning bg-opacity-10 p-3 rounded-circle me-3">
|
||||||
|
<i class="fas fa-trophy text-warning fa-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">{% trans "Leaderboard" %}</h6>
|
||||||
|
<small class="text-muted">{% trans "See top performers" %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<a href="{% url 'appreciation:my_badges_view' %}" class="text-decoration-none">
|
||||||
|
<div class="card shadow-sm border-primary mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="bg-primary bg-opacity-10 p-3 rounded-circle me-3">
|
||||||
|
<i class="fas fa-award text-primary fa-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">{% trans "My Badges" %}</h6>
|
||||||
|
<small class="text-muted">{% trans "View earned badges" %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<a href="{% url 'appreciation:appreciation_send' %}" class="text-decoration-none">
|
||||||
|
<div class="card shadow-sm border-success mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="bg-success bg-opacity-10 p-3 rounded-circle me-3">
|
||||||
|
<i class="fas fa-paper-plane text-success fa-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">{% trans "Send Appreciation" %}</h6>
|
||||||
|
<small class="text-muted">{% trans "Share appreciation" %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="tab" class="form-label">{% trans "View" %}</label>
|
||||||
|
<select class="form-select" id="tab" name="tab" onchange="this.form.submit()">
|
||||||
|
<option value="received" {% if current_tab == 'received' %}selected{% endif %}>
|
||||||
|
{% trans "My Appreciations" %}
|
||||||
|
</option>
|
||||||
|
<option value="sent" {% if current_tab == 'sent' %}selected{% endif %}>
|
||||||
|
{% trans "Sent by Me" %}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="status" class="form-label">{% trans "Status" %}</label>
|
||||||
|
<select class="form-select" id="status" name="status" onchange="this.form.submit()">
|
||||||
|
<option value="">{% trans "All Status" %}</option>
|
||||||
|
{% for choice in status_choices %}
|
||||||
|
<option value="{{ choice.0 }}" {% if filters.status == choice.0 %}selected{% endif %}>
|
||||||
|
{{ choice.1 }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="category" class="form-label">{% trans "Category" %}</label>
|
||||||
|
<select class="form-select" id="category" name="category" onchange="this.form.submit()">
|
||||||
|
<option value="">{% trans "All Categories" %}</option>
|
||||||
|
{% for cat in categories %}
|
||||||
|
<option value="{{ cat.id }}" {% if filters.category == cat.id|stringformat:"s" %}selected{% endif %}>
|
||||||
|
{{ cat.name_en }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="search" class="form-label">{% trans "Search" %}</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="search" name="search"
|
||||||
|
value="{{ filters.search|default:'' }}" placeholder="{% trans 'Search messages...' %}">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<a href="{% url 'appreciation:appreciation_list' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-redo me-2"></i>
|
||||||
|
{% trans "Clear Filters" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Appreciations List -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if page_obj %}
|
||||||
|
<div class="list-group">
|
||||||
|
{% for appreciation in page_obj %}
|
||||||
|
<a href="{% url 'appreciation:appreciation_detail' appreciation.id %}"
|
||||||
|
class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between align-items-start">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
{% if appreciation.category %}
|
||||||
|
<span class="badge bg-primary me-2">
|
||||||
|
<i class="{{ appreciation.category.icon|default:'fa-heart' }} me-1"></i>
|
||||||
|
{{ appreciation.category.name_en }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<h6 class="mb-0">
|
||||||
|
{% if current_tab == 'received' %}
|
||||||
|
{{ appreciation.sender.get_full_name }}
|
||||||
|
{% else %}
|
||||||
|
{{ appreciation.recipient_name }}
|
||||||
|
{% endif %}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<p class="mb-2 text-muted">{{ appreciation.message_en|truncatewords:15 }}</p>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fas fa-clock me-1"></i>
|
||||||
|
{{ appreciation.sent_at|date:"F j, Y, g:i A" }}
|
||||||
|
{% if appreciation.department %}
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<i class="fas fa-building me-1"></i>
|
||||||
|
{{ appreciation.department.name }}
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<span class="badge bg-{% if appreciation.status == 'acknowledged' %}success{% else %}primary{% endif %}">
|
||||||
|
{{ appreciation.get_status_display }}
|
||||||
|
</span>
|
||||||
|
<div class="small text-muted mt-1">
|
||||||
|
{{ appreciation.get_visibility_display }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if page_obj.has_other_pages %}
|
||||||
|
<nav aria-label="Page navigation" class="mt-4">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}&tab={{ current_tab }}{% if filters.urlencode %}&{{ filters.urlencode }}{% endif %}">
|
||||||
|
{% trans "Previous" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link">{% trans "Previous" %}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for num in page_obj.paginator.page_range %}
|
||||||
|
{% if page_obj.number == num %}
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ num }}</span>
|
||||||
|
</li>
|
||||||
|
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ num }}&tab={{ current_tab }}{% if filters.urlencode %}&{{ filters.urlencode }}{% endif %}">{{ num }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}&tab={{ current_tab }}{% if filters.urlencode %}&{{ filters.urlencode }}{% endif %}">
|
||||||
|
{% trans "Next" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link">{% trans "Next" %}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fas fa-heart text-muted fa-4x mb-3"></i>
|
||||||
|
<h4 class="text-muted">
|
||||||
|
{% if current_tab == 'received' %}
|
||||||
|
{% trans "No appreciations received yet" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "No appreciations sent yet" %}
|
||||||
|
{% endif %}
|
||||||
|
</h4>
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
{% trans "Start sharing appreciation with your colleagues!" %}
|
||||||
|
</p>
|
||||||
|
<a href="{% url 'appreciation:appreciation_send' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-paper-plane me-2"></i>
|
||||||
|
{% trans "Send Your First Appreciation" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script>
|
||||||
|
// Refresh summary statistics periodically (every 30 seconds)
|
||||||
|
function refreshSummary() {
|
||||||
|
fetch("{% url 'appreciation:appreciation_summary_ajax' %}")
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('receivedCount').textContent = data.total_received || 0;
|
||||||
|
document.getElementById('sentCount').textContent = data.total_sent || 0;
|
||||||
|
document.getElementById('badgesCount').textContent = data.badges_earned || 0;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error refreshing summary:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh summary every 30 seconds
|
||||||
|
setInterval(refreshSummary, 30000);
|
||||||
|
|
||||||
|
// Initial summary refresh after page load
|
||||||
|
setTimeout(refreshSummary, 1000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
326
templates/appreciation/appreciation_send_form.html
Normal file
326
templates/appreciation/appreciation_send_form.html
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Send Appreciation" %} - {% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{% trans "Send Appreciation" %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Send Appreciation Form -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="fas fa-paper-plane me-2"></i>
|
||||||
|
{% trans "Send Appreciation" %}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" id="appreciationForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<!-- Recipient Type -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="recipient_type" class="form-label">
|
||||||
|
{% trans "Recipient Type" %} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="recipient_type" name="recipient_type" required>
|
||||||
|
<option value="user">{% trans "User" %}</option>
|
||||||
|
<option value="physician">{% trans "Physician" %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="hospital_id" class="form-label">
|
||||||
|
{% trans "Hospital" %} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="hospital_id" name="hospital_id" required>
|
||||||
|
<option value="">-- {% trans "Select Hospital" %} --</option>
|
||||||
|
{% for hospital in hospitals %}
|
||||||
|
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipient -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="recipient_id" class="form-label">
|
||||||
|
{% trans "Recipient" %} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="recipient_id" name="recipient_id" required disabled>
|
||||||
|
<option value="">-- {% trans "Select Recipient" %} --</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text" id="recipientHelp">{% trans "Select a hospital first" %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Department (Optional) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="department_id" class="form-label">{% trans "Department" %}</label>
|
||||||
|
<select class="form-select" id="department_id" name="department_id">
|
||||||
|
<option value="">-- {% trans "Select Department" %} --</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">{% trans "Optional: Select if related to a specific department" %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="category_id" class="form-label">{% trans "Category" %}</label>
|
||||||
|
<select class="form-select" id="category_id" name="category_id">
|
||||||
|
<option value="">-- {% trans "Select Category" %} --</option>
|
||||||
|
{% for category in categories %}
|
||||||
|
<option value="{{ category.id }}">
|
||||||
|
<i class="{{ category.icon }}"></i> {{ category.name_en }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message (English) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="message_en" class="form-label">
|
||||||
|
{% trans "Message (English)" %} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="message_en"
|
||||||
|
name="message_en"
|
||||||
|
rows="4"
|
||||||
|
required
|
||||||
|
placeholder="{% trans 'Write your appreciation message here...' %}"
|
||||||
|
></textarea>
|
||||||
|
<div class="form-text">{% trans "Required: Appreciation message in English" %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message (Arabic) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="message_ar" class="form-label">{% trans "Message (Arabic)" %}</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="message_ar"
|
||||||
|
name="message_ar"
|
||||||
|
rows="4"
|
||||||
|
dir="rtl"
|
||||||
|
placeholder="{% trans 'اكتب رسالة التقدير هنا...' %}"
|
||||||
|
></textarea>
|
||||||
|
<div class="form-text">{% trans "Optional: Appreciation message in Arabic" %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visibility -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="visibility" class="form-label">{% trans "Visibility" %}</label>
|
||||||
|
<select class="form-select" id="visibility" name="visibility">
|
||||||
|
{% for choice in visibility_choices %}
|
||||||
|
<option value="{{ choice.0 }}" {% if choice.0 == 'private' %}selected{% endif %}>
|
||||||
|
{{ choice.1 }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Anonymous -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="is_anonymous"
|
||||||
|
name="is_anonymous"
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for="is_anonymous">
|
||||||
|
{% trans "Send anonymously" %}
|
||||||
|
</label>
|
||||||
|
<div class="form-text">{% trans "Your name will not be shown to the recipient" %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||||
|
<a href="{% url 'appreciation:appreciation_list' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-times me-2"></i>
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-success">
|
||||||
|
<i class="fas fa-paper-plane me-2"></i>
|
||||||
|
{% trans "Send Appreciation" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Tips -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0">
|
||||||
|
<i class="fas fa-lightbulb me-2"></i>
|
||||||
|
{% trans "Tips for Writing Appreciation" %}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
<li class="mb-2">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Be specific about what you appreciate" %}
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Use the person's name when addressing them" %}
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Mention the impact of their actions" %}
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Be sincere and authentic" %}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Keep it positive and uplifting" %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visibility Guide -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
{% trans "Visibility Levels" %}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>{% trans "Private:" %}</strong>
|
||||||
|
<p class="small text-muted mb-0">
|
||||||
|
{% trans "Only you and the recipient can see this appreciation" %}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>{% trans "Department:" %}</strong>
|
||||||
|
<p class="small text-muted mb-0">
|
||||||
|
{% trans "Visible to everyone in the selected department" %}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>{% trans "Hospital:" %}</strong>
|
||||||
|
<p class="small text-muted mb-0">
|
||||||
|
{% trans "Visible to everyone in the selected hospital" %}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>{% trans "Public:" %}</strong>
|
||||||
|
<p class="small text-muted mb-0">
|
||||||
|
{% trans "Visible to all PX360 users" %}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const hospitalSelect = document.getElementById('hospital_id');
|
||||||
|
const recipientTypeSelect = document.getElementById('recipient_type');
|
||||||
|
const recipientSelect = document.getElementById('recipient_id');
|
||||||
|
const departmentSelect = document.getElementById('department_id');
|
||||||
|
const recipientHelp = document.getElementById('recipientHelp');
|
||||||
|
|
||||||
|
let recipientData = [];
|
||||||
|
|
||||||
|
// Load recipients when hospital changes
|
||||||
|
hospitalSelect.addEventListener('change', function() {
|
||||||
|
const hospitalId = this.value;
|
||||||
|
const recipientType = recipientTypeSelect.value;
|
||||||
|
|
||||||
|
recipientSelect.disabled = true;
|
||||||
|
recipientSelect.innerHTML = '<option value="">Loading...</option>';
|
||||||
|
recipientHelp.textContent = 'Loading recipients...';
|
||||||
|
|
||||||
|
if (!hospitalId) {
|
||||||
|
recipientSelect.innerHTML = '<option value="">-- Select Recipient --</option>';
|
||||||
|
recipientSelect.disabled = true;
|
||||||
|
recipientHelp.textContent = 'Select a hospital first';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch recipients
|
||||||
|
const url = recipientType === 'user'
|
||||||
|
? "{% url 'appreciation:get_users_by_hospital' %}?hospital_id=" + hospitalId
|
||||||
|
: "{% url 'appreciation:get_physicians_by_hospital' %}?hospital_id=" + hospitalId;
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
recipientData = recipientType === 'user' ? data.users : data.physicians;
|
||||||
|
|
||||||
|
recipientSelect.innerHTML = '<option value="">-- Select Recipient --</option>';
|
||||||
|
recipientData.forEach(item => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = item.id;
|
||||||
|
option.textContent = item.name;
|
||||||
|
recipientSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
recipientSelect.disabled = false;
|
||||||
|
recipientHelp.textContent = `${recipientData.length} recipients found`;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
recipientSelect.innerHTML = '<option value="">Error loading recipients</option>';
|
||||||
|
recipientHelp.textContent = 'Error loading recipients';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load departments when hospital changes
|
||||||
|
hospitalSelect.addEventListener('change', function() {
|
||||||
|
const hospitalId = this.value;
|
||||||
|
|
||||||
|
departmentSelect.innerHTML = '<option value="">-- Select Department --</option>';
|
||||||
|
|
||||||
|
if (!hospitalId) return;
|
||||||
|
|
||||||
|
fetch("{% url 'appreciation:get_departments_by_hospital' %}?hospital_id=" + hospitalId)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
data.departments.forEach(item => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = item.id;
|
||||||
|
option.textContent = item.name;
|
||||||
|
departmentSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh recipients when recipient type changes
|
||||||
|
recipientTypeSelect.addEventListener('change', function() {
|
||||||
|
if (hospitalSelect.value) {
|
||||||
|
hospitalSelect.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
245
templates/appreciation/badge_form.html
Normal file
245
templates/appreciation/badge_form.html
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block title %}{% if form.instance.pk %}{% trans "Edit Badge" %}{% else %}{% trans "Add Badge" %}{% endif %} - {% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'appreciation:badge_list' %}">{% trans "Badges" %}</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Add" %}{% endif %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-warning text-dark">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="fas fa-award me-2"></i>
|
||||||
|
{% if form.instance.pk %}{% trans "Edit Badge" %}{% else %}{% trans "Add Badge" %}{% endif %}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<!-- Name (English) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_name_en" class="form-label">
|
||||||
|
{% trans "Name (English)" %} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="id_name_en"
|
||||||
|
name="name_en"
|
||||||
|
value="{{ form.name_en.value|default:'' }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name (Arabic) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_name_ar" class="form-label">
|
||||||
|
{% trans "Name (Arabic)" %} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="id_name_ar"
|
||||||
|
name="name_ar"
|
||||||
|
value="{{ form.name_ar.value|default:'' }}"
|
||||||
|
dir="rtl"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description (English) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_description_en" class="form-label">{% trans "Description (English)" %}</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="id_description_en"
|
||||||
|
name="description_en"
|
||||||
|
rows="3"
|
||||||
|
>{{ form.description_en.value|default:'' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description (Arabic) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_description_ar" class="form-label">{% trans "Description (Arabic)" %}</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="id_description_ar"
|
||||||
|
name="description_ar"
|
||||||
|
rows="3"
|
||||||
|
dir="rtl"
|
||||||
|
>{{ form.description_ar.value|default:'' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_icon" class="form-label">{% trans "Icon" %}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="id_icon"
|
||||||
|
name="icon"
|
||||||
|
value="{{ form.icon.value|default:'fa-trophy' }}"
|
||||||
|
placeholder="fa-trophy"
|
||||||
|
>
|
||||||
|
<div class="form-text">
|
||||||
|
{% trans "FontAwesome icon class (e.g., fa-trophy, fa-star, fa-medal)" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Criteria Type -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_criteria_type" class="form-label">{% trans "Criteria Type" %}</label>
|
||||||
|
<select class="form-select" id="id_criteria_type" name="criteria_type">
|
||||||
|
{% for choice in form.fields.criteria_type.choices %}
|
||||||
|
<option value="{{ choice.0 }}" {% if choice.0 == form.criteria_type.value|stringformat:'s' or (not form.criteria_type.value and choice.0 == 'count') %}selected{% endif %}>
|
||||||
|
{{ choice.1 }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Criteria Value -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_criteria_value" class="form-label">{% trans "Criteria Value" %}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
id="id_criteria_value"
|
||||||
|
name="criteria_value"
|
||||||
|
value="{{ form.criteria_value.value|default:'' }}"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<div class="form-text">
|
||||||
|
{% trans "Number of appreciations required to earn this badge" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Is Active -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="id_is_active"
|
||||||
|
name="is_active"
|
||||||
|
{% if form.is_active.value %}checked{% endif %}
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for="id_is_active">
|
||||||
|
{% trans "Active" %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% url 'appreciation:badge_list' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-times me-2"></i>
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-warning text-dark">
|
||||||
|
<i class="fas fa-save me-2"></i>
|
||||||
|
{% if form.instance.pk %}{% trans "Update" %}{% else %}{% trans "Create" %}{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Icon Preview -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0">{% trans "Badge Preview" %}</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="badge-icon-wrapper mb-3">
|
||||||
|
<i id="icon-preview" class="{{ form.icon.value|default:'fa-trophy' }} fa-4x text-warning"></i>
|
||||||
|
</div>
|
||||||
|
<p id="name-preview" class="h5 mb-2">{{ form.name_en.value|default:'Badge Name' }}</p>
|
||||||
|
<p class="small text-muted mb-0">
|
||||||
|
<strong>{% trans "Requires" %}: </strong>
|
||||||
|
<span id="criteria-preview">{{ form.criteria_value.value|default:0 }}</span>
|
||||||
|
{% trans "appreciations" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Criteria Information -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
{% trans "About Badge Criteria" %}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-unstyled mb-0 small">
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>{% trans "Count:" %}</strong>
|
||||||
|
<p class="mb-0 text-muted">
|
||||||
|
{% trans "Badge is earned after receiving the specified number of appreciations" %}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>{% trans "Tips:" %}</strong>
|
||||||
|
<ul class="mb-0 ps-3 text-muted">
|
||||||
|
<li>{% trans "Set achievable criteria to encourage participation" %}</li>
|
||||||
|
<li>{% trans "Use descriptive names and icons" %}</li>
|
||||||
|
<li>{% trans "Create badges for different achievement levels" %}</li>
|
||||||
|
<li>{% trans "Deactivate badges instead of deleting to preserve history" %}</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const iconInput = document.getElementById('id_icon');
|
||||||
|
const iconPreview = document.getElementById('icon-preview');
|
||||||
|
const nameEnInput = document.getElementById('id_name_en');
|
||||||
|
const namePreview = document.getElementById('name-preview');
|
||||||
|
const criteriaValueInput = document.getElementById('id_criteria_value');
|
||||||
|
const criteriaPreview = document.getElementById('criteria-preview');
|
||||||
|
|
||||||
|
// Update icon preview
|
||||||
|
iconInput.addEventListener('input', function() {
|
||||||
|
const iconClass = this.value.trim() || 'fa-trophy';
|
||||||
|
iconPreview.className = iconClass + ' fa-4x text-warning';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update name preview
|
||||||
|
nameEnInput.addEventListener('input', function() {
|
||||||
|
const name = this.value.trim() || 'Badge Name';
|
||||||
|
namePreview.textContent = name;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update criteria preview
|
||||||
|
criteriaValueInput.addEventListener('input', function() {
|
||||||
|
const value = this.value.trim() || '0';
|
||||||
|
criteriaPreview.textContent = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
137
templates/appreciation/badge_list.html
Normal file
137
templates/appreciation/badge_list.html
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Appreciation Badges" %} - {% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{% trans "Badges" %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>
|
||||||
|
<i class="fas fa-award text-warning me-2"></i>
|
||||||
|
{% trans "Appreciation Badges" %}
|
||||||
|
</h2>
|
||||||
|
<a href="{% url 'appreciation:badge_create' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-plus me-2"></i>
|
||||||
|
{% trans "Add Badge" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Badges List -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if badges %}
|
||||||
|
<div class="row">
|
||||||
|
{% for badge in badges %}
|
||||||
|
<div class="col-md-6 col-lg-4 mb-4">
|
||||||
|
<div class="card h-100 border-2 border-warning">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<i class="{{ badge.icon }} fa-4x text-warning"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="card-title text-center">{{ badge.name_en }}</h5>
|
||||||
|
<p class="card-text small text-muted text-center">
|
||||||
|
{{ badge.description_en|truncatewords:10 }}
|
||||||
|
</p>
|
||||||
|
<hr>
|
||||||
|
<ul class="list-unstyled small mb-3">
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>{% trans "Type:" %}</strong>
|
||||||
|
{{ badge.get_criteria_type_display }}
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>{% trans "Value:" %}</strong>
|
||||||
|
{{ badge.criteria_value }}
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>{% trans "Earned:" %}</strong>
|
||||||
|
{{ badge.earned_count }} {% trans "times" %}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>{% trans "Status:" %}</strong>
|
||||||
|
{% if badge.is_active %}
|
||||||
|
<span class="badge bg-success">{% trans "Active" %}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{% trans "Inactive" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="{% url 'appreciation:badge_edit' badge.id %}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fas fa-edit me-2"></i>{% trans "Edit" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'appreciation:badge_delete' badge.id %}" class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="fas fa-trash me-2"></i>{% trans "Delete" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if page_obj.has_other_pages %}
|
||||||
|
<nav aria-label="Page navigation" class="mt-4">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">
|
||||||
|
{% trans "Previous" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link">{% trans "Previous" %}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for num in page_obj.paginator.page_range %}
|
||||||
|
{% if page_obj.number == num %}
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ num }}</span>
|
||||||
|
</li>
|
||||||
|
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}">
|
||||||
|
{% trans "Next" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link">{% trans "Next" %}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fas fa-award fa-4x text-muted mb-3"></i>
|
||||||
|
<h4 class="text-muted">{% trans "No badges found" %}</h4>
|
||||||
|
<p class="text-muted mb-3">{% trans "Create badges to motivate and recognize achievements" %}</p>
|
||||||
|
<a href="{% url 'appreciation:badge_create' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-plus me-2"></i>
|
||||||
|
{% trans "Add Badge" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
214
templates/appreciation/category_form.html
Normal file
214
templates/appreciation/category_form.html
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block title %}{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "Add Category" %}{% endif %} - {% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'appreciation:category_list' %}">{% trans "Categories" %}</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{% if form.instance.pk %}{% trans "Edit" %}{% else %}{% trans "Add" %}{% endif %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="fas fa-tag me-2"></i>
|
||||||
|
{% if form.instance.pk %}{% trans "Edit Category" %}{% else %}{% trans "Add Category" %}{% endif %}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<!-- Name (English) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_name_en" class="form-label">
|
||||||
|
{% trans "Name (English)" %} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="id_name_en"
|
||||||
|
name="name_en"
|
||||||
|
value="{{ form.name_en.value|default:'' }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name (Arabic) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_name_ar" class="form-label">
|
||||||
|
{% trans "Name (Arabic)" %} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="id_name_ar"
|
||||||
|
name="name_ar"
|
||||||
|
value="{{ form.name_ar.value|default:'' }}"
|
||||||
|
dir="rtl"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description (English) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_description_en" class="form-label">{% trans "Description (English)" %}</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="id_description_en"
|
||||||
|
name="description_en"
|
||||||
|
rows="3"
|
||||||
|
>{{ form.description_en.value|default:'' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description (Arabic) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_description_ar" class="form-label">{% trans "Description (Arabic)" %}</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="id_description_ar"
|
||||||
|
name="description_ar"
|
||||||
|
rows="3"
|
||||||
|
dir="rtl"
|
||||||
|
>{{ form.description_ar.value|default:'' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_icon" class="form-label">{% trans "Icon" %}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="id_icon"
|
||||||
|
name="icon"
|
||||||
|
value="{{ form.icon.value|default:'fa-heart' }}"
|
||||||
|
placeholder="fa-heart"
|
||||||
|
>
|
||||||
|
<div class="form-text">
|
||||||
|
{% trans "FontAwesome icon class (e.g., fa-heart, fa-star, fa-thumbs-up)" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="id_color" class="form-label">{% trans "Color" %}</label>
|
||||||
|
<select class="form-select" id="id_color" name="color">
|
||||||
|
{% for choice in form.fields.color.choices %}
|
||||||
|
<option value="{{ choice.0 }}" {% if choice.0 == form.color.value|stringformat:'s' or (not form.color.value and choice.0 == 'primary') %}selected{% endif %}>
|
||||||
|
{{ choice.1 }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Is Active -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="id_is_active"
|
||||||
|
name="is_active"
|
||||||
|
{% if form.is_active.value %}checked{% endif %}
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for="id_is_active">
|
||||||
|
{% trans "Active" %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% url 'appreciation:category_list' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-times me-2"></i>
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save me-2"></i>
|
||||||
|
{% if form.instance.pk %}{% trans "Update" %}{% else %}{% trans "Create" %}{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Icon Preview -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0">{% trans "Icon Preview" %}</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i id="icon-preview" class="{{ form.icon.value|default:'fa-heart' }} fa-4x text-primary mb-3"></i>
|
||||||
|
<p id="name-preview" class="h5 mb-0">{{ form.name_en.value|default:'Category Name' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tips -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0">
|
||||||
|
<i class="fas fa-lightbulb me-2"></i>
|
||||||
|
{% trans "Tips" %}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-unstyled mb-0 small">
|
||||||
|
<li class="mb-2">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Use descriptive names for categories" %}
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Choose appropriate icons for each category" %}
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Colors help users quickly identify categories" %}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Deactivate unused categories instead of deleting" %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const iconInput = document.getElementById('id_icon');
|
||||||
|
const iconPreview = document.getElementById('icon-preview');
|
||||||
|
const nameEnInput = document.getElementById('id_name_en');
|
||||||
|
const namePreview = document.getElementById('name-preview');
|
||||||
|
|
||||||
|
// Update icon preview
|
||||||
|
iconInput.addEventListener('input', function() {
|
||||||
|
const iconClass = this.value.trim() || 'fa-heart';
|
||||||
|
iconPreview.className = iconClass + ' fa-4x text-primary';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update name preview
|
||||||
|
nameEnInput.addEventListener('input', function() {
|
||||||
|
const name = this.value.trim() || 'Category Name';
|
||||||
|
namePreview.textContent = name;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
85
templates/appreciation/category_list.html
Normal file
85
templates/appreciation/category_list.html
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Appreciation Categories" %} - {% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{% trans "Categories" %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>
|
||||||
|
<i class="fas fa-tags text-primary me-2"></i>
|
||||||
|
{% trans "Appreciation Categories" %}
|
||||||
|
</h2>
|
||||||
|
<a href="{% url 'appreciation:category_create' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-plus me-2"></i>
|
||||||
|
{% trans "Add Category" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories List -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if categories %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Icon" %}</th>
|
||||||
|
<th>{% trans "Name (English)" %}</th>
|
||||||
|
<th>{% trans "Name (Arabic)" %}</th>
|
||||||
|
<th>{% trans "Color" %}</th>
|
||||||
|
<th>{% trans "Count" %}</th>
|
||||||
|
<th class="text-center">{% trans "Actions" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for category in categories %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i class="{{ category.icon }} fa-2x text-primary"></i>
|
||||||
|
</td>
|
||||||
|
<td>{{ category.name_en }}</td>
|
||||||
|
<td dir="rtl">{{ category.name_ar }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ category.color }}">
|
||||||
|
{{ category.get_color_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ category.appreciation_count }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<a href="{% url 'appreciation:category_edit' category.id %}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'appreciation:category_delete' category.id %}" class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fas fa-tags fa-4x text-muted mb-3"></i>
|
||||||
|
<h4 class="text-muted">{% trans "No categories found" %}</h4>
|
||||||
|
<p class="text-muted mb-3">{% trans "Create categories to organize appreciations" %}</p>
|
||||||
|
<a href="{% url 'appreciation:category_create' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-plus me-2"></i>
|
||||||
|
{% trans "Add Category" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
248
templates/appreciation/leaderboard.html
Normal file
248
templates/appreciation/leaderboard.html
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Appreciation Leaderboard" %} - {% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{% trans "Leaderboard" %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>
|
||||||
|
<i class="fas fa-trophy text-warning me-2"></i>
|
||||||
|
{% trans "Appreciation Leaderboard" %}
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'appreciation:appreciation_send' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-paper-plane me-2"></i>
|
||||||
|
{% trans "Send Appreciation" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="year" class="form-label">{% trans "Year" %}</label>
|
||||||
|
<select class="form-select" id="year" name="year">
|
||||||
|
{% for y in years %}
|
||||||
|
<option value="{{ y }}" {% if y == selected_year %}selected{% endif %}>{{ y }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="month" class="form-label">{% trans "Month" %}</label>
|
||||||
|
<select class="form-select" id="month" name="month">
|
||||||
|
{% for m in months %}
|
||||||
|
<option value="{{ m.0 }}" {% if m.0 == selected_month %}selected{% endif %}>{{ m.1 }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="hospital" class="form-label">{% trans "Hospital" %}</label>
|
||||||
|
<select class="form-select" id="hospital" name="hospital">
|
||||||
|
<option value="">All Hospitals</option>
|
||||||
|
{% for hospital in hospitals %}
|
||||||
|
<option value="{{ hospital.id }}" {% if filters.hospital == hospital.id|stringformat:"s" %}selected{% endif %}>
|
||||||
|
{{ hospital.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="department" class="form-label">{% trans "Department" %}</label>
|
||||||
|
<select class="form-select" id="department" name="department">
|
||||||
|
<option value="">All Departments</option>
|
||||||
|
{% for department in departments %}
|
||||||
|
<option value="{{ department.id }}" {% if filters.department == department.id|stringformat:"s" %}selected{% endif %}>
|
||||||
|
{{ department.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-filter me-2"></i>
|
||||||
|
{% trans "Apply Filters" %}
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'appreciation:leaderboard_view' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-redo me-2"></i>
|
||||||
|
{% trans "Reset" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leaderboard Table -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if page_obj %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Rank</th>
|
||||||
|
<th>{% trans "Recipient" %}</th>
|
||||||
|
<th>{% trans "Hospital" %}</th>
|
||||||
|
<th>{% trans "Department" %}</th>
|
||||||
|
<th class="text-center">{% trans "Received" %}</th>
|
||||||
|
<th class="text-center">{% trans "Sent" %}</th>
|
||||||
|
<th class="text-center">{% trans "Hospital Rank" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in page_obj %}
|
||||||
|
<tr class="{% if item.get_recipient_name == request.user.get_full_name %}table-primary{% endif %}">
|
||||||
|
<td>
|
||||||
|
{% if forloop.counter <= 3 %}
|
||||||
|
<span class="badge bg-{% if forloop.counter == 1 %}warning{% elif forloop.counter == 2 %}secondary{% else %}info{% endif %} fs-6">
|
||||||
|
{% if forloop.counter == 1 %}<i class="fas fa-crown me-1"></i>{% endif %}
|
||||||
|
#{{ forloop.counter }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">#{{ forloop.counter }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<i class="fas fa-user text-primary me-2"></i>
|
||||||
|
{{ item.get_recipient_name }}
|
||||||
|
</td>
|
||||||
|
<td>{{ item.hospital.name }}</td>
|
||||||
|
<td>{% if item.department %}{{ item.department.name }}{% else %}-{% endif %}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge bg-success fs-6">{{ item.received_count }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge bg-info fs-6">{{ item.sent_count }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{% if item.hospital_rank %}
|
||||||
|
{% if item.hospital_rank <= 3 %}
|
||||||
|
<span class="badge bg-warning">
|
||||||
|
#{{ item.hospital_rank }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
#{{ item.hospital_rank }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if page_obj.has_other_pages %}
|
||||||
|
<nav aria-label="Page navigation" class="mt-4">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if filters.urlencode %}&{{ filters.urlencode }}{% endif %}">
|
||||||
|
{% trans "Previous" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link">{% trans "Previous" %}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for num in page_obj.paginator.page_range %}
|
||||||
|
{% if page_obj.number == num %}
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ num }}</span>
|
||||||
|
</li>
|
||||||
|
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ num }}{% if filters.urlencode %}&{{ filters.urlencode }}{% endif %}">{{ num }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if filters.urlencode %}&{{ filters.urlencode }}{% endif %}">
|
||||||
|
{% trans "Next" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link">{% trans "Next" %}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fas fa-trophy fa-4x text-muted mb-3"></i>
|
||||||
|
<h4 class="text-muted">{% trans "No appreciations found for this period" %}</h4>
|
||||||
|
<p class="text-muted">{% trans "Try changing the filters or select a different time period" %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-paper-plane fa-3x text-primary mb-3"></i>
|
||||||
|
<h5>{% trans "Send Appreciation" %}</h5>
|
||||||
|
<p class="text-muted mb-3">{% trans "Share your appreciation with colleagues" %}</p>
|
||||||
|
<a href="{% url 'appreciation:appreciation_send' %}" class="btn btn-primary">
|
||||||
|
{% trans "Send Now" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-award fa-3x text-warning mb-3"></i>
|
||||||
|
<h5>{% trans "View Badges" %}</h5>
|
||||||
|
<p class="text-muted mb-3">{% trans "See your earned badges" %}</p>
|
||||||
|
<a href="{% url 'appreciation:my_badges_view' %}" class="btn btn-warning">
|
||||||
|
{% trans "My Badges" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-list fa-3x text-info mb-3"></i>
|
||||||
|
<h5>{% trans "All Appreciations" %}</h5>
|
||||||
|
<p class="text-muted mb-3">{% trans "View your appreciations" %}</p>
|
||||||
|
<a href="{% url 'appreciation:appreciation_list' %}" class="btn btn-info">
|
||||||
|
{% trans "View List" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script>
|
||||||
|
// Add any leaderboard-specific JavaScript here
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
256
templates/appreciation/my_badges.html
Normal file
256
templates/appreciation/my_badges.html
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "My Badges" %} - {% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav aria-label="breadcrumb" class="mb-4">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'appreciation:appreciation_list' %}">{% trans "Appreciation" %}</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{% trans "My Badges" %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>
|
||||||
|
<i class="fas fa-award text-warning me-2"></i>
|
||||||
|
{% trans "My Badges" %}
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'appreciation:appreciation_send' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-paper-plane me-2"></i>
|
||||||
|
{% trans "Send Appreciation" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Summary -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm border-primary">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-heart fa-2x text-danger mb-2"></i>
|
||||||
|
<h3 class="mb-0">{{ total_received }}</h3>
|
||||||
|
<p class="text-muted mb-0">{% trans "Total Appreciations Received" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm border-warning">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-award fa-2x text-warning mb-2"></i>
|
||||||
|
<h3 class="mb-0">{{ badges|length }}</h3>
|
||||||
|
<p class="text-muted mb-0">{% trans "Badges Earned" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm border-info">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="fas fa-trophy fa-2x text-info mb-2"></i>
|
||||||
|
<h3 class="mb-0">{{ badge_progress|length }}</h3>
|
||||||
|
<p class="text-muted mb-0">{% trans "Available Badges" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Earned Badges -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-warning text-white">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<i class="fas fa-medal me-2"></i>
|
||||||
|
{% trans "Earned Badges" %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if badges %}
|
||||||
|
<div class="row">
|
||||||
|
{% for user_badge in badges %}
|
||||||
|
<div class="col-md-6 col-lg-4 mb-3">
|
||||||
|
<div class="card h-100 border-2 border-warning">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="badge-icon-wrapper mb-3">
|
||||||
|
<i class="{{ user_badge.badge.icon }} fa-4x text-warning"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="card-title">{{ user_badge.badge.name_en }}</h5>
|
||||||
|
<p class="card-text small text-muted">
|
||||||
|
{{ user_badge.badge.description_en|truncatewords:10 }}
|
||||||
|
</p>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fas fa-calendar-alt me-1"></i>
|
||||||
|
{% trans "Earned on" %} {{ user_badge.earned_at|date:"F j, Y" }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if page_obj.has_other_pages %}
|
||||||
|
<nav aria-label="Page navigation" class="mt-4">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">
|
||||||
|
{% trans "Previous" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link">{% trans "Previous" %}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for num in page_obj.paginator.page_range %}
|
||||||
|
{% if page_obj.number == num %}
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">{{ num }}</span>
|
||||||
|
</li>
|
||||||
|
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}">
|
||||||
|
{% trans "Next" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link">{% trans "Next" %}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fas fa-award fa-4x text-muted mb-3"></i>
|
||||||
|
<h4 class="text-muted">{% trans "No badges earned yet" %}</h4>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% trans "Start receiving appreciations to earn badges!" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Badge Progress -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0">
|
||||||
|
<i class="fas fa-chart-line me-2"></i>
|
||||||
|
{% trans "Badge Progress" %}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for item in badge_progress %}
|
||||||
|
<div class="mb-3 {% if not item.earned %}opacity-75{% endif %}">
|
||||||
|
<div class="d-flex align-items-center mb-1">
|
||||||
|
<i class="{{ item.badge.icon }} me-2 {% if item.earned %}text-warning{% else %}text-muted{% endif %}"></i>
|
||||||
|
<div>
|
||||||
|
<strong class="{% if item.earned %}text-warning{% else %}text-muted{% endif %}">
|
||||||
|
{{ item.badge.name_en }}
|
||||||
|
</strong>
|
||||||
|
{% if item.earned %}
|
||||||
|
<span class="badge bg-success ms-2">✓</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height: 10px;">
|
||||||
|
<div
|
||||||
|
class="progress-bar {% if item.earned %}bg-warning{% else %}bg-info{% endif %}"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {{ item.progress }}%"
|
||||||
|
aria-valuenow="{{ item.progress }}"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
{% if item.badge.criteria_type == 'count' %}
|
||||||
|
{% trans "Requires" %} {{ item.badge.criteria_value }} {% trans "appreciations" %}
|
||||||
|
{% endif %}
|
||||||
|
({% trans "Progress" %}: {{ item.progress }}%)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="text-center py-3">
|
||||||
|
<i class="fas fa-trophy fa-2x text-muted mb-2"></i>
|
||||||
|
<p class="text-muted mb-0">{% trans "No badges available" %}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tips -->
|
||||||
|
<div class="card shadow-sm mt-3">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0">
|
||||||
|
<i class="fas fa-lightbulb me-2"></i>
|
||||||
|
{% trans "How to Earn Badges" %}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-unstyled mb-0 small">
|
||||||
|
<li class="mb-2">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Receive appreciations from colleagues" %}
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Consistently provide excellent service" %}
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Be a team player" %}
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Show leadership qualities" %}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
{% trans "Demonstrate innovation" %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
{{ block.super }}
|
||||||
|
<style>
|
||||||
|
.badge-icon-wrapper {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff9c4;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opacity-75 {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user