add reference and standard
This commit is contained in:
parent
5bb2abf8bb
commit
97de5919f2
@ -121,4 +121,5 @@ STATIC_URL = 'static/'
|
|||||||
|
|
||||||
|
|
||||||
OPENROUTER_API_KEY = "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"
|
OPENROUTER_API_KEY = "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"
|
||||||
AI_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"
|
AI_MODEL = "openrouter/z-ai/glm-4.7"
|
||||||
|
# AI_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"
|
||||||
@ -53,7 +53,7 @@ class ComplaintSerializer(serializers.ModelSerializer):
|
|||||||
patient_mrn = serializers.CharField(source='patient.mrn', read_only=True)
|
patient_mrn = serializers.CharField(source='patient.mrn', read_only=True)
|
||||||
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
|
||||||
department_name = serializers.CharField(source='department.name', read_only=True)
|
department_name = serializers.CharField(source='department.name', read_only=True)
|
||||||
physician_name = serializers.SerializerMethodField()
|
staff_name = serializers.SerializerMethodField()
|
||||||
assigned_to_name = serializers.SerializerMethodField()
|
assigned_to_name = serializers.SerializerMethodField()
|
||||||
attachments = ComplaintAttachmentSerializer(many=True, read_only=True)
|
attachments = ComplaintAttachmentSerializer(many=True, read_only=True)
|
||||||
updates = ComplaintUpdateSerializer(many=True, read_only=True)
|
updates = ComplaintUpdateSerializer(many=True, read_only=True)
|
||||||
@ -64,7 +64,7 @@ class ComplaintSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'patient', 'patient_name', 'patient_mrn', 'encounter_id',
|
'id', 'patient', 'patient_name', 'patient_mrn', 'encounter_id',
|
||||||
'hospital', 'hospital_name', 'department', 'department_name',
|
'hospital', 'hospital_name', 'department', 'department_name',
|
||||||
'physician', 'physician_name',
|
'staff', 'staff_name',
|
||||||
'title', 'description', 'category', 'subcategory',
|
'title', 'description', 'category', 'subcategory',
|
||||||
'priority', 'severity', 'source', 'status',
|
'priority', 'severity', 'source', 'status',
|
||||||
'assigned_to', 'assigned_to_name', 'assigned_at',
|
'assigned_to', 'assigned_to_name', 'assigned_at',
|
||||||
@ -140,10 +140,10 @@ class ComplaintSerializer(serializers.ModelSerializer):
|
|||||||
# Create the complaint
|
# Create the complaint
|
||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
def get_physician_name(self, obj):
|
def get_staff_name(self, obj):
|
||||||
"""Get physician name"""
|
"""Get staff name"""
|
||||||
if obj.physician:
|
if obj.staff:
|
||||||
return obj.physician.get_full_name()
|
return f"{obj.staff.first_name} {obj.staff.last_name}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_assigned_to_name(self, obj):
|
def get_assigned_to_name(self, obj):
|
||||||
|
|||||||
@ -19,7 +19,7 @@ from django.utils import timezone
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Optional[str] = None) -> Tuple[Optional[str], float, str]:
|
def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Optional[str] = None, return_all: bool = False) -> Tuple[list, float, str]:
|
||||||
"""
|
"""
|
||||||
Match staff member from extracted name using multiple matching strategies.
|
Match staff member from extracted name using multiple matching strategies.
|
||||||
|
|
||||||
@ -27,9 +27,15 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
|
|||||||
staff_name: Name extracted from complaint (without titles)
|
staff_name: Name extracted from complaint (without titles)
|
||||||
hospital_id: Hospital ID to search within
|
hospital_id: Hospital ID to search within
|
||||||
department_name: Optional department name to prioritize matching
|
department_name: Optional department name to prioritize matching
|
||||||
|
return_all: If True, return all matching staff. If False, return single best match.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (staff_id, confidence_score, matching_method)
|
If return_all=True: Tuple of (matches_list, confidence_score, matching_method)
|
||||||
|
- matches_list: List of dicts with matched staff details
|
||||||
|
- confidence_score: Float from 0.0 to 1.0 (best match confidence)
|
||||||
|
- matching_method: Description of how staff was matched
|
||||||
|
|
||||||
|
If return_all=False: Tuple of (staff_id, confidence_score, matching_method)
|
||||||
- staff_id: UUID of matched staff or None
|
- staff_id: UUID of matched staff or None
|
||||||
- confidence_score: Float from 0.0 to 1.0
|
- confidence_score: Float from 0.0 to 1.0
|
||||||
- matching_method: Description of how staff was matched
|
- matching_method: Description of how staff was matched
|
||||||
@ -37,9 +43,10 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
|
|||||||
from apps.organizations.models import Staff, Department
|
from apps.organizations.models import Staff, Department
|
||||||
|
|
||||||
if not staff_name or not staff_name.strip():
|
if not staff_name or not staff_name.strip():
|
||||||
return None, 0.0, "No staff name provided"
|
return [], 0.0, "No staff name provided"
|
||||||
|
|
||||||
staff_name = staff_name.strip()
|
staff_name = staff_name.strip()
|
||||||
|
matches = []
|
||||||
|
|
||||||
# Build base query - staff from this hospital, active status
|
# Build base query - staff from this hospital, active status
|
||||||
base_query = Staff.objects.filter(
|
base_query = Staff.objects.filter(
|
||||||
@ -72,12 +79,23 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
|
|||||||
if dept_id:
|
if dept_id:
|
||||||
exact_query = exact_query.filter(department_id=dept_id)
|
exact_query = exact_query.filter(department_id=dept_id)
|
||||||
|
|
||||||
staff = exact_query.first()
|
exact_matches = list(exact_query)
|
||||||
if staff:
|
if exact_matches:
|
||||||
confidence = 0.95 if dept_id else 0.90
|
confidence = 0.95 if dept_id else 0.90
|
||||||
method = f"Exact English match in {'correct' if dept_id else 'any'} department"
|
method = f"Exact English match in {'correct' if dept_id else 'any'} department"
|
||||||
logger.info(f"Matched staff using exact English match: {staff.first_name} {staff.last_name} (confidence: {confidence})")
|
for staff in exact_matches:
|
||||||
return str(staff.id), confidence, method
|
matches.append({
|
||||||
|
'id': str(staff.id),
|
||||||
|
'name_en': f"{staff.first_name} {staff.last_name}",
|
||||||
|
'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "",
|
||||||
|
'job_title': staff.job_title,
|
||||||
|
'specialization': staff.specialization,
|
||||||
|
'department': staff.department.name if staff.department else None,
|
||||||
|
'department_id': str(staff.department.id) if staff.department else None,
|
||||||
|
'confidence': confidence,
|
||||||
|
'matching_method': method
|
||||||
|
})
|
||||||
|
logger.info(f"Found {len(exact_matches)} exact English matches for: {staff_name}")
|
||||||
|
|
||||||
# Layer 2: Exact Arabic match
|
# Layer 2: Exact Arabic match
|
||||||
arabic_query = base_query.filter(
|
arabic_query = base_query.filter(
|
||||||
@ -92,8 +110,20 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
|
|||||||
if full_arabic_name == staff_name:
|
if full_arabic_name == staff_name:
|
||||||
confidence = 0.95 if dept_id else 0.90
|
confidence = 0.95 if dept_id else 0.90
|
||||||
method = f"Exact Arabic match in {'correct' if dept_id else 'any'} department"
|
method = f"Exact Arabic match in {'correct' if dept_id else 'any'} department"
|
||||||
logger.info(f"Matched staff using exact Arabic match: {staff.first_name_ar} {staff.last_name_ar} (confidence: {confidence})")
|
# Check if already in matches
|
||||||
return str(staff.id), confidence, method
|
if not any(m['id'] == str(staff.id) for m in matches):
|
||||||
|
matches.append({
|
||||||
|
'id': str(staff.id),
|
||||||
|
'name_en': f"{staff.first_name} {staff.last_name}",
|
||||||
|
'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}",
|
||||||
|
'job_title': staff.job_title,
|
||||||
|
'specialization': staff.specialization,
|
||||||
|
'department': staff.department.name if staff.department else None,
|
||||||
|
'department_id': str(staff.department.id) if staff.department else None,
|
||||||
|
'confidence': confidence,
|
||||||
|
'matching_method': method
|
||||||
|
})
|
||||||
|
logger.info(f"Found Arabic match: {staff.first_name_ar} {staff.last_name_ar}")
|
||||||
|
|
||||||
# Layer 3: Partial match (first name or last name)
|
# Layer 3: Partial match (first name or last name)
|
||||||
partial_query = base_query.filter(
|
partial_query = base_query.filter(
|
||||||
@ -105,12 +135,25 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
|
|||||||
if dept_id:
|
if dept_id:
|
||||||
partial_query = partial_query.filter(department_id=dept_id)
|
partial_query = partial_query.filter(department_id=dept_id)
|
||||||
|
|
||||||
staff = partial_query.first()
|
partial_matches = list(partial_query)
|
||||||
if staff:
|
for staff in partial_matches:
|
||||||
|
# Check if already in matches
|
||||||
|
if not any(m['id'] == str(staff.id) for m in matches):
|
||||||
confidence = 0.70 if dept_id else 0.60
|
confidence = 0.70 if dept_id else 0.60
|
||||||
method = f"Partial match in {'correct' if dept_id else 'any'} department"
|
method = f"Partial match in {'correct' if dept_id else 'any'} department"
|
||||||
logger.info(f"Matched staff using partial match: {staff.first_name} {staff.last_name} (confidence: {confidence})")
|
matches.append({
|
||||||
return str(staff.id), confidence, method
|
'id': str(staff.id),
|
||||||
|
'name_en': f"{staff.first_name} {staff.last_name}",
|
||||||
|
'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "",
|
||||||
|
'job_title': staff.job_title,
|
||||||
|
'specialization': staff.specialization,
|
||||||
|
'department': staff.department.name if staff.department else None,
|
||||||
|
'department_id': str(staff.department.id) if staff.department else None,
|
||||||
|
'confidence': confidence,
|
||||||
|
'matching_method': method
|
||||||
|
})
|
||||||
|
if partial_matches:
|
||||||
|
logger.info(f"Found {len(partial_matches)} partial matches for: {staff_name}")
|
||||||
|
|
||||||
# Layer 4: Fuzzy match using individual words
|
# Layer 4: Fuzzy match using individual words
|
||||||
# Handle cases like "Dr. Ahmed" or "Nurse Sarah"
|
# Handle cases like "Dr. Ahmed" or "Nurse Sarah"
|
||||||
@ -120,17 +163,53 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op
|
|||||||
if dept_id:
|
if dept_id:
|
||||||
word_query = word_query.filter(department_id=dept_id)
|
word_query = word_query.filter(department_id=dept_id)
|
||||||
|
|
||||||
staff = word_query.first()
|
word_matches = list(word_query)
|
||||||
if staff:
|
for staff in word_matches:
|
||||||
|
# Check if already in matches
|
||||||
|
if not any(m['id'] == str(staff.id) for m in matches):
|
||||||
confidence = 0.50 if dept_id else 0.45
|
confidence = 0.50 if dept_id else 0.45
|
||||||
method = f"Word match in {'correct' if dept_id else 'any'} department"
|
method = f"Word match in {'correct' if dept_id else 'any'} department"
|
||||||
logger.info(f"Matched staff using word match: {staff.first_name} {staff.last_name} (confidence: {confidence})")
|
matches.append({
|
||||||
return str(staff.id), confidence, method
|
'id': str(staff.id),
|
||||||
|
'name_en': f"{staff.first_name} {staff.last_name}",
|
||||||
|
'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "",
|
||||||
|
'job_title': staff.job_title,
|
||||||
|
'specialization': staff.specialization,
|
||||||
|
'department': staff.department.name if staff.department else None,
|
||||||
|
'department_id': str(staff.department.id) if staff.department else None,
|
||||||
|
'confidence': confidence,
|
||||||
|
'matching_method': method
|
||||||
|
})
|
||||||
|
if word_matches:
|
||||||
|
logger.info(f"Found {len(word_matches)} word matches for: {staff_name}")
|
||||||
|
|
||||||
# No match found
|
# If return_all is False, return only the best match (highest confidence)
|
||||||
|
if not return_all:
|
||||||
|
if matches:
|
||||||
|
# Sort by confidence (descending)
|
||||||
|
matches.sort(key=lambda x: x['confidence'], reverse=True)
|
||||||
|
best_match = matches[0]
|
||||||
|
logger.info(
|
||||||
|
f"Best match: {best_match['name_en']} "
|
||||||
|
f"(confidence: {best_match['confidence']:.2f}, method: {best_match['matching_method']})"
|
||||||
|
)
|
||||||
|
return str(best_match['id']), best_match['confidence'], best_match['matching_method']
|
||||||
|
else:
|
||||||
logger.warning(f"No staff match found for name: {staff_name}")
|
logger.warning(f"No staff match found for name: {staff_name}")
|
||||||
return None, 0.0, "No match found"
|
return None, 0.0, "No match found"
|
||||||
|
|
||||||
|
# Return all matches
|
||||||
|
if matches:
|
||||||
|
# Sort by confidence (descending)
|
||||||
|
matches.sort(key=lambda x: x['confidence'], reverse=True)
|
||||||
|
best_confidence = matches[0]['confidence']
|
||||||
|
best_method = matches[0]['matching_method']
|
||||||
|
logger.info(f"Returning {len(matches)} matches for: {staff_name}")
|
||||||
|
return matches, best_confidence, best_method
|
||||||
|
else:
|
||||||
|
logger.warning(f"No staff match found for name: {staff_name}")
|
||||||
|
return [], 0.0, "No match found"
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def check_overdue_complaints():
|
def check_overdue_complaints():
|
||||||
@ -709,9 +788,11 @@ def analyze_complaint_with_ai(complaint_id):
|
|||||||
# Get staff_name from analyze_complaint result (already extracted by AI)
|
# Get staff_name from analyze_complaint result (already extracted by AI)
|
||||||
staff_name = analysis.get('staff_name', '').strip()
|
staff_name = analysis.get('staff_name', '').strip()
|
||||||
|
|
||||||
matched_staff_id = None
|
# Always get ALL matching staff for PX Admin review
|
||||||
|
staff_matches = []
|
||||||
staff_confidence = 0.0
|
staff_confidence = 0.0
|
||||||
staff_matching_method = None
|
staff_matching_method = None
|
||||||
|
matched_staff_id = None
|
||||||
|
|
||||||
# Capture old staff before matching
|
# Capture old staff before matching
|
||||||
old_staff = complaint.staff
|
old_staff = complaint.staff
|
||||||
@ -720,23 +801,30 @@ def analyze_complaint_with_ai(complaint_id):
|
|||||||
logger.info(f"AI extracted staff name: {staff_name}")
|
logger.info(f"AI extracted staff name: {staff_name}")
|
||||||
|
|
||||||
# Try matching WITH department filter first (higher confidence if match found)
|
# Try matching WITH department filter first (higher confidence if match found)
|
||||||
matched_staff_id, staff_confidence, staff_matching_method = match_staff_from_name(
|
staff_matches, staff_confidence, staff_matching_method = match_staff_from_name(
|
||||||
staff_name=staff_name,
|
staff_name=staff_name,
|
||||||
hospital_id=str(complaint.hospital.id),
|
hospital_id=str(complaint.hospital.id),
|
||||||
department_name=department_name
|
department_name=department_name,
|
||||||
|
return_all=True # Return ALL matches
|
||||||
)
|
)
|
||||||
|
|
||||||
# If no match found with department, try WITHOUT department filter
|
# If no match found with department, try WITHOUT department filter
|
||||||
if not matched_staff_id:
|
if not staff_matches:
|
||||||
logger.info(f"No match found with department filter '{department_name}', trying without department filter...")
|
logger.info(f"No match found with department filter '{department_name}', trying without department filter...")
|
||||||
matched_staff_id, staff_confidence, staff_matching_method = match_staff_from_name(
|
staff_matches, staff_confidence, staff_matching_method = match_staff_from_name(
|
||||||
staff_name=staff_name,
|
staff_name=staff_name,
|
||||||
hospital_id=str(complaint.hospital.id),
|
hospital_id=str(complaint.hospital.id),
|
||||||
department_name=None # Search all departments
|
department_name=None, # Search all departments
|
||||||
|
return_all=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only assign staff if confidence is above threshold (0.6)
|
# Logic for staff assignment
|
||||||
if matched_staff_id and staff_confidence >= 0.6:
|
needs_staff_review = False
|
||||||
|
|
||||||
|
if staff_matches:
|
||||||
|
# If only ONE match, assign it (regardless of confidence for PX Admin review)
|
||||||
|
if len(staff_matches) == 1:
|
||||||
|
matched_staff_id = staff_matches[0]['id']
|
||||||
from apps.organizations.models import Staff
|
from apps.organizations.models import Staff
|
||||||
try:
|
try:
|
||||||
staff = Staff.objects.get(id=matched_staff_id)
|
staff = Staff.objects.get(id=matched_staff_id)
|
||||||
@ -748,11 +836,32 @@ def analyze_complaint_with_ai(complaint_id):
|
|||||||
)
|
)
|
||||||
except Staff.DoesNotExist:
|
except Staff.DoesNotExist:
|
||||||
logger.warning(f"Staff {matched_staff_id} not found in database")
|
logger.warning(f"Staff {matched_staff_id} not found in database")
|
||||||
|
# Still mark for review if confidence is low
|
||||||
|
if staff_confidence < 0.6:
|
||||||
|
needs_staff_review = True
|
||||||
else:
|
else:
|
||||||
|
# Multiple matches found - don't assign, mark for review
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Staff match confidence {staff_confidence:.2f} below threshold 0.6, "
|
f"Multiple staff matches found ({len(staff_matches)}), "
|
||||||
f"or no match found. Not assigning staff."
|
f"marking for PX Admin review"
|
||||||
)
|
)
|
||||||
|
needs_staff_review = True
|
||||||
|
# Assign to department instead if available
|
||||||
|
if department_name:
|
||||||
|
# Department already set from AI analysis
|
||||||
|
pass
|
||||||
|
elif staff_matches[0].get('department_id'):
|
||||||
|
from apps.organizations.models import Department
|
||||||
|
try:
|
||||||
|
dept = Department.objects.get(id=staff_matches[0]['department_id'])
|
||||||
|
complaint.department = dept
|
||||||
|
logger.info(f"Assigned to department: {dept.name}")
|
||||||
|
except Department.DoesNotExist:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# No matches found
|
||||||
|
logger.warning(f"No staff match found for name: {staff_name}")
|
||||||
|
needs_staff_review = False # No review needed if no name found
|
||||||
|
|
||||||
# Save reasoning in metadata
|
# Save reasoning in metadata
|
||||||
# Use JSON-serializable values instead of model objects
|
# Use JSON-serializable values instead of model objects
|
||||||
@ -790,9 +899,12 @@ def analyze_complaint_with_ai(complaint_id):
|
|||||||
'old_staff': old_staff_name,
|
'old_staff': old_staff_name,
|
||||||
'old_staff_id': old_staff_id,
|
'old_staff_id': old_staff_id,
|
||||||
'extracted_staff_name': staff_name,
|
'extracted_staff_name': staff_name,
|
||||||
|
'staff_matches': staff_matches,
|
||||||
'matched_staff_id': matched_staff_id,
|
'matched_staff_id': matched_staff_id,
|
||||||
'staff_confidence': staff_confidence,
|
'staff_confidence': staff_confidence,
|
||||||
'staff_matching_method': staff_matching_method
|
'staff_matching_method': staff_matching_method,
|
||||||
|
'needs_staff_review': needs_staff_review,
|
||||||
|
'staff_match_count': len(staff_matches)
|
||||||
}
|
}
|
||||||
|
|
||||||
complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata'])
|
complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata'])
|
||||||
|
|||||||
@ -221,6 +221,14 @@ def complaint_detail(request, pk):
|
|||||||
if complaint.hospital:
|
if complaint.hospital:
|
||||||
assignable_users = assignable_users.filter(hospital=complaint.hospital)
|
assignable_users = assignable_users.filter(hospital=complaint.hospital)
|
||||||
|
|
||||||
|
# Get departments for the complaint's hospital
|
||||||
|
hospital_departments = []
|
||||||
|
if complaint.hospital:
|
||||||
|
hospital_departments = Department.objects.filter(
|
||||||
|
hospital=complaint.hospital,
|
||||||
|
status='active'
|
||||||
|
).order_by('name')
|
||||||
|
|
||||||
# Check if overdue
|
# Check if overdue
|
||||||
complaint.check_overdue()
|
complaint.check_overdue()
|
||||||
|
|
||||||
@ -232,6 +240,7 @@ def complaint_detail(request, pk):
|
|||||||
'assignable_users': assignable_users,
|
'assignable_users': assignable_users,
|
||||||
'status_choices': ComplaintStatus.choices,
|
'status_choices': ComplaintStatus.choices,
|
||||||
'can_edit': user.is_px_admin() or user.is_hospital_admin(),
|
'can_edit': user.is_px_admin() or user.is_hospital_admin(),
|
||||||
|
'hospital_departments': hospital_departments,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'complaints/complaint_detail.html', context)
|
return render(request, 'complaints/complaint_detail.html', context)
|
||||||
@ -462,6 +471,67 @@ def complaint_add_note(request, pk):
|
|||||||
return redirect('complaints:complaint_detail', pk=pk)
|
return redirect('complaints:complaint_detail', pk=pk)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def complaint_change_department(request, pk):
|
||||||
|
"""Change complaint department"""
|
||||||
|
complaint = get_object_or_404(Complaint, pk=pk)
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
user = request.user
|
||||||
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
|
messages.error(request, "You don't have permission to change complaint department.")
|
||||||
|
return redirect('complaints:complaint_detail', pk=pk)
|
||||||
|
|
||||||
|
department_id = request.POST.get('department_id')
|
||||||
|
if not department_id:
|
||||||
|
messages.error(request, "Please select a department.")
|
||||||
|
return redirect('complaints:complaint_detail', pk=pk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
department = Department.objects.get(id=department_id)
|
||||||
|
|
||||||
|
# Check department belongs to same hospital
|
||||||
|
if department.hospital != complaint.hospital:
|
||||||
|
messages.error(request, "Department does not belong to this complaint's hospital.")
|
||||||
|
return redirect('complaints:complaint_detail', pk=pk)
|
||||||
|
|
||||||
|
old_department = complaint.department
|
||||||
|
complaint.department = department
|
||||||
|
complaint.save(update_fields=['department'])
|
||||||
|
|
||||||
|
# Create update
|
||||||
|
ComplaintUpdate.objects.create(
|
||||||
|
complaint=complaint,
|
||||||
|
update_type='assignment',
|
||||||
|
message=f"Department changed to {department.name}",
|
||||||
|
created_by=request.user,
|
||||||
|
metadata={
|
||||||
|
'old_department_id': str(old_department.id) if old_department else None,
|
||||||
|
'new_department_id': str(department.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
AuditService.log_event(
|
||||||
|
event_type='department_change',
|
||||||
|
description=f"Complaint department changed to {department.name}",
|
||||||
|
user=request.user,
|
||||||
|
content_object=complaint,
|
||||||
|
metadata={
|
||||||
|
'old_department_id': str(old_department.id) if old_department else None,
|
||||||
|
'new_department_id': str(department.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.success(request, f"Department changed to {department.name}.")
|
||||||
|
|
||||||
|
except Department.DoesNotExist:
|
||||||
|
messages.error(request, "Department not found.")
|
||||||
|
|
||||||
|
return redirect('complaints:complaint_detail', pk=pk)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
def complaint_escalate(request, pk):
|
def complaint_escalate(request, pk):
|
||||||
|
|||||||
@ -18,6 +18,7 @@ urlpatterns = [
|
|||||||
path('<uuid:pk>/', ui_views.complaint_detail, name='complaint_detail'),
|
path('<uuid:pk>/', ui_views.complaint_detail, name='complaint_detail'),
|
||||||
path('<uuid:pk>/assign/', ui_views.complaint_assign, name='complaint_assign'),
|
path('<uuid:pk>/assign/', ui_views.complaint_assign, name='complaint_assign'),
|
||||||
path('<uuid:pk>/change-status/', ui_views.complaint_change_status, name='complaint_change_status'),
|
path('<uuid:pk>/change-status/', ui_views.complaint_change_status, name='complaint_change_status'),
|
||||||
|
path('<uuid:pk>/change-department/', ui_views.complaint_change_department, name='complaint_change_department'),
|
||||||
path('<uuid:pk>/add-note/', ui_views.complaint_add_note, name='complaint_add_note'),
|
path('<uuid:pk>/add-note/', ui_views.complaint_add_note, name='complaint_add_note'),
|
||||||
path('<uuid:pk>/escalate/', ui_views.complaint_escalate, name='complaint_escalate'),
|
path('<uuid:pk>/escalate/', ui_views.complaint_escalate, name='complaint_escalate'),
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Complaints views and viewsets
|
Complaints views and viewsets
|
||||||
"""
|
"""
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status, viewsets
|
from rest_framework import status, viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
@ -107,7 +108,7 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
|||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = [
|
filterset_fields = [
|
||||||
'status', 'severity', 'priority', 'category', 'source',
|
'status', 'severity', 'priority', 'category', 'source',
|
||||||
'hospital', 'department', 'physician', 'assigned_to',
|
'hospital', 'department', 'staff', 'assigned_to',
|
||||||
'is_overdue', 'hospital__organization'
|
'is_overdue', 'hospital__organization'
|
||||||
]
|
]
|
||||||
search_fields = ['title', 'description', 'patient__mrn', 'patient__first_name', 'patient__last_name']
|
search_fields = ['title', 'description', 'patient__mrn', 'patient__first_name', 'patient__last_name']
|
||||||
@ -123,7 +124,7 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter complaints based on user role"""
|
"""Filter complaints based on user role"""
|
||||||
queryset = super().get_queryset().select_related(
|
queryset = super().get_queryset().select_related(
|
||||||
'patient', 'hospital', 'department', 'physician',
|
'patient', 'hospital', 'department', 'staff',
|
||||||
'assigned_to', 'resolved_by', 'closed_by'
|
'assigned_to', 'resolved_by', 'closed_by'
|
||||||
).prefetch_related('attachments', 'updates')
|
).prefetch_related('attachments', 'updates')
|
||||||
|
|
||||||
@ -281,6 +282,257 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
|||||||
serializer = ComplaintUpdateSerializer(update)
|
serializer = ComplaintUpdateSerializer(update)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def staff_suggestions(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Get staff matching suggestions for a complaint.
|
||||||
|
|
||||||
|
Returns potential staff matches from AI analysis,
|
||||||
|
allowing PX Admins to review and select correct staff.
|
||||||
|
"""
|
||||||
|
complaint = self.get_object()
|
||||||
|
|
||||||
|
# Check if user is PX Admin
|
||||||
|
if not request.user.is_px_admin():
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only PX Admins can access staff suggestions'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get AI analysis metadata
|
||||||
|
ai_analysis = complaint.metadata.get('ai_analysis', {})
|
||||||
|
staff_matches = ai_analysis.get('staff_matches', [])
|
||||||
|
extracted_name = ai_analysis.get('extracted_staff_name', '')
|
||||||
|
needs_review = ai_analysis.get('needs_staff_review', False)
|
||||||
|
matched_staff_id = ai_analysis.get('matched_staff_id')
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'extracted_name': extracted_name,
|
||||||
|
'staff_matches': staff_matches,
|
||||||
|
'current_staff_id': matched_staff_id,
|
||||||
|
'needs_staff_review': needs_staff_review,
|
||||||
|
'staff_match_count': len(staff_matches)
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def hospital_staff(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Get all staff from complaint's hospital for manual selection.
|
||||||
|
|
||||||
|
Allows PX Admins to manually select staff.
|
||||||
|
Supports filtering by department.
|
||||||
|
"""
|
||||||
|
complaint = self.get_object()
|
||||||
|
|
||||||
|
# Check if user is PX Admin
|
||||||
|
if not request.user.is_px_admin():
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only PX Admins can access hospital staff list'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
from apps.organizations.models import Staff
|
||||||
|
|
||||||
|
# Get query params
|
||||||
|
department_id = request.query_params.get('department_id')
|
||||||
|
search = request.query_params.get('search', '').strip()
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
queryset = Staff.objects.filter(
|
||||||
|
hospital=complaint.hospital,
|
||||||
|
status='active'
|
||||||
|
).select_related('department')
|
||||||
|
|
||||||
|
# Filter by department if specified
|
||||||
|
if department_id:
|
||||||
|
queryset = queryset.filter(department_id=department_id)
|
||||||
|
|
||||||
|
# Search by name if provided
|
||||||
|
if search:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(first_name__icontains=search) |
|
||||||
|
Q(last_name__icontains=search) |
|
||||||
|
Q(first_name_ar__icontains=search) |
|
||||||
|
Q(last_name_ar__icontains=search) |
|
||||||
|
Q(job_title__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order by department and name
|
||||||
|
queryset = queryset.order_by('department__name', 'first_name', 'last_name')
|
||||||
|
|
||||||
|
# Serialize
|
||||||
|
staff_list = []
|
||||||
|
for staff in queryset:
|
||||||
|
staff_list.append({
|
||||||
|
'id': str(staff.id),
|
||||||
|
'name_en': f"{staff.first_name} {staff.last_name}",
|
||||||
|
'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "",
|
||||||
|
'job_title': staff.job_title,
|
||||||
|
'specialization': staff.specialization,
|
||||||
|
'department': staff.department.name if staff.department else None,
|
||||||
|
'department_id': str(staff.department.id) if staff.department else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'hospital_id': str(complaint.hospital.id),
|
||||||
|
'hospital_name': complaint.hospital.name,
|
||||||
|
'staff_count': len(staff_list),
|
||||||
|
'staff': staff_list
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def assign_staff(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Manually assign staff to a complaint.
|
||||||
|
|
||||||
|
Allows PX Admins to assign specific staff member,
|
||||||
|
especially when AI matching is ambiguous.
|
||||||
|
"""
|
||||||
|
complaint = self.get_object()
|
||||||
|
|
||||||
|
# Check if user is PX Admin
|
||||||
|
if not request.user.is_px_admin():
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only PX Admins can assign staff to complaints'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
staff_id = request.data.get('staff_id')
|
||||||
|
reason = request.data.get('reason', '')
|
||||||
|
|
||||||
|
if not staff_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'staff_id is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
from apps.organizations.models import Staff
|
||||||
|
try:
|
||||||
|
staff = Staff.objects.get(id=staff_id)
|
||||||
|
except Staff.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Staff not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check staff belongs to same hospital
|
||||||
|
if staff.hospital != complaint.hospital:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Staff does not belong to complaint hospital'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update complaint
|
||||||
|
old_staff_id = str(complaint.staff.id) if complaint.staff else None
|
||||||
|
complaint.staff = staff
|
||||||
|
complaint.save(update_fields=['staff'])
|
||||||
|
|
||||||
|
# Update metadata to clear review flag
|
||||||
|
if not complaint.metadata:
|
||||||
|
complaint.metadata = {}
|
||||||
|
if 'ai_analysis' in complaint.metadata:
|
||||||
|
complaint.metadata['ai_analysis']['needs_staff_review'] = False
|
||||||
|
complaint.metadata['ai_analysis']['staff_manually_assigned'] = True
|
||||||
|
complaint.metadata['ai_analysis']['staff_assigned_by'] = str(request.user.id)
|
||||||
|
complaint.metadata['ai_analysis']['staff_assigned_at'] = timezone.now().isoformat()
|
||||||
|
complaint.metadata['ai_analysis']['staff_assignment_reason'] = reason
|
||||||
|
complaint.save(update_fields=['metadata'])
|
||||||
|
|
||||||
|
# Create update
|
||||||
|
ComplaintUpdate.objects.create(
|
||||||
|
complaint=complaint,
|
||||||
|
update_type='assignment',
|
||||||
|
message=f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title}). {reason}" if reason else f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title})",
|
||||||
|
created_by=request.user,
|
||||||
|
metadata={
|
||||||
|
'old_staff_id': old_staff_id,
|
||||||
|
'new_staff_id': str(staff.id),
|
||||||
|
'manual_assignment': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
AuditService.log_from_request(
|
||||||
|
event_type='staff_assigned',
|
||||||
|
description=f"Staff {staff.first_name} {staff.last_name} manually assigned to complaint by {request.user.get_full_name()}",
|
||||||
|
request=request,
|
||||||
|
content_object=complaint,
|
||||||
|
metadata={
|
||||||
|
'old_staff_id': old_staff_id,
|
||||||
|
'new_staff_id': str(staff.id),
|
||||||
|
'reason': reason
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Staff assigned successfully',
|
||||||
|
'staff_id': str(staff.id),
|
||||||
|
'staff_name': f"{staff.first_name} {staff.last_name}"
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def change_department(self, request, pk=None):
|
||||||
|
"""Change complaint department"""
|
||||||
|
complaint = self.get_object()
|
||||||
|
department_id = request.data.get('department_id')
|
||||||
|
|
||||||
|
if not department_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'department_id is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
from apps.organizations.models import Department
|
||||||
|
try:
|
||||||
|
department = Department.objects.get(id=department_id)
|
||||||
|
except Department.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Department not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check department belongs to same hospital
|
||||||
|
if department.hospital != complaint.hospital:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Department does not belong to complaint hospital'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update complaint
|
||||||
|
old_department_id = str(complaint.department.id) if complaint.department else None
|
||||||
|
complaint.department = department
|
||||||
|
complaint.save(update_fields=['department'])
|
||||||
|
|
||||||
|
# Create update
|
||||||
|
ComplaintUpdate.objects.create(
|
||||||
|
complaint=complaint,
|
||||||
|
update_type='assignment',
|
||||||
|
message=f"Department changed to {department.name}",
|
||||||
|
created_by=request.user,
|
||||||
|
metadata={
|
||||||
|
'old_department_id': old_department_id,
|
||||||
|
'new_department_id': str(department.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
AuditService.log_from_request(
|
||||||
|
event_type='department_change',
|
||||||
|
description=f"Complaint department changed to {department.name}",
|
||||||
|
request=request,
|
||||||
|
content_object=complaint,
|
||||||
|
metadata={
|
||||||
|
'old_department_id': old_department_id,
|
||||||
|
'new_department_id': str(department.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Department changed successfully',
|
||||||
|
'department_id': str(department.id),
|
||||||
|
'department_name': department.name
|
||||||
|
})
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def create_action_from_ai(self, request, pk=None):
|
def create_action_from_ai(self, request, pk=None):
|
||||||
"""Create PX Action from AI-suggested action"""
|
"""Create PX Action from AI-suggested action"""
|
||||||
@ -397,6 +649,164 @@ class ComplaintViewSet(viewsets.ModelViewSet):
|
|||||||
'message': 'Action created successfully from AI-suggested action'
|
'message': 'Action created successfully from AI-suggested action'
|
||||||
}, status=status.HTTP_201_CREATED)
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def send_notification(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Send email notification to staff member or department head.
|
||||||
|
|
||||||
|
Sends complaint notification with AI-generated summary (editable by user).
|
||||||
|
Logs the operation to NotificationLog and ComplaintUpdate.
|
||||||
|
"""
|
||||||
|
complaint = self.get_object()
|
||||||
|
|
||||||
|
# Get email message (required)
|
||||||
|
email_message = request.data.get('email_message', '').strip()
|
||||||
|
if not email_message:
|
||||||
|
return Response(
|
||||||
|
{'error': 'email_message is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get additional message (optional)
|
||||||
|
additional_message = request.data.get('additional_message', '').strip()
|
||||||
|
|
||||||
|
# Determine recipient
|
||||||
|
recipient = None
|
||||||
|
recipient_display = None
|
||||||
|
recipient_type = None
|
||||||
|
|
||||||
|
# Priority 1: Staff member mentioned in complaint
|
||||||
|
if complaint.staff and complaint.staff.user:
|
||||||
|
recipient = complaint.staff.user
|
||||||
|
recipient_display = complaint.staff.get_full_name()
|
||||||
|
recipient_type = 'Staff Member'
|
||||||
|
# Priority 2: Department head
|
||||||
|
elif complaint.department and complaint.department.manager:
|
||||||
|
recipient = complaint.department.manager
|
||||||
|
recipient_display = recipient.get_full_name()
|
||||||
|
recipient_type = 'Department Head'
|
||||||
|
|
||||||
|
# Check if we found a recipient
|
||||||
|
if not recipient or not recipient.email:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No valid recipient found. Complaint must have either a staff member with user account, or a department manager with email.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Construct email content
|
||||||
|
subject = f"Complaint Notification - #{complaint.id}"
|
||||||
|
|
||||||
|
# Build email body
|
||||||
|
email_body = f"""
|
||||||
|
Dear {recipient.get_full_name()},
|
||||||
|
|
||||||
|
You have been assigned to review the following complaint:
|
||||||
|
|
||||||
|
COMPLAINT DETAILS:
|
||||||
|
----------------
|
||||||
|
ID: #{complaint.id}
|
||||||
|
Title: {complaint.title}
|
||||||
|
Severity: {complaint.get_severity_display()}
|
||||||
|
Priority: {complaint.get_priority_display()}
|
||||||
|
Status: {complaint.get_status_display()}
|
||||||
|
|
||||||
|
SUMMARY:
|
||||||
|
--------
|
||||||
|
{email_message}
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Add patient info if available
|
||||||
|
if complaint.patient:
|
||||||
|
email_body += f"""
|
||||||
|
PATIENT INFORMATION:
|
||||||
|
------------------
|
||||||
|
Name: {complaint.patient.get_full_name()}
|
||||||
|
MRN: {complaint.patient.mrn}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Add additional message if provided
|
||||||
|
if additional_message:
|
||||||
|
email_body += f"""
|
||||||
|
|
||||||
|
ADDITIONAL MESSAGE:
|
||||||
|
------------------
|
||||||
|
{additional_message}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Add link to complaint
|
||||||
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
|
site = get_current_site(request)
|
||||||
|
complaint_url = f"https://{site.domain}/complaints/{complaint.id}/"
|
||||||
|
|
||||||
|
email_body += f"""
|
||||||
|
|
||||||
|
To view the full complaint details, please visit:
|
||||||
|
{complaint_url}
|
||||||
|
|
||||||
|
Thank you for your attention to this matter.
|
||||||
|
|
||||||
|
---
|
||||||
|
This is an automated message from PX360 Complaint Management System.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Send email using NotificationService
|
||||||
|
from apps.notifications.services import NotificationService
|
||||||
|
|
||||||
|
try:
|
||||||
|
notification_log = NotificationService.send_email(
|
||||||
|
email=recipient.email,
|
||||||
|
subject=subject,
|
||||||
|
message=email_body,
|
||||||
|
related_object=complaint,
|
||||||
|
metadata={
|
||||||
|
'notification_type': 'complaint_notification',
|
||||||
|
'recipient_type': recipient_type,
|
||||||
|
'recipient_id': str(recipient.id),
|
||||||
|
'sender_id': str(request.user.id),
|
||||||
|
'has_additional_message': bool(additional_message)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Failed to send email: {str(e)}'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create ComplaintUpdate entry
|
||||||
|
ComplaintUpdate.objects.create(
|
||||||
|
complaint=complaint,
|
||||||
|
update_type='communication',
|
||||||
|
message=f"Email notification sent to {recipient_type}: {recipient_display}",
|
||||||
|
created_by=request.user,
|
||||||
|
metadata={
|
||||||
|
'recipient_type': recipient_type,
|
||||||
|
'recipient_id': str(recipient.id),
|
||||||
|
'notification_log_id': str(notification_log.id) if notification_log else None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log audit
|
||||||
|
AuditService.log_from_request(
|
||||||
|
event_type='notification_sent',
|
||||||
|
description=f"Email notification sent to {recipient_type}: {recipient_display}",
|
||||||
|
request=request,
|
||||||
|
content_object=complaint,
|
||||||
|
metadata={
|
||||||
|
'recipient_type': recipient_type,
|
||||||
|
'recipient_id': str(recipient.id),
|
||||||
|
'recipient_email': recipient.email
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Email notification sent successfully',
|
||||||
|
'recipient': recipient_display,
|
||||||
|
'recipient_type': recipient_type,
|
||||||
|
'recipient_email': recipient.email
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class ComplaintAttachmentViewSet(viewsets.ModelViewSet):
|
class ComplaintAttachmentViewSet(viewsets.ModelViewSet):
|
||||||
"""ViewSet for Complaint Attachments"""
|
"""ViewSet for Complaint Attachments"""
|
||||||
|
|||||||
@ -37,7 +37,8 @@ class AIService:
|
|||||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||||
OPENROUTER_API_KEY = "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"
|
OPENROUTER_API_KEY = "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"
|
||||||
# Default configuration
|
# Default configuration
|
||||||
DEFAULT_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"
|
DEFAULT_MODEL = "openrouter/z-ai/glm-4.7"
|
||||||
|
# DEFAULT_MODEL = "openrouter/xiaomi/mimo-v2-flash:free"
|
||||||
DEFAULT_TEMPERATURE = 0.3
|
DEFAULT_TEMPERATURE = 0.3
|
||||||
DEFAULT_MAX_TOKENS = 500
|
DEFAULT_MAX_TOKENS = 500
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|||||||
65
apps/observations/migrations/0002_add_missing_fields.py
Normal file
65
apps/observations/migrations/0002_add_missing_fields.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# Generated migration to add missing fields to Observation model
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('organizations', '0001_initial'), # Need hospital and department models
|
||||||
|
('observations', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Add hospital field (required for tenant isolation)
|
||||||
|
# Initially nullable, will be made required in next migration
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='observation',
|
||||||
|
name='hospital',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='observations',
|
||||||
|
to='organizations.hospital'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Add staff field (optional, for AI-matching like complaints)
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='observation',
|
||||||
|
name='staff',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='observations',
|
||||||
|
to='organizations.staff'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Add source field (to track how observation was submitted)
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='observation',
|
||||||
|
name='source',
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
('staff_portal', 'Staff Portal'),
|
||||||
|
('web_form', 'Web Form'),
|
||||||
|
('mobile_app', 'Mobile App'),
|
||||||
|
('email', 'Email'),
|
||||||
|
('call_center', 'Call Center'),
|
||||||
|
('other', 'Other'),
|
||||||
|
],
|
||||||
|
default='staff_portal',
|
||||||
|
max_length=50
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Add indexes for hospital filtering
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='observation',
|
||||||
|
index=models.Index(fields=['hospital', 'status', '-created_at'], name='obs_hospital_status_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.0.14 on 2026-01-07 11:02
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('observations', '0002_add_missing_fields'),
|
||||||
|
('organizations', '0005_alter_staff_department'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameIndex(
|
||||||
|
model_name='observation',
|
||||||
|
new_name='observation_hospita_dcd21a_idx',
|
||||||
|
old_name='obs_hospital_status_idx',
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='observation',
|
||||||
|
name='hospital',
|
||||||
|
field=models.ForeignKey(default=1, help_text='Hospital where observation was made', on_delete=django.db.models.deletion.CASCADE, related_name='observations', to='organizations.hospital'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='observation',
|
||||||
|
name='source',
|
||||||
|
field=models.CharField(choices=[('staff_portal', 'Staff Portal'), ('web_form', 'Web Form'), ('mobile_app', 'Mobile App'), ('email', 'Email'), ('call_center', 'Call Center'), ('other', 'Other')], default='staff_portal', help_text='How the observation was submitted', max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='observation',
|
||||||
|
name='staff',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Staff member mentioned in observation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='organizations.staff'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -166,6 +166,39 @@ class Observation(UUIDModel, TimeStampedModel):
|
|||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Organization (required for tenant isolation)
|
||||||
|
hospital = models.ForeignKey(
|
||||||
|
'organizations.Hospital',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='observations',
|
||||||
|
help_text="Hospital where observation was made"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Staff member mentioned in observation (optional, for AI-matching like complaints)
|
||||||
|
staff = models.ForeignKey(
|
||||||
|
'organizations.Staff',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='observations',
|
||||||
|
help_text="Staff member mentioned in observation"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Source tracking
|
||||||
|
source = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=[
|
||||||
|
('staff_portal', 'Staff Portal'),
|
||||||
|
('web_form', 'Web Form'),
|
||||||
|
('mobile_app', 'Mobile App'),
|
||||||
|
('email', 'Email'),
|
||||||
|
('call_center', 'Call Center'),
|
||||||
|
('other', 'Other'),
|
||||||
|
],
|
||||||
|
default='staff_portal',
|
||||||
|
help_text="How the observation was submitted"
|
||||||
|
)
|
||||||
|
|
||||||
# Internal routing
|
# Internal routing
|
||||||
assigned_department = models.ForeignKey(
|
assigned_department = models.ForeignKey(
|
||||||
'organizations.Department',
|
'organizations.Department',
|
||||||
@ -231,6 +264,7 @@ class Observation(UUIDModel, TimeStampedModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
|
models.Index(fields=['hospital', 'status', '-created_at']),
|
||||||
models.Index(fields=['status', '-created_at']),
|
models.Index(fields=['status', '-created_at']),
|
||||||
models.Index(fields=['severity', '-created_at']),
|
models.Index(fields=['severity', '-created_at']),
|
||||||
models.Index(fields=['tracking_code']),
|
models.Index(fields=['tracking_code']),
|
||||||
|
|||||||
406
apps/organizations/management/commands/seed_staff.py
Normal file
406
apps/organizations/management/commands/seed_staff.py
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
"""
|
||||||
|
Management command to seed staff data with bilingual support (English and Arabic)
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from apps.accounts.models import User
|
||||||
|
from apps.organizations.models import Hospital, Department, Staff
|
||||||
|
|
||||||
|
|
||||||
|
# Saudi names data - Paired to ensure English and Arabic correspond
|
||||||
|
NAMES_MALE = [
|
||||||
|
{'en': 'Mohammed', 'ar': 'محمد'},
|
||||||
|
{'en': 'Ahmed', 'ar': 'أحمد'},
|
||||||
|
{'en': 'Abdullah', 'ar': 'عبدالله'},
|
||||||
|
{'en': 'Khalid', 'ar': 'خالد'},
|
||||||
|
{'en': 'Saud', 'ar': 'سعود'},
|
||||||
|
{'en': 'Fahd', 'ar': 'فهد'},
|
||||||
|
{'en': 'Abdulaziz', 'ar': 'عبدالعزيز'},
|
||||||
|
{'en': 'Sultan', 'ar': 'سلطان'},
|
||||||
|
{'en': 'Faisal', 'ar': 'فيصل'},
|
||||||
|
{'en': 'Omar', 'ar': 'عمر'},
|
||||||
|
{'en': 'Turki', 'ar': 'تركي'},
|
||||||
|
{'en': 'Nasser', 'ar': 'ناصر'},
|
||||||
|
{'en': 'Mishari', 'ar': 'مشاري'},
|
||||||
|
{'en': 'Abdulrahman', 'ar': 'عبدالرحمن'},
|
||||||
|
{'en': 'Yousef', 'ar': 'يوسف'},
|
||||||
|
{'en': 'Ali', 'ar': 'علي'}
|
||||||
|
]
|
||||||
|
|
||||||
|
NAMES_FEMALE = [
|
||||||
|
{'en': 'Fatimah', 'ar': 'فاطمة'},
|
||||||
|
{'en': 'Aisha', 'ar': 'عائشة'},
|
||||||
|
{'en': 'Maryam', 'ar': 'مريم'},
|
||||||
|
{'en': 'Noura', 'ar': 'نورة'},
|
||||||
|
{'en': 'Sarah', 'ar': 'سارة'},
|
||||||
|
{'en': 'Hind', 'ar': 'هند'},
|
||||||
|
{'en': 'Latifa', 'ar': 'لطيفة'},
|
||||||
|
{'en': 'Mona', 'ar': 'منى'},
|
||||||
|
{'en': 'Reem', 'ar': 'ريم'},
|
||||||
|
{'en': 'Jawaher', 'ar': 'جواهر'},
|
||||||
|
{'en': 'Lina', 'ar': 'لينا'},
|
||||||
|
{'en': 'Dua', 'ar': 'دعاء'},
|
||||||
|
{'en': 'Maha', 'ar': 'مها'},
|
||||||
|
{'en': 'Jumanah', 'ar': 'جمانة'},
|
||||||
|
{'en': 'Rahaf', 'ar': 'رحاب'},
|
||||||
|
{'en': 'Samar', 'ar': 'سمر'}
|
||||||
|
]
|
||||||
|
|
||||||
|
LAST_NAMES = [
|
||||||
|
{'en': 'Al-Otaibi', 'ar': 'العتيبي'},
|
||||||
|
{'en': 'Al-Dosari', 'ar': 'الدوسري'},
|
||||||
|
{'en': 'Al-Qahtani', 'ar': 'القحطاني'},
|
||||||
|
{'en': 'Al-Shammari', 'ar': 'الشمري'},
|
||||||
|
{'en': 'Al-Harbi', 'ar': 'الحربي'},
|
||||||
|
{'en': 'Al-Mutairi', 'ar': 'المطيري'},
|
||||||
|
{'en': 'Al-Anazi', 'ar': 'العنزي'},
|
||||||
|
{'en': 'Al-Zahrani', 'ar': 'الزهراني'},
|
||||||
|
{'en': 'Al-Ghamdi', 'ar': 'الغامدي'},
|
||||||
|
{'en': 'Al-Shehri', 'ar': 'الشهري'},
|
||||||
|
{'en': 'Al-Salem', 'ar': 'السالم'},
|
||||||
|
{'en': 'Al-Fahad', 'ar': 'الفالح'}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Specializations for physicians
|
||||||
|
PHYSICIAN_SPECIALIZATIONS = [
|
||||||
|
'Internal Medicine', 'General Surgery', 'Pediatrics', 'Obstetrics & Gynecology',
|
||||||
|
'Cardiology', 'Orthopedics', 'Neurology', 'Dermatology', 'Ophthalmology',
|
||||||
|
'ENT', 'Urology', 'Nephrology', 'Gastroenterology', 'Pulmonology',
|
||||||
|
'Endocrinology', 'Rheumatology', 'Hematology', 'Oncology', 'Psychiatry'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Job titles for nurses
|
||||||
|
NURSE_JOB_TITLES = [
|
||||||
|
'Registered Nurse', 'Senior Nurse', 'Nurse Practitioner', 'ICU Nurse',
|
||||||
|
'Emergency Nurse', 'Pediatric Nurse', 'Operating Room Nurse', 'Head Nurse'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Job titles for admin staff
|
||||||
|
ADMIN_JOB_TITLES = [
|
||||||
|
'Medical Receptionist', 'Administrative Assistant', 'Medical Records Clerk',
|
||||||
|
'Hospital Administrator', 'Department Secretary', 'Patient Services Representative',
|
||||||
|
'Front Desk Officer', 'Billing Specialist'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Seed staff data with bilingual support (English and Arabic)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--hospital-code',
|
||||||
|
type=str,
|
||||||
|
help='Target hospital code (default: all hospitals)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--count',
|
||||||
|
type=int,
|
||||||
|
default=10,
|
||||||
|
help='Number of staff to create per type (default: 10)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--physicians',
|
||||||
|
type=int,
|
||||||
|
default=10,
|
||||||
|
help='Number of physicians to create (default: 10)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--nurses',
|
||||||
|
type=int,
|
||||||
|
default=15,
|
||||||
|
help='Number of nurses to create (default: 15)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--admin-staff',
|
||||||
|
type=int,
|
||||||
|
default=5,
|
||||||
|
help='Number of admin staff to create (default: 5)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--create-users',
|
||||||
|
action='store_true',
|
||||||
|
help='Create user accounts for staff'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--clear',
|
||||||
|
action='store_true',
|
||||||
|
help='Clear existing staff first'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run',
|
||||||
|
action='store_true',
|
||||||
|
help='Preview without making changes'
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
hospital_code = options['hospital_code']
|
||||||
|
count = options['count']
|
||||||
|
physicians_count = options['physicians']
|
||||||
|
nurses_count = options['nurses']
|
||||||
|
admin_staff_count = options['admin_staff']
|
||||||
|
create_users = options['create_users']
|
||||||
|
clear_existing = options['clear']
|
||||||
|
dry_run = options['dry_run']
|
||||||
|
|
||||||
|
self.stdout.write(f"\n{'='*60}")
|
||||||
|
self.stdout.write("Staff Data Seeding Command")
|
||||||
|
self.stdout.write(f"{'='*60}\n")
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Get hospitals
|
||||||
|
if hospital_code:
|
||||||
|
hospitals = Hospital.objects.filter(code=hospital_code)
|
||||||
|
if not hospitals.exists():
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR(f"Hospital with code '{hospital_code}' not found")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
hospitals = Hospital.objects.filter(status='active')
|
||||||
|
|
||||||
|
if not hospitals.exists():
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.ERROR("No hospitals found. Please create hospitals first.")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"Found {hospitals.count()} hospital(s) to seed staff")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display configuration
|
||||||
|
self.stdout.write("\nConfiguration:")
|
||||||
|
self.stdout.write(f" Physicians per hospital: {physicians_count}")
|
||||||
|
self.stdout.write(f" Nurses per hospital: {nurses_count}")
|
||||||
|
self.stdout.write(f" Admin staff per hospital: {admin_staff_count}")
|
||||||
|
self.stdout.write(f" Total staff per hospital: {physicians_count + nurses_count + admin_staff_count}")
|
||||||
|
self.stdout.write(f" Create user accounts: {create_users}")
|
||||||
|
self.stdout.write(f" Clear existing: {clear_existing}")
|
||||||
|
self.stdout.write(f" Dry run: {dry_run}")
|
||||||
|
|
||||||
|
# Get all departments for assignment
|
||||||
|
all_departments = Department.objects.filter(status='active')
|
||||||
|
if not all_departments.exists():
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING("\nNo departments found. Staff will be created without departments.")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear existing staff if requested
|
||||||
|
if clear_existing:
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f"\nWould delete {Staff.objects.count()} existing staff")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
deleted_count = Staff.objects.count()
|
||||||
|
Staff.objects.all().delete()
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"\n✓ Deleted {deleted_count} existing staff")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track created staff
|
||||||
|
created_staff = []
|
||||||
|
|
||||||
|
# Seed physicians
|
||||||
|
physicians = self.create_staff_type(
|
||||||
|
hospitals=hospitals,
|
||||||
|
departments=all_departments,
|
||||||
|
staff_type=Staff.StaffType.PHYSICIAN,
|
||||||
|
count=physicians_count,
|
||||||
|
job_titles=PHYSICIAN_SPECIALIZATIONS,
|
||||||
|
create_users=create_users,
|
||||||
|
dry_run=dry_run
|
||||||
|
)
|
||||||
|
created_staff.extend(physicians)
|
||||||
|
|
||||||
|
# Seed nurses
|
||||||
|
nurses = self.create_staff_type(
|
||||||
|
hospitals=hospitals,
|
||||||
|
departments=all_departments,
|
||||||
|
staff_type=Staff.StaffType.NURSE,
|
||||||
|
count=nurses_count,
|
||||||
|
job_titles=NURSE_JOB_TITLES,
|
||||||
|
create_users=create_users,
|
||||||
|
dry_run=dry_run
|
||||||
|
)
|
||||||
|
created_staff.extend(nurses)
|
||||||
|
|
||||||
|
# Seed admin staff
|
||||||
|
admins = self.create_staff_type(
|
||||||
|
hospitals=hospitals,
|
||||||
|
departments=all_departments,
|
||||||
|
staff_type=Staff.StaffType.ADMIN,
|
||||||
|
count=admin_staff_count,
|
||||||
|
job_titles=ADMIN_JOB_TITLES,
|
||||||
|
create_users=create_users,
|
||||||
|
dry_run=dry_run
|
||||||
|
)
|
||||||
|
created_staff.extend(admins)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
self.stdout.write("\n" + "="*60)
|
||||||
|
self.stdout.write("Summary:")
|
||||||
|
self.stdout.write(f" Physicians created: {len(physicians)}")
|
||||||
|
self.stdout.write(f" Nurses created: {len(nurses)}")
|
||||||
|
self.stdout.write(f" Admin staff created: {len(admins)}")
|
||||||
|
self.stdout.write(f" Total staff created: {len(created_staff)}")
|
||||||
|
self.stdout.write("="*60 + "\n")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING("DRY RUN: No changes were made\n"))
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS("Staff seeding completed successfully!\n"))
|
||||||
|
|
||||||
|
def create_staff_type(self, hospitals, departments, staff_type, count, job_titles,
|
||||||
|
create_users, dry_run):
|
||||||
|
"""Create staff of a specific type"""
|
||||||
|
created = []
|
||||||
|
staff_type_display = dict(Staff.StaffType.choices).get(staff_type, staff_type)
|
||||||
|
|
||||||
|
self.stdout.write(f"\nSeeding {staff_type_display}...")
|
||||||
|
|
||||||
|
for hospital in hospitals:
|
||||||
|
# Get departments for this hospital
|
||||||
|
hospital_depts = [d for d in departments if d.hospital == hospital]
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
# Generate names (paired to ensure English and Arabic correspond)
|
||||||
|
if staff_type == Staff.StaffType.NURSE:
|
||||||
|
# More female nurses (70%)
|
||||||
|
if random.random() < 0.7:
|
||||||
|
first_name = random.choice(NAMES_FEMALE)
|
||||||
|
else:
|
||||||
|
first_name = random.choice(NAMES_MALE)
|
||||||
|
else:
|
||||||
|
# Physicians and admin staff are mostly male (60%)
|
||||||
|
if random.random() < 0.6:
|
||||||
|
first_name = random.choice(NAMES_MALE)
|
||||||
|
else:
|
||||||
|
first_name = random.choice(NAMES_FEMALE)
|
||||||
|
|
||||||
|
last_name = random.choice(LAST_NAMES)
|
||||||
|
|
||||||
|
job_title = random.choice(job_titles)
|
||||||
|
|
||||||
|
# Generate employee ID
|
||||||
|
employee_id = self.generate_employee_id(hospital.code, staff_type)
|
||||||
|
|
||||||
|
# Generate license number for physicians
|
||||||
|
license_number = None
|
||||||
|
if staff_type == Staff.StaffType.PHYSICIAN:
|
||||||
|
license_number = self.generate_license_number()
|
||||||
|
|
||||||
|
# Select department (optional)
|
||||||
|
department = random.choice(hospital_depts) if hospital_depts and random.random() > 0.1 else None
|
||||||
|
|
||||||
|
# Specialization for all staff types (required field)
|
||||||
|
if staff_type == Staff.StaffType.PHYSICIAN:
|
||||||
|
specialization = job_title
|
||||||
|
elif staff_type == Staff.StaffType.NURSE:
|
||||||
|
specialization = random.choice(['General Nursing', 'Patient Care', 'Clinical Nursing', 'Nursing'])
|
||||||
|
elif staff_type == Staff.StaffType.ADMIN:
|
||||||
|
specialization = random.choice(['Administration', 'Hospital Operations', 'Medical Administration', 'Healthcare Admin'])
|
||||||
|
else:
|
||||||
|
specialization = 'General Staff'
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(
|
||||||
|
f" Would create: {first_name['en']} {last_name['en']} ({first_name['ar']} {last_name['ar']}) - {job_title}"
|
||||||
|
)
|
||||||
|
created.append({'type': staff_type_display, 'name': f"{first_name['en']} {last_name['en']}"})
|
||||||
|
else:
|
||||||
|
# Create staff
|
||||||
|
staff = Staff.objects.create(
|
||||||
|
first_name=first_name['en'],
|
||||||
|
last_name=last_name['en'],
|
||||||
|
first_name_ar=first_name['ar'],
|
||||||
|
last_name_ar=last_name['ar'],
|
||||||
|
staff_type=staff_type,
|
||||||
|
job_title=job_title,
|
||||||
|
license_number=license_number,
|
||||||
|
specialization=specialization,
|
||||||
|
employee_id=employee_id,
|
||||||
|
hospital=hospital,
|
||||||
|
department=department,
|
||||||
|
status='active'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create user account if requested
|
||||||
|
if create_users:
|
||||||
|
self.create_user_for_staff(staff)
|
||||||
|
|
||||||
|
created.append(staff)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ Created {len([s for s in created if isinstance(s, Staff)])} {staff_type_display}")
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|
||||||
|
def generate_employee_id(self, hospital_code, staff_type):
|
||||||
|
"""Generate unique employee ID"""
|
||||||
|
prefix = {
|
||||||
|
Staff.StaffType.PHYSICIAN: 'DR',
|
||||||
|
Staff.StaffType.NURSE: 'RN',
|
||||||
|
Staff.StaffType.ADMIN: 'ADM',
|
||||||
|
Staff.StaffType.OTHER: 'STF'
|
||||||
|
}.get(staff_type, 'STF')
|
||||||
|
|
||||||
|
random_num = random.randint(10000, 99999)
|
||||||
|
return f"{prefix}-{hospital_code}-{random_num}"
|
||||||
|
|
||||||
|
def generate_license_number(self):
|
||||||
|
"""Generate unique license number"""
|
||||||
|
random_num = random.randint(1000000, 9999999)
|
||||||
|
return f"MOH-LIC-{random_num}"
|
||||||
|
|
||||||
|
def create_user_for_staff(self, staff):
|
||||||
|
"""Create a user account for staff"""
|
||||||
|
username = self.generate_username(staff)
|
||||||
|
|
||||||
|
# Check if user already exists
|
||||||
|
if User.objects.filter(username=username).exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate email
|
||||||
|
email = f"{staff.first_name.lower()}.{staff.last_name.lower()}@{staff.hospital.code.lower()}.sa"
|
||||||
|
|
||||||
|
# Check if email exists
|
||||||
|
if User.objects.filter(email=email).exists():
|
||||||
|
email = f"{username}@{staff.hospital.code.lower()}.sa"
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
first_name=staff.first_name,
|
||||||
|
last_name=staff.last_name,
|
||||||
|
password='password123', # Default password
|
||||||
|
employee_id=staff.employee_id,
|
||||||
|
hospital=staff.hospital,
|
||||||
|
department=staff.department,
|
||||||
|
language='ar' if random.random() < 0.5 else 'en', # Random language preference
|
||||||
|
is_staff=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Link staff to user
|
||||||
|
staff.user = user
|
||||||
|
staff.save(update_fields=['user'])
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ Created user: {username}")
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_username(self, staff):
|
||||||
|
"""Generate unique username"""
|
||||||
|
base = f"{staff.first_name.lower()}.{staff.last_name.lower()}"
|
||||||
|
username = base
|
||||||
|
|
||||||
|
# Add suffix if username exists
|
||||||
|
counter = 1
|
||||||
|
while User.objects.filter(username=username).exists():
|
||||||
|
username = f"{base}{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
return username
|
||||||
19
apps/organizations/migrations/0005_alter_staff_department.py
Normal file
19
apps/organizations/migrations/0005_alter_staff_department.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.0.14 on 2026-01-07 08:54
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('organizations', '0004_remove_patient_department'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='staff',
|
||||||
|
name='department',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff', to='organizations.department'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -161,7 +161,7 @@ class Staff(UUIDModel, TimeStampedModel):
|
|||||||
|
|
||||||
# Organization
|
# Organization
|
||||||
hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='staff')
|
hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='staff')
|
||||||
department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True)
|
department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True, related_name='staff')
|
||||||
|
|
||||||
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE)
|
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE)
|
||||||
|
|
||||||
|
|||||||
4
apps/references/__init__.py
Normal file
4
apps/references/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
References app - Reference Section for document management
|
||||||
|
"""
|
||||||
|
default_app_config = 'apps.references.apps.ReferencesConfig'
|
||||||
120
apps/references/admin.py
Normal file
120
apps/references/admin.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
References admin - Django admin configuration for Reference Section
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
|
from apps.references.models import ReferenceFolder, ReferenceDocument, ReferenceDocumentAccess
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ReferenceFolder)
|
||||||
|
class ReferenceFolderAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for ReferenceFolder"""
|
||||||
|
list_display = ['name', 'name_ar', 'hospital', 'parent', 'get_document_count', 'is_active', 'order']
|
||||||
|
list_filter = ['hospital', 'is_active', 'parent']
|
||||||
|
search_fields = ['name', 'name_ar', 'description', 'description_ar']
|
||||||
|
prepopulated_fields = {}
|
||||||
|
readonly_fields = ['created_at', 'updated_at', 'get_full_path_display']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('hospital', 'name', 'name_ar', 'parent')
|
||||||
|
}),
|
||||||
|
('Description', {
|
||||||
|
'fields': ('description', 'description_ar')
|
||||||
|
}),
|
||||||
|
('UI Customization', {
|
||||||
|
'fields': ('icon', 'color', 'order')
|
||||||
|
}),
|
||||||
|
('Access Control', {
|
||||||
|
'fields': ('access_roles', 'is_active')
|
||||||
|
}),
|
||||||
|
('System Information', {
|
||||||
|
'fields': ('get_full_path_display', 'is_deleted', 'deleted_at', 'created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_horizontal = ['access_roles']
|
||||||
|
|
||||||
|
def get_full_path_display(self, obj):
|
||||||
|
"""Display full path in admin"""
|
||||||
|
return obj.get_full_path()
|
||||||
|
get_full_path_display.short_description = 'Full Path'
|
||||||
|
|
||||||
|
def get_document_count(self, obj):
|
||||||
|
"""Display document count"""
|
||||||
|
count = obj.get_document_count()
|
||||||
|
return f"{count} document{'s' if count != 1 else ''}"
|
||||||
|
get_document_count.short_description = 'Documents'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ReferenceDocument)
|
||||||
|
class ReferenceDocumentAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for ReferenceDocument"""
|
||||||
|
list_display = ['title', 'folder', 'hospital', 'get_file_icon_display', 'version', 'is_latest_version', 'download_count', 'is_published']
|
||||||
|
list_filter = ['hospital', 'folder', 'file_type', 'is_latest_version', 'is_published']
|
||||||
|
search_fields = ['title', 'title_ar', 'filename', 'description', 'description_ar', 'tags']
|
||||||
|
prepopulated_fields = {}
|
||||||
|
readonly_fields = ['file_type', 'file_size', 'download_count', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('hospital', 'folder', 'title', 'title_ar')
|
||||||
|
}),
|
||||||
|
('File', {
|
||||||
|
'fields': ('file', 'filename', 'file_type', 'file_size', 'get_file_icon_display')
|
||||||
|
}),
|
||||||
|
('Description', {
|
||||||
|
'fields': ('description', 'description_ar')
|
||||||
|
}),
|
||||||
|
('Versioning', {
|
||||||
|
'fields': ('version', 'is_latest_version', 'parent_document')
|
||||||
|
}),
|
||||||
|
('Upload Information', {
|
||||||
|
'fields': ('uploaded_by',)
|
||||||
|
}),
|
||||||
|
('Usage Tracking', {
|
||||||
|
'fields': ('download_count', 'last_accessed_at')
|
||||||
|
}),
|
||||||
|
('Access Control', {
|
||||||
|
'fields': ('access_roles', 'is_published')
|
||||||
|
}),
|
||||||
|
('Tags', {
|
||||||
|
'fields': ('tags', 'metadata')
|
||||||
|
}),
|
||||||
|
('System Information', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at', 'created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_horizontal = ['access_roles']
|
||||||
|
|
||||||
|
def get_file_icon_display(self, obj):
|
||||||
|
"""Display file icon"""
|
||||||
|
icon_class = obj.get_file_icon()
|
||||||
|
return format_html('<i class="fa {}"></i>', icon_class)
|
||||||
|
get_file_icon_display.short_description = 'File Type'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ReferenceDocumentAccess)
|
||||||
|
class ReferenceDocumentAccessAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for ReferenceDocumentAccess"""
|
||||||
|
list_display = ['document', 'user', 'action', 'ip_address', 'created_at']
|
||||||
|
list_filter = ['action', 'created_at', 'document__hospital']
|
||||||
|
search_fields = ['user__email', 'user__username', 'document__title']
|
||||||
|
readonly_fields = ['created_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Access Details', {
|
||||||
|
'fields': ('document', 'user', 'action')
|
||||||
|
}),
|
||||||
|
('Context', {
|
||||||
|
'fields': ('ip_address', 'user_agent')
|
||||||
|
}),
|
||||||
|
('System Information', {
|
||||||
|
'fields': ('created_at',),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
11
apps/references/apps.py
Normal file
11
apps/references/apps.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
References app configuration
|
||||||
|
"""
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ReferencesConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.references'
|
||||||
|
verbose_name = 'Reference Section'
|
||||||
|
verbose_name_plural = 'Reference Section'
|
||||||
256
apps/references/forms.py
Normal file
256
apps/references/forms.py
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
"""
|
||||||
|
References forms - Forms for Reference Section
|
||||||
|
"""
|
||||||
|
from django import forms
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from apps.references.models import ReferenceFolder, ReferenceDocument
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceFolderForm(forms.ModelForm):
|
||||||
|
"""Form for creating and editing reference folders"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ReferenceFolder
|
||||||
|
fields = [
|
||||||
|
'name', 'name_ar',
|
||||||
|
'description', 'description_ar',
|
||||||
|
'parent', 'icon', 'color', 'order',
|
||||||
|
'access_roles', 'is_active'
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('Enter folder name (English)')
|
||||||
|
}),
|
||||||
|
'name_ar': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('Enter folder name (Arabic)')
|
||||||
|
}),
|
||||||
|
'description': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': _('Enter folder description (English)')
|
||||||
|
}),
|
||||||
|
'description_ar': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': _('Enter folder description (Arabic)')
|
||||||
|
}),
|
||||||
|
'parent': forms.Select(attrs={
|
||||||
|
'class': 'form-select'
|
||||||
|
}),
|
||||||
|
'icon': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('e.g., fa-folder, fa-file-pdf')
|
||||||
|
}),
|
||||||
|
'color': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('e.g., #007bff')
|
||||||
|
}),
|
||||||
|
'order': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'min': 0
|
||||||
|
}),
|
||||||
|
'access_roles': forms.CheckboxSelectMultiple(),
|
||||||
|
'is_active': forms.CheckboxInput(attrs={
|
||||||
|
'class': 'form-check-input'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
hospital = kwargs.pop('hospital', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Filter parent folders by hospital
|
||||||
|
if hospital:
|
||||||
|
self.fields['parent'].queryset = ReferenceFolder.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter access roles
|
||||||
|
if hospital:
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
self.fields['access_roles'].queryset = Group.objects.all()
|
||||||
|
|
||||||
|
def clean_name(self):
|
||||||
|
"""Ensure name is provided in at least one language"""
|
||||||
|
name = self.cleaned_data.get('name', '')
|
||||||
|
name_ar = self.cleaned_data.get('name_ar', '')
|
||||||
|
|
||||||
|
if not name and not name_ar:
|
||||||
|
raise forms.ValidationError(_('Please provide a name in English or Arabic.'))
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceDocumentForm(forms.ModelForm):
|
||||||
|
"""Form for uploading and editing reference documents"""
|
||||||
|
|
||||||
|
new_version = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_('Upload as new version'),
|
||||||
|
help_text=_('Check this to create a new version of an existing document')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ReferenceDocument
|
||||||
|
fields = [
|
||||||
|
'folder', 'title', 'title_ar',
|
||||||
|
'file', 'description', 'description_ar',
|
||||||
|
'access_roles', 'is_published',
|
||||||
|
'tags'
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'folder': forms.Select(attrs={
|
||||||
|
'class': 'form-select'
|
||||||
|
}),
|
||||||
|
'title': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('Enter document title (English)')
|
||||||
|
}),
|
||||||
|
'title_ar': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('Enter document title (Arabic)')
|
||||||
|
}),
|
||||||
|
'file': forms.FileInput(attrs={
|
||||||
|
'class': 'form-control'
|
||||||
|
}),
|
||||||
|
'description': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 4,
|
||||||
|
'placeholder': _('Enter document description (English)')
|
||||||
|
}),
|
||||||
|
'description_ar': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 4,
|
||||||
|
'placeholder': _('Enter document description (Arabic)')
|
||||||
|
}),
|
||||||
|
'access_roles': forms.CheckboxSelectMultiple(),
|
||||||
|
'is_published': forms.CheckboxInput(attrs={
|
||||||
|
'class': 'form-check-input'
|
||||||
|
}),
|
||||||
|
'tags': forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('e.g., policy, procedure, handbook (comma-separated)')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
hospital = kwargs.pop('hospital', None)
|
||||||
|
folder = kwargs.pop('folder', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Filter folders by hospital
|
||||||
|
if hospital:
|
||||||
|
self.fields['folder'].queryset = ReferenceFolder.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter access roles
|
||||||
|
if hospital:
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
self.fields['access_roles'].queryset = Group.objects.all()
|
||||||
|
|
||||||
|
# Make file required for new documents, optional for updates
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
self.fields['file'].required = False
|
||||||
|
else:
|
||||||
|
self.fields['file'].required = True
|
||||||
|
|
||||||
|
# Make folder optional when uploading from within a folder
|
||||||
|
if folder:
|
||||||
|
self.fields['folder'].required = False
|
||||||
|
self.fields['folder'].initial = folder
|
||||||
|
|
||||||
|
def clean_title(self):
|
||||||
|
"""Ensure title is provided in at least one language"""
|
||||||
|
title = self.cleaned_data.get('title', '')
|
||||||
|
title_ar = self.cleaned_data.get('title_ar', '')
|
||||||
|
|
||||||
|
if not title and not title_ar:
|
||||||
|
raise forms.ValidationError(_('Please provide a title in English or Arabic.'))
|
||||||
|
|
||||||
|
return title
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Custom validation for new version creation"""
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
new_version = cleaned_data.get('new_version')
|
||||||
|
file = cleaned_data.get('file')
|
||||||
|
|
||||||
|
# If uploading as new version, file is required
|
||||||
|
if new_version and not file and not (self.instance and self.instance.pk and self.instance.file):
|
||||||
|
raise forms.ValidationError({
|
||||||
|
'file': _('File is required when uploading a new version.')
|
||||||
|
})
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceDocumentSearchForm(forms.Form):
|
||||||
|
"""Form for searching and filtering documents"""
|
||||||
|
|
||||||
|
search = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
label=_('Search'),
|
||||||
|
widget=forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('Search by title, description, or tags...')
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
folder = forms.ModelChoiceField(
|
||||||
|
required=False,
|
||||||
|
label=_('Folder'),
|
||||||
|
queryset=ReferenceFolder.objects.none(),
|
||||||
|
widget=forms.Select(attrs={
|
||||||
|
'class': 'form-select'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
file_type = forms.ChoiceField(
|
||||||
|
required=False,
|
||||||
|
label=_('File Type'),
|
||||||
|
choices=[
|
||||||
|
('', _('All Types')),
|
||||||
|
('pdf', 'PDF'),
|
||||||
|
('doc', 'Word'),
|
||||||
|
('docx', 'Word'),
|
||||||
|
('xls', 'Excel'),
|
||||||
|
('xlsx', 'Excel'),
|
||||||
|
('ppt', 'PowerPoint'),
|
||||||
|
('pptx', 'PowerPoint'),
|
||||||
|
('jpg', 'Image'),
|
||||||
|
('jpeg', 'Image'),
|
||||||
|
('png', 'Image'),
|
||||||
|
('gif', 'Image'),
|
||||||
|
],
|
||||||
|
widget=forms.Select(attrs={
|
||||||
|
'class': 'form-select'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
tags = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
label=_('Tags'),
|
||||||
|
widget=forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': _('Filter by tags...')
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
hospital = kwargs.pop('hospital', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Filter folders by hospital
|
||||||
|
if hospital:
|
||||||
|
self.fields['folder'].queryset = ReferenceFolder.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
121
apps/references/migrations/0001_initial.py
Normal file
121
apps/references/migrations/0001_initial.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
# Generated by Django 5.0.14 on 2026-01-07 16:18
|
||||||
|
|
||||||
|
import apps.references.models
|
||||||
|
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 = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
('organizations', '0005_alter_staff_department'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReferenceFolder',
|
||||||
|
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)),
|
||||||
|
('is_deleted', models.BooleanField(db_index=True, default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('name', models.CharField(db_index=True, max_length=200)),
|
||||||
|
('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('description_ar', models.TextField(blank=True, verbose_name='Description (Arabic)')),
|
||||||
|
('icon', models.CharField(blank=True, help_text="Icon class (e.g., 'fa-folder', 'fa-file-pdf')", max_length=50)),
|
||||||
|
('color', models.CharField(blank=True, help_text="Hex color code (e.g., '#007bff')", max_length=7)),
|
||||||
|
('order', models.IntegerField(default=0, help_text='Display order within parent folder')),
|
||||||
|
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||||
|
('access_roles', models.ManyToManyField(blank=True, help_text='Roles that can access this folder (empty = all roles)', related_name='accessible_folders', to='auth.group')),
|
||||||
|
('hospital', models.ForeignKey(help_text='Tenant hospital for this record', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', to='organizations.hospital')),
|
||||||
|
('parent', models.ForeignKey(blank=True, help_text='Parent folder for nested structure', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subfolders', to='references.referencefolder')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Reference Folder',
|
||||||
|
'verbose_name_plural': 'Reference Folders',
|
||||||
|
'ordering': ['parent__order', 'order', 'name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReferenceDocument',
|
||||||
|
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)),
|
||||||
|
('is_deleted', models.BooleanField(db_index=True, default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('title', models.CharField(db_index=True, max_length=500)),
|
||||||
|
('title_ar', models.CharField(blank=True, max_length=500, verbose_name='Title (Arabic)')),
|
||||||
|
('file', models.FileField(max_length=500, upload_to=apps.references.models.document_upload_path)),
|
||||||
|
('filename', models.CharField(help_text='Original filename', max_length=500)),
|
||||||
|
('file_type', models.CharField(blank=True, help_text='File extension/type (e.g., pdf, docx, xlsx)', max_length=50)),
|
||||||
|
('file_size', models.IntegerField(help_text='File size in bytes')),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('description_ar', models.TextField(blank=True, verbose_name='Description (Arabic)')),
|
||||||
|
('version', models.CharField(default='1.0', help_text='Document version (e.g., 1.0, 1.1, 2.0)', max_length=20)),
|
||||||
|
('is_latest_version', models.BooleanField(db_index=True, default=True, help_text='Is this the latest version?')),
|
||||||
|
('download_count', models.IntegerField(default=0, help_text='Number of downloads')),
|
||||||
|
('last_accessed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('is_published', models.BooleanField(db_index=True, default=True, help_text='Is this document visible to users?')),
|
||||||
|
('tags', models.CharField(blank=True, help_text='Comma-separated tags for search (e.g., policy, procedure, handbook)', max_length=500)),
|
||||||
|
('metadata', models.JSONField(blank=True, default=dict)),
|
||||||
|
('access_roles', models.ManyToManyField(blank=True, help_text='Roles that can access this document (empty = all roles)', related_name='accessible_documents', to='auth.group')),
|
||||||
|
('hospital', models.ForeignKey(help_text='Tenant hospital for this record', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', to='organizations.hospital')),
|
||||||
|
('parent_document', models.ForeignKey(blank=True, help_text='Previous version of this document', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='versions', to='references.referencedocument')),
|
||||||
|
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_documents', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('folder', models.ForeignKey(blank=True, help_text='Folder containing this document', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='references.referencefolder')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Reference Document',
|
||||||
|
'verbose_name_plural': 'Reference Documents',
|
||||||
|
'ordering': ['title', '-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReferenceDocumentAccess',
|
||||||
|
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)),
|
||||||
|
('action', models.CharField(choices=[('view', 'Viewed'), ('download', 'Downloaded'), ('preview', 'Previewed')], db_index=True, max_length=20)),
|
||||||
|
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
('user_agent', models.TextField(blank=True)),
|
||||||
|
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_logs', to='references.referencedocument')),
|
||||||
|
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='document_accesses', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Document Access Log',
|
||||||
|
'verbose_name_plural': 'Document Access Logs',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['document', '-created_at'], name='references__documen_396fa2_idx'), models.Index(fields=['user', '-created_at'], name='references__user_id_56bf5f_idx'), models.Index(fields=['action', '-created_at'], name='references__action_b9899d_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='referencefolder',
|
||||||
|
index=models.Index(fields=['hospital', 'parent', 'order'], name='references__hospita_faa66f_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='referencefolder',
|
||||||
|
index=models.Index(fields=['hospital', 'is_active'], name='references__hospita_6c43f3_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='referencedocument',
|
||||||
|
index=models.Index(fields=['hospital', 'folder', 'is_latest_version'], name='references__hospita_36d516_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='referencedocument',
|
||||||
|
index=models.Index(fields=['hospital', 'is_published'], name='references__hospita_413b58_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='referencedocument',
|
||||||
|
index=models.Index(fields=['folder', 'title'], name='references__folder__e09b4c_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
1
apps/references/migrations/__init__.py
Normal file
1
apps/references/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Migrations for references app
|
||||||
389
apps/references/models.py
Normal file
389
apps/references/models.py
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
"""
|
||||||
|
References models - Reference Section for document management
|
||||||
|
|
||||||
|
This module implements a file server system for managing reference documents
|
||||||
|
with folder categorization, version control, and role-based access control.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.core.models import TenantModel, TimeStampedModel, UUIDModel, SoftDeleteModel
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def document_upload_path(instance, filename):
|
||||||
|
"""
|
||||||
|
Generate upload path for reference documents.
|
||||||
|
|
||||||
|
Format: references/<hospital_id>/YYYY/MM/DD/<uuid>_<filename>
|
||||||
|
"""
|
||||||
|
hospital_id = instance.hospital.id if instance.hospital else 'default'
|
||||||
|
from django.utils import timezone
|
||||||
|
date_str = timezone.now().strftime('%Y/%m/%d')
|
||||||
|
# Get file extension
|
||||||
|
ext = os.path.splitext(filename)[1]
|
||||||
|
return f'references/{hospital_id}/{date_str}/{instance.id}{ext}'
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceFolder(UUIDModel, TimeStampedModel, SoftDeleteModel, TenantModel):
|
||||||
|
"""
|
||||||
|
Reference Folder model for organizing documents into hierarchical folders.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Hospital-specific folders (via TenantModel)
|
||||||
|
- Nested folder hierarchy via self-referential parent field
|
||||||
|
- Bilingual support (English/Arabic)
|
||||||
|
- Role-based access control
|
||||||
|
- Soft delete support
|
||||||
|
"""
|
||||||
|
# Bilingual folder name
|
||||||
|
name = models.CharField(max_length=200, db_index=True)
|
||||||
|
name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)")
|
||||||
|
|
||||||
|
# Description
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
description_ar = models.TextField(blank=True, verbose_name="Description (Arabic)")
|
||||||
|
|
||||||
|
# Hierarchy - self-referential for nested folders
|
||||||
|
parent = models.ForeignKey(
|
||||||
|
'self',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='subfolders',
|
||||||
|
help_text="Parent folder for nested structure"
|
||||||
|
)
|
||||||
|
|
||||||
|
# UI customization
|
||||||
|
icon = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
help_text="Icon class (e.g., 'fa-folder', 'fa-file-pdf')"
|
||||||
|
)
|
||||||
|
color = models.CharField(
|
||||||
|
max_length=7,
|
||||||
|
blank=True,
|
||||||
|
help_text="Hex color code (e.g., '#007bff')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ordering
|
||||||
|
order = models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Display order within parent folder"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Role-based access control
|
||||||
|
access_roles = models.ManyToManyField(
|
||||||
|
'auth.Group',
|
||||||
|
blank=True,
|
||||||
|
related_name='accessible_folders',
|
||||||
|
help_text="Roles that can access this folder (empty = all roles)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = models.BooleanField(default=True, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['parent__order', 'order', 'name']
|
||||||
|
verbose_name = 'Reference Folder'
|
||||||
|
verbose_name_plural = 'Reference Folders'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['hospital', 'parent', 'order']),
|
||||||
|
models.Index(fields=['hospital', 'is_active']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
hospital_name = self.hospital.name if self.hospital else 'No Hospital'
|
||||||
|
return f"{hospital_name} - {self.name}"
|
||||||
|
|
||||||
|
def get_full_path(self):
|
||||||
|
"""Get full folder path as a list of folder names"""
|
||||||
|
path = [self.name]
|
||||||
|
parent = self.parent
|
||||||
|
while parent:
|
||||||
|
path.insert(0, parent.name)
|
||||||
|
parent = parent.parent
|
||||||
|
return ' / '.join(path)
|
||||||
|
|
||||||
|
def has_access(self, user):
|
||||||
|
"""
|
||||||
|
Check if user has access to this folder.
|
||||||
|
|
||||||
|
Returns True if:
|
||||||
|
- Folder has no access_roles (public to all)
|
||||||
|
- User belongs to any of the access_roles
|
||||||
|
"""
|
||||||
|
if not self.access_roles.exists():
|
||||||
|
return True
|
||||||
|
return user.groups.filter(id__in=self.access_roles.all()).exists()
|
||||||
|
|
||||||
|
def get_subfolders(self):
|
||||||
|
"""Get all immediate subfolders"""
|
||||||
|
return self.subfolders.filter(is_active=True, is_deleted=False)
|
||||||
|
|
||||||
|
def get_documents(self):
|
||||||
|
"""Get all documents in this folder"""
|
||||||
|
return self.documents.filter(is_published=True, is_deleted=False)
|
||||||
|
|
||||||
|
def get_document_count(self):
|
||||||
|
"""Get count of documents in this folder (including subfolders)"""
|
||||||
|
count = self.get_documents().count()
|
||||||
|
for subfolder in self.get_subfolders():
|
||||||
|
count += subfolder.get_document_count()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceDocument(UUIDModel, TimeStampedModel, SoftDeleteModel, TenantModel):
|
||||||
|
"""
|
||||||
|
Reference Document model for storing and managing documents.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Multi-version support
|
||||||
|
- Multiple file types (PDF, Word, Excel, etc.)
|
||||||
|
- Role-based access control
|
||||||
|
- Download tracking
|
||||||
|
- Bilingual support
|
||||||
|
- Soft delete support
|
||||||
|
"""
|
||||||
|
# Folder association
|
||||||
|
folder = models.ForeignKey(
|
||||||
|
ReferenceFolder,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='documents',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Folder containing this document"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bilingual title
|
||||||
|
title = models.CharField(max_length=500, db_index=True)
|
||||||
|
title_ar = models.CharField(max_length=500, blank=True, verbose_name="Title (Arabic)")
|
||||||
|
|
||||||
|
# File information
|
||||||
|
file = models.FileField(upload_to=document_upload_path, max_length=500)
|
||||||
|
filename = models.CharField(max_length=500, help_text="Original filename")
|
||||||
|
file_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
help_text="File extension/type (e.g., pdf, docx, xlsx)"
|
||||||
|
)
|
||||||
|
file_size = models.IntegerField(help_text="File size in bytes")
|
||||||
|
|
||||||
|
# Description
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
description_ar = models.TextField(blank=True, verbose_name="Description (Arabic)")
|
||||||
|
|
||||||
|
# Versioning
|
||||||
|
version = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
default='1.0',
|
||||||
|
help_text="Document version (e.g., 1.0, 1.1, 2.0)"
|
||||||
|
)
|
||||||
|
is_latest_version = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Is this the latest version?"
|
||||||
|
)
|
||||||
|
parent_document = models.ForeignKey(
|
||||||
|
'self',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='versions',
|
||||||
|
help_text="Previous version of this document"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload tracking
|
||||||
|
uploaded_by = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
related_name='uploaded_documents'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Usage tracking
|
||||||
|
download_count = models.IntegerField(default=0, help_text="Number of downloads")
|
||||||
|
last_accessed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Visibility
|
||||||
|
is_published = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Is this document visible to users?"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Role-based access control
|
||||||
|
access_roles = models.ManyToManyField(
|
||||||
|
'auth.Group',
|
||||||
|
blank=True,
|
||||||
|
related_name='accessible_documents',
|
||||||
|
help_text="Roles that can access this document (empty = all roles)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tags for search
|
||||||
|
tags = models.CharField(
|
||||||
|
max_length=500,
|
||||||
|
blank=True,
|
||||||
|
help_text="Comma-separated tags for search (e.g., policy, procedure, handbook)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Additional metadata
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['title', '-created_at']
|
||||||
|
verbose_name = 'Reference Document'
|
||||||
|
verbose_name_plural = 'Reference Documents'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['hospital', 'folder', 'is_latest_version']),
|
||||||
|
models.Index(fields=['hospital', 'is_published']),
|
||||||
|
models.Index(fields=['folder', 'title']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
title = self.title or self.filename
|
||||||
|
version = f" v{self.version}" if self.version else ""
|
||||||
|
return f"{title}{version}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Validate file type"""
|
||||||
|
if self.file:
|
||||||
|
ext = os.path.splitext(self.file.name)[1].lower().lstrip('.')
|
||||||
|
# Allowed file types - can be extended as needed
|
||||||
|
allowed_types = [
|
||||||
|
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||||
|
'txt', 'rtf', 'csv', 'jpg', 'jpeg', 'png', 'gif'
|
||||||
|
]
|
||||||
|
if ext not in allowed_types:
|
||||||
|
raise ValidationError({
|
||||||
|
'file': f'File type "{ext}" is not allowed. Allowed types: {", ".join(allowed_types)}'
|
||||||
|
})
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Auto-populate file_type and file_size"""
|
||||||
|
if self.file:
|
||||||
|
# Extract file extension
|
||||||
|
ext = os.path.splitext(self.file.name)[1].lower().lstrip('.')
|
||||||
|
self.file_type = ext
|
||||||
|
|
||||||
|
# Get file size if not already set
|
||||||
|
if not self.file_size and hasattr(self.file, 'size'):
|
||||||
|
self.file_size = self.file.size
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def has_access(self, user):
|
||||||
|
"""
|
||||||
|
Check if user has access to this document.
|
||||||
|
|
||||||
|
Returns True if:
|
||||||
|
- Document has no access_roles (public to all)
|
||||||
|
- User belongs to any of the access_roles
|
||||||
|
"""
|
||||||
|
if not self.access_roles.exists():
|
||||||
|
return True
|
||||||
|
return user.groups.filter(id__in=self.access_roles.all()).exists()
|
||||||
|
|
||||||
|
def increment_download_count(self):
|
||||||
|
"""Increment download count and update last_accessed_at"""
|
||||||
|
self.download_count += 1
|
||||||
|
from django.utils import timezone
|
||||||
|
self.last_accessed_at = timezone.now()
|
||||||
|
self.save(update_fields=['download_count', 'last_accessed_at'])
|
||||||
|
|
||||||
|
def get_tags_list(self):
|
||||||
|
"""Get tags as a list"""
|
||||||
|
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
|
||||||
|
|
||||||
|
def get_file_icon(self):
|
||||||
|
"""Get icon class based on file type"""
|
||||||
|
icon_map = {
|
||||||
|
'pdf': 'fa-file-pdf text-danger',
|
||||||
|
'doc': 'fa-file-word text-primary',
|
||||||
|
'docx': 'fa-file-word text-primary',
|
||||||
|
'xls': 'fa-file-excel text-success',
|
||||||
|
'xlsx': 'fa-file-excel text-success',
|
||||||
|
'ppt': 'fa-file-powerpoint text-warning',
|
||||||
|
'pptx': 'fa-file-powerpoint text-warning',
|
||||||
|
'jpg': 'fa-file-image text-info',
|
||||||
|
'jpeg': 'fa-file-image text-info',
|
||||||
|
'png': 'fa-file-image text-info',
|
||||||
|
'gif': 'fa-file-image text-info',
|
||||||
|
'txt': 'fa-file-lines text-secondary',
|
||||||
|
}
|
||||||
|
return icon_map.get(self.file_type.lower(), 'fa-file text-secondary')
|
||||||
|
|
||||||
|
def get_file_size_display(self):
|
||||||
|
"""Get human-readable file size"""
|
||||||
|
size = self.file_size
|
||||||
|
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||||
|
if size < 1024:
|
||||||
|
return f"{size:.2f} {unit}"
|
||||||
|
size /= 1024
|
||||||
|
return f"{size:.2f} TB"
|
||||||
|
|
||||||
|
def get_version_history(self):
|
||||||
|
"""Get all versions of this document"""
|
||||||
|
versions = list(self.versions.all())
|
||||||
|
versions.append(self)
|
||||||
|
return sorted(versions, key=lambda x: x.version, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceDocumentAccess(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Reference Document Access model for tracking document access.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Audit trail for document downloads/views
|
||||||
|
- Track user actions on documents
|
||||||
|
- IP address logging
|
||||||
|
"""
|
||||||
|
ACTION_CHOICES = [
|
||||||
|
('view', 'Viewed'),
|
||||||
|
('download', 'Downloaded'),
|
||||||
|
('preview', 'Previewed'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Document reference
|
||||||
|
document = models.ForeignKey(
|
||||||
|
ReferenceDocument,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='access_logs'
|
||||||
|
)
|
||||||
|
|
||||||
|
# User who accessed the document
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
related_name='document_accesses'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Action type
|
||||||
|
action = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=ACTION_CHOICES,
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Context information
|
||||||
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
user_agent = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Document Access Log'
|
||||||
|
verbose_name_plural = 'Document Access Logs'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['document', '-created_at']),
|
||||||
|
models.Index(fields=['user', '-created_at']),
|
||||||
|
models.Index(fields=['action', '-created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
user_str = self.user.email if self.user else 'Anonymous'
|
||||||
|
return f"{user_str} - {self.get_action_display()} - {self.document}"
|
||||||
526
apps/references/ui_views.py
Normal file
526
apps/references/ui_views.py
Normal file
@ -0,0 +1,526 @@
|
|||||||
|
"""
|
||||||
|
References UI views - Server-rendered templates for Reference Section
|
||||||
|
"""
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404, render, redirect
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from apps.references.models import ReferenceFolder, ReferenceDocument
|
||||||
|
from apps.references.forms import ReferenceFolderForm, ReferenceDocumentForm, ReferenceDocumentSearchForm
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def reference_dashboard(request):
|
||||||
|
"""
|
||||||
|
Reference dashboard - Main entry point for Reference Section.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
- Root folders
|
||||||
|
- Recent documents
|
||||||
|
- Quick search
|
||||||
|
- Statistics
|
||||||
|
"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
return render(request, 'core/no_hospital_assigned.html')
|
||||||
|
|
||||||
|
# Get root folders (no parent)
|
||||||
|
folders = ReferenceFolder.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
parent=None,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
).order_by('order', 'name')
|
||||||
|
|
||||||
|
# Filter by access
|
||||||
|
folders = [f for f in folders if f.has_access(request.user)]
|
||||||
|
|
||||||
|
# Get recent documents (last 10)
|
||||||
|
recent_documents = ReferenceDocument.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
is_published=True,
|
||||||
|
is_deleted=False
|
||||||
|
).select_related('folder').order_by('-created_at')[:10]
|
||||||
|
|
||||||
|
# Filter by access
|
||||||
|
recent_documents = [d for d in recent_documents if d.has_access(request.user)]
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
total_folders = ReferenceFolder.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
).count()
|
||||||
|
|
||||||
|
total_documents = ReferenceDocument.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
is_published=True,
|
||||||
|
is_deleted=False
|
||||||
|
).count()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'folders': folders,
|
||||||
|
'recent_documents': recent_documents,
|
||||||
|
'total_folders': total_folders,
|
||||||
|
'total_documents': total_documents,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'references/dashboard.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def folder_view(request, pk=None):
|
||||||
|
"""
|
||||||
|
Folder view - Browse folders and documents.
|
||||||
|
|
||||||
|
If pk is provided, shows contents of that folder.
|
||||||
|
Otherwise, shows root level folders.
|
||||||
|
"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
return render(request, 'core/no_hospital_assigned.html')
|
||||||
|
|
||||||
|
current_folder = None
|
||||||
|
breadcrumb = []
|
||||||
|
subfolders = []
|
||||||
|
documents = []
|
||||||
|
|
||||||
|
if pk:
|
||||||
|
current_folder = get_object_or_404(
|
||||||
|
ReferenceFolder,
|
||||||
|
pk=pk,
|
||||||
|
hospital=hospital,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
if not current_folder.has_access(request.user):
|
||||||
|
messages.error(request, "You don't have permission to access this folder.")
|
||||||
|
return redirect('references:dashboard')
|
||||||
|
|
||||||
|
# Build breadcrumb
|
||||||
|
breadcrumb = []
|
||||||
|
folder = current_folder
|
||||||
|
while folder:
|
||||||
|
breadcrumb.insert(0, folder)
|
||||||
|
folder = folder.parent
|
||||||
|
|
||||||
|
# Get subfolders
|
||||||
|
subfolders = current_folder.get_subfolders()
|
||||||
|
subfolders = [f for f in subfolders if f.has_access(request.user)]
|
||||||
|
|
||||||
|
# Get documents
|
||||||
|
documents = current_folder.get_documents()
|
||||||
|
documents = [d for d in documents if d.has_access(request.user)]
|
||||||
|
else:
|
||||||
|
# Root level - show root folders
|
||||||
|
subfolders = ReferenceFolder.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
parent=None,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
).order_by('order', 'name')
|
||||||
|
|
||||||
|
subfolders = [f for f in subfolders if f.has_access(request.user)]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'current_folder': current_folder,
|
||||||
|
'breadcrumb': breadcrumb,
|
||||||
|
'subfolders': subfolders,
|
||||||
|
'documents': documents,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'references/folder_view.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def folder_create(request, parent_pk=None):
|
||||||
|
"""Create a new folder"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
messages.error(request, "No hospital assigned.")
|
||||||
|
return redirect('references:dashboard')
|
||||||
|
|
||||||
|
parent = None
|
||||||
|
if parent_pk:
|
||||||
|
parent = get_object_or_404(ReferenceFolder, pk=parent_pk, hospital=hospital, is_deleted=False)
|
||||||
|
if not parent.has_access(request.user):
|
||||||
|
messages.error(request, "You don't have permission to access this folder.")
|
||||||
|
return redirect('references:dashboard')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ReferenceFolderForm(request.POST, hospital=hospital)
|
||||||
|
if form.is_valid():
|
||||||
|
folder = form.save(commit=False)
|
||||||
|
folder.hospital = hospital
|
||||||
|
folder.parent = parent
|
||||||
|
folder.save()
|
||||||
|
form.save_m2m()
|
||||||
|
|
||||||
|
messages.success(request, f"Folder '{folder.name}' created successfully.")
|
||||||
|
if parent:
|
||||||
|
return redirect('references:folder_view', pk=parent_pk)
|
||||||
|
return redirect('references:dashboard')
|
||||||
|
else:
|
||||||
|
form = ReferenceFolderForm(hospital=hospital)
|
||||||
|
if parent:
|
||||||
|
form.fields['parent'].initial = parent
|
||||||
|
|
||||||
|
# Get all folders for parent dropdown with level info
|
||||||
|
all_folders = []
|
||||||
|
for f in ReferenceFolder.objects.filter(hospital=hospital, is_deleted=False):
|
||||||
|
level = 0
|
||||||
|
temp = f
|
||||||
|
while temp.parent:
|
||||||
|
level += 1
|
||||||
|
temp = temp.parent
|
||||||
|
# Create a display string with appropriate number of dashes
|
||||||
|
f.level_display = '—' * level
|
||||||
|
all_folders.append(f)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'form': form,
|
||||||
|
'folder': None,
|
||||||
|
'parent': parent,
|
||||||
|
'all_folders': all_folders,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'references/folder_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def folder_edit(request, pk):
|
||||||
|
"""Edit a folder"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
messages.error(request, "No hospital assigned.")
|
||||||
|
return redirect('references:dashboard')
|
||||||
|
|
||||||
|
folder = get_object_or_404(ReferenceFolder, pk=pk, hospital=hospital, is_deleted=False)
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
if not folder.has_access(request.user):
|
||||||
|
messages.error(request, "You don't have permission to edit this folder.")
|
||||||
|
return redirect('references:dashboard')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ReferenceFolderForm(request.POST, instance=folder, hospital=hospital)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, f"Folder '{folder.name}' updated successfully.")
|
||||||
|
if folder.parent:
|
||||||
|
return redirect('references:folder_view', pk=folder.parent.id)
|
||||||
|
return redirect('references:dashboard')
|
||||||
|
else:
|
||||||
|
form = ReferenceFolderForm(instance=folder, hospital=hospital)
|
||||||
|
|
||||||
|
# Get all folders for parent dropdown with level info
|
||||||
|
all_folders = []
|
||||||
|
for f in ReferenceFolder.objects.filter(hospital=hospital, is_deleted=False):
|
||||||
|
level = 0
|
||||||
|
temp = f
|
||||||
|
while temp.parent:
|
||||||
|
level += 1
|
||||||
|
temp = temp.parent
|
||||||
|
# Create a display string with appropriate number of dashes
|
||||||
|
f.level_display = '—' * level
|
||||||
|
all_folders.append(f)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'form': form,
|
||||||
|
'folder': folder,
|
||||||
|
'all_folders': all_folders,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'references/folder_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def folder_delete(request, pk):
|
||||||
|
"""Soft delete a folder"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
return JsonResponse({'success': False, 'error': 'No hospital assigned'}, status=400)
|
||||||
|
|
||||||
|
folder = get_object_or_404(ReferenceFolder, pk=pk, hospital=hospital, is_deleted=False)
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
if not folder.has_access(request.user):
|
||||||
|
return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403)
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
folder.is_deleted = True
|
||||||
|
folder.save()
|
||||||
|
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def document_view(request, pk):
|
||||||
|
"""Document detail view"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
return render(request, 'core/no_hospital_assigned.html')
|
||||||
|
|
||||||
|
document = get_object_or_404(
|
||||||
|
ReferenceDocument.objects.select_related('folder', 'uploaded_by'),
|
||||||
|
pk=pk,
|
||||||
|
hospital=hospital,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
if not document.has_access(request.user):
|
||||||
|
messages.error(request, "You don't have permission to access this document.")
|
||||||
|
return redirect('references:dashboard')
|
||||||
|
|
||||||
|
# Get version history
|
||||||
|
versions = document.get_version_history()
|
||||||
|
|
||||||
|
# Get related documents (same folder)
|
||||||
|
related_documents = []
|
||||||
|
if document.folder:
|
||||||
|
related_documents = ReferenceDocument.objects.filter(
|
||||||
|
folder=document.folder,
|
||||||
|
is_published=True,
|
||||||
|
is_deleted=False
|
||||||
|
).exclude(pk=document.pk)[:5]
|
||||||
|
related_documents = [d for d in related_documents if d.has_access(request.user)]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'document': document,
|
||||||
|
'versions': versions,
|
||||||
|
'related_documents': related_documents,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'references/document_view.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def document_create(request, folder_pk=None):
|
||||||
|
"""Upload a new document"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
messages.error(request, "No hospital assigned.")
|
||||||
|
return redirect('references:dashboard')
|
||||||
|
|
||||||
|
folder = None
|
||||||
|
if folder_pk:
|
||||||
|
folder = get_object_or_404(ReferenceFolder, pk=folder_pk, hospital=hospital, is_deleted=False)
|
||||||
|
if not folder.has_access(request.user):
|
||||||
|
messages.error(request, "You don't have permission to access this folder.")
|
||||||
|
return redirect('references:dashboard')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ReferenceDocumentForm(request.POST, request.FILES, hospital=hospital, folder=folder)
|
||||||
|
if form.is_valid():
|
||||||
|
document = form.save(commit=False)
|
||||||
|
document.hospital = hospital
|
||||||
|
# Use folder from form, fallback to URL parameter folder
|
||||||
|
document.folder = form.cleaned_data.get('folder') or folder
|
||||||
|
document.uploaded_by = request.user
|
||||||
|
if 'file' in request.FILES:
|
||||||
|
document.filename = request.FILES['file'].name
|
||||||
|
document.save()
|
||||||
|
form.save_m2m()
|
||||||
|
|
||||||
|
messages.success(request, f"Document '{document.title}' uploaded successfully.")
|
||||||
|
if document.folder:
|
||||||
|
return redirect('references:folder_view', pk=document.folder.id)
|
||||||
|
return redirect('references:dashboard')
|
||||||
|
else:
|
||||||
|
# Show error to user
|
||||||
|
messages.error(request, "Please fix the errors below to upload the document.")
|
||||||
|
else:
|
||||||
|
form = ReferenceDocumentForm(hospital=hospital, folder=folder)
|
||||||
|
|
||||||
|
# Get all folders for dropdown with level info
|
||||||
|
all_folders = []
|
||||||
|
for f in ReferenceFolder.objects.filter(hospital=hospital, is_deleted=False):
|
||||||
|
level = 0
|
||||||
|
temp = f
|
||||||
|
while temp.parent:
|
||||||
|
level += 1
|
||||||
|
temp = temp.parent
|
||||||
|
# Create a display string with appropriate number of dashes
|
||||||
|
f.level_display = '—' * level
|
||||||
|
all_folders.append(f)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'form': form,
|
||||||
|
'folder': folder,
|
||||||
|
'all_folders': all_folders,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'references/document_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def document_edit(request, pk):
|
||||||
|
"""Edit a document"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
messages.error(request, "No hospital assigned.")
|
||||||
|
return redirect('references:dashboard')
|
||||||
|
|
||||||
|
document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False)
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
if not document.has_access(request.user):
|
||||||
|
messages.error(request, "You don't have permission to edit this document.")
|
||||||
|
return redirect('references:dashboard')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ReferenceDocumentForm(request.POST, request.FILES, instance=document, hospital=hospital)
|
||||||
|
if form.is_valid():
|
||||||
|
# Check if uploading as new version
|
||||||
|
new_version = form.cleaned_data.get('new_version')
|
||||||
|
file = request.FILES.get('file')
|
||||||
|
|
||||||
|
if new_version and file:
|
||||||
|
# Create new version
|
||||||
|
old_document = document
|
||||||
|
document.pk = None # Create new instance
|
||||||
|
document.parent_document = old_document
|
||||||
|
document.uploaded_by = request.user
|
||||||
|
document.filename = file.name
|
||||||
|
document.save()
|
||||||
|
form.save_m2m()
|
||||||
|
|
||||||
|
# Mark old version as not latest
|
||||||
|
old_document.is_latest_version = False
|
||||||
|
old_document.save()
|
||||||
|
|
||||||
|
messages.success(request, f"New version '{document.title}' created successfully.")
|
||||||
|
else:
|
||||||
|
# Just update metadata
|
||||||
|
if file:
|
||||||
|
document.filename = file.name
|
||||||
|
form.save()
|
||||||
|
messages.success(request, f"Document '{document.title}' updated successfully.")
|
||||||
|
|
||||||
|
return redirect('references:document_view', pk=document.id)
|
||||||
|
else:
|
||||||
|
form = ReferenceDocumentForm(instance=document, hospital=hospital)
|
||||||
|
|
||||||
|
# Get all folders for dropdown with level info
|
||||||
|
all_folders = []
|
||||||
|
for f in ReferenceFolder.objects.filter(hospital=hospital, is_deleted=False):
|
||||||
|
level = 0
|
||||||
|
temp = f
|
||||||
|
while temp.parent:
|
||||||
|
level += 1
|
||||||
|
temp = temp.parent
|
||||||
|
# Create a display string with appropriate number of dashes
|
||||||
|
f.level_display = '—' * level
|
||||||
|
all_folders.append(f)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'form': form,
|
||||||
|
'document': document,
|
||||||
|
'all_folders': all_folders,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'references/document_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def document_delete(request, pk):
|
||||||
|
"""Soft delete a document"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
return JsonResponse({'success': False, 'error': 'No hospital assigned'}, status=400)
|
||||||
|
|
||||||
|
document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False)
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
if not document.has_access(request.user):
|
||||||
|
return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403)
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
document.is_deleted = True
|
||||||
|
document.save()
|
||||||
|
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def search(request):
|
||||||
|
"""
|
||||||
|
Search documents across all folders.
|
||||||
|
"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
return render(request, 'core/no_hospital_assigned.html')
|
||||||
|
|
||||||
|
form = ReferenceDocumentSearchForm(request.GET or None, hospital=hospital)
|
||||||
|
documents = []
|
||||||
|
|
||||||
|
if form.is_valid():
|
||||||
|
documents = ReferenceDocument.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
is_published=True,
|
||||||
|
is_deleted=False
|
||||||
|
).select_related('folder')
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
search = form.cleaned_data.get('search')
|
||||||
|
if search:
|
||||||
|
documents = documents.filter(
|
||||||
|
Q(title__icontains=search) |
|
||||||
|
Q(title_ar__icontains=search) |
|
||||||
|
Q(description__icontains=search) |
|
||||||
|
Q(description_ar__icontains=search) |
|
||||||
|
Q(tags__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
folder = form.cleaned_data.get('folder')
|
||||||
|
if folder:
|
||||||
|
documents = documents.filter(folder=folder)
|
||||||
|
|
||||||
|
file_type = form.cleaned_data.get('file_type')
|
||||||
|
if file_type:
|
||||||
|
documents = documents.filter(file_type__iexact=file_type)
|
||||||
|
|
||||||
|
tags = form.cleaned_data.get('tags')
|
||||||
|
if tags:
|
||||||
|
tag_list = [tag.strip() for tag in tags.split(',')]
|
||||||
|
for tag in tag_list:
|
||||||
|
documents = documents.filter(tags__icontains=tag)
|
||||||
|
|
||||||
|
# Filter by access
|
||||||
|
documents = [d for d in documents if d.has_access(request.user)]
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
page_size = int(request.GET.get('page_size', 20))
|
||||||
|
paginator = Paginator(documents, page_size)
|
||||||
|
page_number = request.GET.get('page', 1)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'form': form,
|
||||||
|
'page_obj': page_obj,
|
||||||
|
'documents': page_obj.object_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'references/search.html', context)
|
||||||
44
apps/references/urls.py
Normal file
44
apps/references/urls.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
References URL configuration
|
||||||
|
"""
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views, ui_views
|
||||||
|
|
||||||
|
app_name = 'references'
|
||||||
|
|
||||||
|
# API Endpoints
|
||||||
|
urlpatterns_api = [
|
||||||
|
path('api/folders/', views.folder_list, name='api_folder_list'),
|
||||||
|
path('api/folders/<uuid:pk>/', views.folder_detail, name='api_folder_detail'),
|
||||||
|
path('api/documents/', views.document_list, name='api_document_list'),
|
||||||
|
path('api/documents/<uuid:pk>/', views.document_detail, name='api_document_detail'),
|
||||||
|
path('api/documents/<uuid:pk>/download/', views.document_download, name='api_document_download'),
|
||||||
|
path('api/documents/<uuid:pk>/versions/', views.document_version_history, name='api_document_versions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# UI Views
|
||||||
|
urlpatterns_ui = [
|
||||||
|
# Dashboard
|
||||||
|
path('', ui_views.reference_dashboard, name='dashboard'),
|
||||||
|
|
||||||
|
# Folders
|
||||||
|
path('folders/', ui_views.folder_view, name='folder_view'),
|
||||||
|
path('folders/new/', ui_views.folder_create, name='folder_create'),
|
||||||
|
path('folders/new/<uuid:parent_pk>/', ui_views.folder_create, name='folder_create_in_parent'),
|
||||||
|
path('folders/<uuid:pk>/', ui_views.folder_view, name='folder_view'),
|
||||||
|
path('folders/<uuid:pk>/edit/', ui_views.folder_edit, name='folder_edit'),
|
||||||
|
path('folders/<uuid:pk>/delete/', ui_views.folder_delete, name='folder_delete'),
|
||||||
|
|
||||||
|
# Documents
|
||||||
|
path('documents/new/', ui_views.document_create, name='document_create'),
|
||||||
|
path('documents/new/<uuid:folder_pk>/', ui_views.document_create, name='document_create_in_folder'),
|
||||||
|
path('documents/<uuid:pk>/', ui_views.document_view, name='document_view'),
|
||||||
|
path('documents/<uuid:pk>/edit/', ui_views.document_edit, name='document_edit'),
|
||||||
|
path('documents/<uuid:pk>/delete/', ui_views.document_delete, name='document_delete'),
|
||||||
|
|
||||||
|
# Search
|
||||||
|
path('search/', ui_views.search, name='search'),
|
||||||
|
]
|
||||||
|
|
||||||
|
urlpatterns = urlpatterns_ui + urlpatterns_api
|
||||||
270
apps/references/views.py
Normal file
270
apps/references/views.py
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
"""
|
||||||
|
References views - View functions for Reference Section
|
||||||
|
"""
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.http import JsonResponse, Http404, FileResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.core.mixins import TenantRequiredMixin
|
||||||
|
from apps.references.models import ReferenceFolder, ReferenceDocument, ReferenceDocumentAccess
|
||||||
|
from apps.references.forms import ReferenceFolderForm, ReferenceDocumentForm
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def folder_list(request):
|
||||||
|
"""API endpoint to list folders for the current hospital"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
return JsonResponse({'error': 'No hospital assigned'}, status=400)
|
||||||
|
|
||||||
|
parent_id = request.GET.get('parent')
|
||||||
|
folders = ReferenceFolder.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if parent_id:
|
||||||
|
try:
|
||||||
|
parent = ReferenceFolder.objects.get(id=parent_id, hospital=hospital)
|
||||||
|
folders = folders.filter(parent=parent)
|
||||||
|
except ReferenceFolder.DoesNotExist:
|
||||||
|
folders = folders.filter(parent=None)
|
||||||
|
else:
|
||||||
|
folders = folders.filter(parent=None)
|
||||||
|
|
||||||
|
# Filter by access
|
||||||
|
folders = [f for f in folders if f.has_access(request.user)]
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'id': str(folder.id),
|
||||||
|
'name': folder.name,
|
||||||
|
'name_ar': folder.name_ar,
|
||||||
|
'description': folder.description,
|
||||||
|
'description_ar': folder.description_ar,
|
||||||
|
'icon': folder.icon,
|
||||||
|
'color': folder.color,
|
||||||
|
'document_count': folder.get_document_count(),
|
||||||
|
'order': folder.order
|
||||||
|
}
|
||||||
|
for folder in folders
|
||||||
|
]
|
||||||
|
|
||||||
|
return JsonResponse({'folders': data})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def folder_detail(request, pk):
|
||||||
|
"""API endpoint to get folder details"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
return JsonResponse({'error': 'No hospital assigned'}, status=400)
|
||||||
|
|
||||||
|
folder = get_object_or_404(ReferenceFolder, pk=pk, hospital=hospital, is_deleted=False)
|
||||||
|
|
||||||
|
if not folder.has_access(request.user):
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'id': str(folder.id),
|
||||||
|
'name': folder.name,
|
||||||
|
'name_ar': folder.name_ar,
|
||||||
|
'description': folder.description,
|
||||||
|
'description_ar': folder.description_ar,
|
||||||
|
'icon': folder.icon,
|
||||||
|
'color': folder.color,
|
||||||
|
'document_count': folder.get_document_count(),
|
||||||
|
'full_path': folder.get_full_path()
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse({'folder': data})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def document_list(request):
|
||||||
|
"""API endpoint to list documents with filtering"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
return JsonResponse({'error': 'No hospital assigned'}, status=400)
|
||||||
|
|
||||||
|
documents = ReferenceDocument.objects.filter(
|
||||||
|
hospital=hospital,
|
||||||
|
is_published=True,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by folder
|
||||||
|
folder_id = request.GET.get('folder')
|
||||||
|
if folder_id:
|
||||||
|
try:
|
||||||
|
folder = ReferenceFolder.objects.get(id=folder_id, hospital=hospital)
|
||||||
|
documents = documents.filter(folder=folder)
|
||||||
|
except ReferenceFolder.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Filter by file type
|
||||||
|
file_type = request.GET.get('file_type')
|
||||||
|
if file_type:
|
||||||
|
documents = documents.filter(file_type__iexact=file_type)
|
||||||
|
|
||||||
|
# Search
|
||||||
|
search = request.GET.get('search')
|
||||||
|
if search:
|
||||||
|
from django.db.models import Q
|
||||||
|
documents = documents.filter(
|
||||||
|
Q(title__icontains=search) |
|
||||||
|
Q(title_ar__icontains=search) |
|
||||||
|
Q(description__icontains=search) |
|
||||||
|
Q(description_ar__icontains=search) |
|
||||||
|
Q(tags__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by tags
|
||||||
|
tags = request.GET.get('tags')
|
||||||
|
if tags:
|
||||||
|
tag_list = [tag.strip() for tag in tags.split(',')]
|
||||||
|
for tag in tag_list:
|
||||||
|
documents = documents.filter(tags__icontains=tag)
|
||||||
|
|
||||||
|
# Filter by access
|
||||||
|
documents = [d for d in documents if d.has_access(request.user)]
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'id': str(doc.id),
|
||||||
|
'title': doc.title,
|
||||||
|
'title_ar': doc.title_ar,
|
||||||
|
'description': doc.description,
|
||||||
|
'description_ar': doc.description_ar,
|
||||||
|
'filename': doc.filename,
|
||||||
|
'file_type': doc.file_type,
|
||||||
|
'file_size': doc.file_size,
|
||||||
|
'file_size_display': doc.get_file_size_display(),
|
||||||
|
'version': doc.version,
|
||||||
|
'is_latest_version': doc.is_latest_version,
|
||||||
|
'download_count': doc.download_count,
|
||||||
|
'tags': doc.tags,
|
||||||
|
'folder_id': str(doc.folder.id) if doc.folder else None,
|
||||||
|
'folder_name': doc.folder.name if doc.folder else None,
|
||||||
|
'created_at': doc.created_at.isoformat(),
|
||||||
|
'last_accessed_at': doc.last_accessed_at.isoformat() if doc.last_accessed_at else None
|
||||||
|
}
|
||||||
|
for doc in documents
|
||||||
|
]
|
||||||
|
|
||||||
|
return JsonResponse({'documents': data})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def document_detail(request, pk):
|
||||||
|
"""API endpoint to get document details"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
return JsonResponse({'error': 'No hospital assigned'}, status=400)
|
||||||
|
|
||||||
|
document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False)
|
||||||
|
|
||||||
|
if not document.has_access(request.user):
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'id': str(document.id),
|
||||||
|
'title': document.title,
|
||||||
|
'title_ar': document.title_ar,
|
||||||
|
'description': document.description,
|
||||||
|
'description_ar': document.description_ar,
|
||||||
|
'filename': document.filename,
|
||||||
|
'file_type': document.file_type,
|
||||||
|
'file_size': document.file_size,
|
||||||
|
'file_size_display': document.get_file_size_display(),
|
||||||
|
'version': document.version,
|
||||||
|
'is_latest_version': document.is_latest_version,
|
||||||
|
'download_count': document.download_count,
|
||||||
|
'tags': document.tags,
|
||||||
|
'folder_id': str(document.folder.id) if document.folder else None,
|
||||||
|
'folder_name': document.folder.name if document.folder else None,
|
||||||
|
'created_at': document.created_at.isoformat(),
|
||||||
|
'last_accessed_at': document.last_accessed_at.isoformat() if document.last_accessed_at else None,
|
||||||
|
'uploaded_by': {
|
||||||
|
'email': document.uploaded_by.email,
|
||||||
|
'full_name': document.uploaded_by.get_full_name()
|
||||||
|
} if document.uploaded_by else None
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse({'document': data})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def document_download(request, pk):
|
||||||
|
"""
|
||||||
|
Handle document download with access logging
|
||||||
|
"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
raise Http404("No hospital assigned")
|
||||||
|
|
||||||
|
document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False)
|
||||||
|
|
||||||
|
if not document.has_access(request.user):
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
# Increment download count
|
||||||
|
document.increment_download_count()
|
||||||
|
|
||||||
|
# Log the access
|
||||||
|
ReferenceDocumentAccess.objects.create(
|
||||||
|
document=document,
|
||||||
|
user=request.user,
|
||||||
|
action='download',
|
||||||
|
ip_address=request.META.get('REMOTE_ADDR'),
|
||||||
|
user_agent=request.META.get('HTTP_USER_AGENT', '')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return file response
|
||||||
|
if document.file:
|
||||||
|
return FileResponse(
|
||||||
|
document.file,
|
||||||
|
as_attachment=True,
|
||||||
|
filename=document.filename
|
||||||
|
)
|
||||||
|
|
||||||
|
raise Http404("File not found")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def document_version_history(request, pk):
|
||||||
|
"""API endpoint to get version history of a document"""
|
||||||
|
# Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital
|
||||||
|
if not hospital:
|
||||||
|
return JsonResponse({'error': 'No hospital assigned'}, status=400)
|
||||||
|
|
||||||
|
document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False)
|
||||||
|
|
||||||
|
if not document.has_access(request.user):
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
versions = document.get_version_history()
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'id': str(ver.id),
|
||||||
|
'version': ver.version,
|
||||||
|
'is_latest_version': ver.is_latest_version,
|
||||||
|
'filename': ver.filename,
|
||||||
|
'file_size': ver.file_size,
|
||||||
|
'file_size_display': ver.get_file_size_display(),
|
||||||
|
'created_at': ver.created_at.isoformat()
|
||||||
|
}
|
||||||
|
for ver in versions
|
||||||
|
]
|
||||||
|
|
||||||
|
return JsonResponse({'versions': data})
|
||||||
1
apps/standards/__init__.py
Normal file
1
apps/standards/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
default_app_config = 'apps.standards.apps.StandardsConfig'
|
||||||
52
apps/standards/admin.py
Normal file
52
apps/standards/admin.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.standards.models import (
|
||||||
|
StandardSource,
|
||||||
|
StandardCategory,
|
||||||
|
Standard,
|
||||||
|
StandardCompliance,
|
||||||
|
StandardAttachment
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(StandardSource)
|
||||||
|
class StandardSourceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'code', 'is_active', 'created_at']
|
||||||
|
list_filter = ['is_active', 'created_at']
|
||||||
|
search_fields = ['name', 'name_ar', 'code', 'description']
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(StandardCategory)
|
||||||
|
class StandardCategoryAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'order', 'is_active', 'created_at']
|
||||||
|
list_filter = ['is_active', 'created_at']
|
||||||
|
search_fields = ['name', 'name_ar', 'description']
|
||||||
|
ordering = ['order', 'name']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Standard)
|
||||||
|
class StandardAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['code', 'title', 'source', 'category', 'department', 'is_active', 'effective_date']
|
||||||
|
list_filter = ['source', 'category', 'department', 'is_active', 'effective_date']
|
||||||
|
search_fields = ['code', 'title', 'title_ar', 'description']
|
||||||
|
ordering = ['source', 'category', 'code']
|
||||||
|
date_hierarchy = 'effective_date'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(StandardCompliance)
|
||||||
|
class StandardComplianceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['department', 'standard', 'status', 'last_assessed_date', 'assessor', 'created_at']
|
||||||
|
list_filter = ['status', 'last_assessed_date', 'created_at']
|
||||||
|
search_fields = ['department__name', 'standard__code', 'standard__title', 'notes', 'evidence_summary']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
raw_id_fields = ['department', 'standard', 'assessor']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(StandardAttachment)
|
||||||
|
class StandardAttachmentAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['filename', 'compliance', 'uploaded_by', 'created_at']
|
||||||
|
list_filter = ['created_at']
|
||||||
|
search_fields = ['filename', 'description']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
raw_id_fields = ['compliance', 'uploaded_by']
|
||||||
7
apps/standards/apps.py
Normal file
7
apps/standards/apps.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class StandardsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.standards'
|
||||||
|
verbose_name = 'Standards Section'
|
||||||
66
apps/standards/forms.py
Normal file
66
apps/standards/forms.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.core.validators import FileExtensionValidator
|
||||||
|
|
||||||
|
from apps.standards.models import (
|
||||||
|
StandardSource,
|
||||||
|
StandardCategory,
|
||||||
|
Standard,
|
||||||
|
StandardCompliance,
|
||||||
|
StandardAttachment
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StandardSourceForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = StandardSource
|
||||||
|
fields = ['name', 'name_ar', 'code', 'description', 'website', 'is_active']
|
||||||
|
widgets = {
|
||||||
|
'description': forms.Textarea(attrs={'rows': 3}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StandardCategoryForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = StandardCategory
|
||||||
|
fields = ['name', 'name_ar', 'description', 'order', 'is_active']
|
||||||
|
widgets = {
|
||||||
|
'description': forms.Textarea(attrs={'rows': 3}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StandardForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Standard
|
||||||
|
fields = ['code', 'title', 'title_ar', 'description',
|
||||||
|
'department', 'effective_date', 'review_date', 'is_active']
|
||||||
|
widgets = {
|
||||||
|
'description': forms.Textarea(attrs={'rows': 5}),
|
||||||
|
'effective_date': forms.DateInput(attrs={'type': 'date'}),
|
||||||
|
'review_date': forms.DateInput(attrs={'type': 'date'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StandardComplianceForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = StandardCompliance
|
||||||
|
fields = ['status', 'last_assessed_date', 'assessor', 'notes', 'evidence_summary']
|
||||||
|
widgets = {
|
||||||
|
'last_assessed_date': forms.DateInput(attrs={'type': 'date'}),
|
||||||
|
'notes': forms.Textarea(attrs={'rows': 3}),
|
||||||
|
'evidence_summary': forms.Textarea(attrs={'rows': 3}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StandardAttachmentForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = StandardAttachment
|
||||||
|
fields = ['file', 'description']
|
||||||
|
widgets = {
|
||||||
|
'description': forms.Textarea(attrs={'rows': 2}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['file'].widget.attrs.update({
|
||||||
|
'accept': '.pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.zip'
|
||||||
|
})
|
||||||
119
apps/standards/migrations/0001_initial.py
Normal file
119
apps/standards/migrations/0001_initial.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# Generated by Django 5.0.14 on 2026-01-07 20:27
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
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 = [
|
||||||
|
('organizations', '0005_alter_staff_department'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StandardCategory',
|
||||||
|
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)),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('name_ar', models.CharField(blank=True, max_length=100, verbose_name='Name (Arabic)')),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('order', models.PositiveIntegerField(default=0, help_text='Display order')),
|
||||||
|
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Standard Category',
|
||||||
|
'verbose_name_plural': 'Standard Categories',
|
||||||
|
'ordering': ['order', 'name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StandardSource',
|
||||||
|
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)),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('name_ar', models.CharField(blank=True, max_length=100, verbose_name='Name (Arabic)')),
|
||||||
|
('code', models.CharField(db_index=True, max_length=50, unique=True)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('website', models.URLField(blank=True)),
|
||||||
|
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Standard Source',
|
||||||
|
'verbose_name_plural': 'Standard Sources',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Standard',
|
||||||
|
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(db_index=True, help_text='e.g., CBAHI-PS-01', max_length=50)),
|
||||||
|
('title', models.CharField(max_length=300)),
|
||||||
|
('title_ar', models.CharField(blank=True, max_length=300, verbose_name='Title (Arabic)')),
|
||||||
|
('description', models.TextField(help_text='Full description of the standard')),
|
||||||
|
('effective_date', models.DateField(blank=True, help_text='When standard becomes effective', null=True)),
|
||||||
|
('review_date', models.DateField(blank=True, help_text='Next review date', null=True)),
|
||||||
|
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||||
|
('department', models.ForeignKey(blank=True, help_text='Department-specific standard (null if applicable to all)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='standards', to='organizations.department')),
|
||||||
|
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='standards', to='standards.standardcategory')),
|
||||||
|
('source', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='standards', to='standards.standardsource')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Standard',
|
||||||
|
'verbose_name_plural': 'Standards',
|
||||||
|
'ordering': ['source', 'category', 'code'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StandardCompliance',
|
||||||
|
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)),
|
||||||
|
('status', models.CharField(choices=[('not_assessed', 'Not Assessed'), ('met', 'Met'), ('partially_met', 'Partially Met'), ('not_met', 'Not Met')], db_index=True, default='not_assessed', max_length=20)),
|
||||||
|
('last_assessed_date', models.DateField(blank=True, help_text='Date of last assessment', null=True)),
|
||||||
|
('notes', models.TextField(blank=True, help_text='Assessment notes')),
|
||||||
|
('evidence_summary', models.TextField(blank=True, help_text='Summary of evidence')),
|
||||||
|
('assessor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assessments', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='compliance_records', to='organizations.department')),
|
||||||
|
('standard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='compliance_records', to='standards.standard')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Standard Compliance',
|
||||||
|
'verbose_name_plural': 'Standard Compliance',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'unique_together': {('department', 'standard')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StandardAttachment',
|
||||||
|
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)),
|
||||||
|
('file', models.FileField(upload_to='standards/attachments/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'jpeg', 'png', 'zip'])])),
|
||||||
|
('filename', models.CharField(help_text='Original filename', max_length=255)),
|
||||||
|
('description', models.TextField(blank=True, help_text='Attachment description')),
|
||||||
|
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_standards_attachments', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('compliance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='standards.standardcompliance')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Standard Attachment',
|
||||||
|
'verbose_name_plural': 'Standard Attachments',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/standards/migrations/__init__.py
Normal file
0
apps/standards/migrations/__init__.py
Normal file
165
apps/standards/models.py
Normal file
165
apps/standards/models.py
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
Standards Section Models - Track compliance standards (CBAHI, MOH, CHI, etc.)
|
||||||
|
"""
|
||||||
|
from django.db import models
|
||||||
|
from django.core.validators import FileExtensionValidator
|
||||||
|
|
||||||
|
from apps.core.models import TimeStampedModel, UUIDModel, StatusChoices
|
||||||
|
|
||||||
|
|
||||||
|
class StandardSource(UUIDModel, TimeStampedModel):
|
||||||
|
"""Standard sources like CBAHI, MOH, CHI, JCI, etc."""
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
name_ar = models.CharField(max_length=100, blank=True, verbose_name="Name (Arabic)")
|
||||||
|
code = models.CharField(max_length=50, unique=True, db_index=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
website = models.URLField(blank=True)
|
||||||
|
is_active = models.BooleanField(default=True, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
verbose_name = 'Standard Source'
|
||||||
|
verbose_name_plural = 'Standard Sources'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class StandardCategory(UUIDModel, TimeStampedModel):
|
||||||
|
"""Group standards by category (Patient Safety, Quality Management, etc.)"""
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
name_ar = models.CharField(max_length=100, blank=True, verbose_name="Name (Arabic)")
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
order = models.PositiveIntegerField(default=0, help_text="Display order")
|
||||||
|
is_active = models.BooleanField(default=True, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['order', 'name']
|
||||||
|
verbose_name = 'Standard Category'
|
||||||
|
verbose_name_plural = 'Standard Categories'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Standard(UUIDModel, TimeStampedModel):
|
||||||
|
"""Actual standard requirements"""
|
||||||
|
class ComplianceStatus(models.TextChoices):
|
||||||
|
NOT_ASSESSED = 'not_assessed', 'Not Assessed'
|
||||||
|
MET = 'met', 'Met'
|
||||||
|
PARTIALLY_MET = 'partially_met', 'Partially Met'
|
||||||
|
NOT_MET = 'not_met', 'Not Met'
|
||||||
|
|
||||||
|
code = models.CharField(max_length=50, db_index=True, help_text="e.g., CBAHI-PS-01")
|
||||||
|
title = models.CharField(max_length=300)
|
||||||
|
title_ar = models.CharField(max_length=300, blank=True, verbose_name="Title (Arabic)")
|
||||||
|
description = models.TextField(help_text="Full description of the standard")
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
source = models.ForeignKey(
|
||||||
|
StandardSource,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='standards'
|
||||||
|
)
|
||||||
|
category = models.ForeignKey(
|
||||||
|
StandardCategory,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='standards'
|
||||||
|
)
|
||||||
|
department = models.ForeignKey(
|
||||||
|
'organizations.Department',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='standards',
|
||||||
|
help_text="Department-specific standard (null if applicable to all)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dates
|
||||||
|
effective_date = models.DateField(null=True, blank=True, help_text="When standard becomes effective")
|
||||||
|
review_date = models.DateField(null=True, blank=True, help_text="Next review date")
|
||||||
|
|
||||||
|
is_active = models.BooleanField(default=True, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['source', 'category', 'code']
|
||||||
|
verbose_name = 'Standard'
|
||||||
|
verbose_name_plural = 'Standards'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.code}: {self.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class StandardCompliance(UUIDModel, TimeStampedModel):
|
||||||
|
"""Track compliance status per department"""
|
||||||
|
class ComplianceStatus(models.TextChoices):
|
||||||
|
NOT_ASSESSED = 'not_assessed', 'Not Assessed'
|
||||||
|
MET = 'met', 'Met'
|
||||||
|
PARTIALLY_MET = 'partially_met', 'Partially Met'
|
||||||
|
NOT_MET = 'not_met', 'Not Met'
|
||||||
|
|
||||||
|
department = models.ForeignKey(
|
||||||
|
'organizations.Department',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='compliance_records'
|
||||||
|
)
|
||||||
|
standard = models.ForeignKey(
|
||||||
|
Standard,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='compliance_records'
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=ComplianceStatus.choices,
|
||||||
|
default=ComplianceStatus.NOT_ASSESSED,
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
last_assessed_date = models.DateField(null=True, blank=True, help_text="Date of last assessment")
|
||||||
|
assessor = models.ForeignKey(
|
||||||
|
'accounts.User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='assessments'
|
||||||
|
)
|
||||||
|
notes = models.TextField(blank=True, help_text="Assessment notes")
|
||||||
|
evidence_summary = models.TextField(blank=True, help_text="Summary of evidence")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Standard Compliance'
|
||||||
|
verbose_name_plural = 'Standard Compliance'
|
||||||
|
unique_together = [['department', 'standard']]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.department.name} - {self.standard.code} - {self.status}"
|
||||||
|
|
||||||
|
|
||||||
|
class StandardAttachment(UUIDModel, TimeStampedModel):
|
||||||
|
"""Proof of compliance - evidence documents"""
|
||||||
|
compliance = models.ForeignKey(
|
||||||
|
StandardCompliance,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='attachments'
|
||||||
|
)
|
||||||
|
file = models.FileField(
|
||||||
|
upload_to='standards/attachments/%Y/%m/',
|
||||||
|
validators=[FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'jpeg', 'png', 'zip'])]
|
||||||
|
)
|
||||||
|
filename = models.CharField(max_length=255, help_text="Original filename")
|
||||||
|
description = models.TextField(blank=True, help_text="Attachment description")
|
||||||
|
uploaded_by = models.ForeignKey(
|
||||||
|
'accounts.User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='uploaded_standards_attachments'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Standard Attachment'
|
||||||
|
verbose_name_plural = 'Standard Attachments'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.compliance} - {self.filename}"
|
||||||
61
apps/standards/serializers.py
Normal file
61
apps/standards/serializers.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.standards.models import (
|
||||||
|
StandardSource,
|
||||||
|
StandardCategory,
|
||||||
|
Standard,
|
||||||
|
StandardCompliance,
|
||||||
|
StandardAttachment
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StandardSourceSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = StandardSource
|
||||||
|
fields = '__all__'
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class StandardCategorySerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = StandardCategory
|
||||||
|
fields = '__all__'
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class StandardSerializer(serializers.ModelSerializer):
|
||||||
|
source_name = serializers.CharField(source='source.name', read_only=True)
|
||||||
|
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||||
|
department_name = serializers.CharField(source='department.name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Standard
|
||||||
|
fields = '__all__'
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class StandardComplianceSerializer(serializers.ModelSerializer):
|
||||||
|
department_name = serializers.CharField(source='department.name', read_only=True)
|
||||||
|
standard_code = serializers.CharField(source='standard.code', read_only=True)
|
||||||
|
standard_title = serializers.CharField(source='standard.title', read_only=True)
|
||||||
|
assessor_name = serializers.CharField(source='assessor.get_full_name', read_only=True)
|
||||||
|
attachment_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = StandardCompliance
|
||||||
|
fields = '__all__'
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_attachment_count(self, obj):
|
||||||
|
return obj.attachments.count()
|
||||||
|
|
||||||
|
|
||||||
|
class StandardAttachmentSerializer(serializers.ModelSerializer):
|
||||||
|
uploaded_by_name = serializers.CharField(source='uploaded_by.get_full_name', read_only=True)
|
||||||
|
standard_code = serializers.CharField(source='compliance.standard.code', read_only=True)
|
||||||
|
department_name = serializers.CharField(source='compliance.department.name', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = StandardAttachment
|
||||||
|
fields = '__all__'
|
||||||
|
read_only_fields = ['id', 'uploaded_at', 'created_at', 'updated_at']
|
||||||
0
apps/standards/templatetags/__init__.py
Normal file
0
apps/standards/templatetags/__init__.py
Normal file
50
apps/standards/templatetags/standards_filters.py
Normal file
50
apps/standards/templatetags/standards_filters.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
Template filters for Standards app
|
||||||
|
"""
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def get_unique(data_list, field_path):
|
||||||
|
"""
|
||||||
|
Get unique values from a list of dictionaries based on a dot-notation path.
|
||||||
|
|
||||||
|
Usage: {{ standards_data|get_unique:"standard.source" }}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_list: List of dictionaries
|
||||||
|
field_path: Dot-separated path to the field (e.g., "standard.source")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of unique values for the specified field path
|
||||||
|
"""
|
||||||
|
if not data_list:
|
||||||
|
return []
|
||||||
|
|
||||||
|
values = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for item in data_list:
|
||||||
|
# Navigate through the dot-notation path
|
||||||
|
value = item
|
||||||
|
try:
|
||||||
|
for attr in field_path.split('.'):
|
||||||
|
if value is None:
|
||||||
|
break
|
||||||
|
# Handle both dict and object access
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value = value.get(attr)
|
||||||
|
else:
|
||||||
|
value = getattr(value, attr, None)
|
||||||
|
|
||||||
|
# Only add non-None values that haven't been seen
|
||||||
|
if value is not None and value not in seen:
|
||||||
|
values.append(value)
|
||||||
|
seen.add(value)
|
||||||
|
except (AttributeError, KeyError, TypeError):
|
||||||
|
# Skip items that don't have the path
|
||||||
|
continue
|
||||||
|
|
||||||
|
return values
|
||||||
52
apps/standards/urls.py
Normal file
52
apps/standards/urls.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.standards.views import (
|
||||||
|
StandardSourceViewSet,
|
||||||
|
StandardCategoryViewSet,
|
||||||
|
StandardViewSet,
|
||||||
|
StandardComplianceViewSet,
|
||||||
|
StandardAttachmentViewSet,
|
||||||
|
standards_dashboard,
|
||||||
|
department_standards_view,
|
||||||
|
standard_detail,
|
||||||
|
standard_compliance_update,
|
||||||
|
standard_attachment_upload,
|
||||||
|
standards_search,
|
||||||
|
get_compliance_status,
|
||||||
|
standard_create,
|
||||||
|
create_compliance_ajax,
|
||||||
|
update_compliance_ajax,
|
||||||
|
)
|
||||||
|
|
||||||
|
# API Router
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'sources', StandardSourceViewSet, basename='standard-source')
|
||||||
|
router.register(r'categories', StandardCategoryViewSet, basename='standard-category')
|
||||||
|
router.register(r'standards', StandardViewSet, basename='standard')
|
||||||
|
router.register(r'compliance', StandardComplianceViewSet, basename='standard-compliance')
|
||||||
|
router.register(r'attachments', StandardAttachmentViewSet, basename='standard-attachment')
|
||||||
|
|
||||||
|
app_name = 'standards'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# API endpoints
|
||||||
|
path('api/', include(router.urls)),
|
||||||
|
|
||||||
|
# API endpoint for compliance status
|
||||||
|
path('api/compliance/<uuid:department_id>/<uuid:standard_id>/', get_compliance_status, name='compliance_status'),
|
||||||
|
|
||||||
|
# UI Views
|
||||||
|
path('', standards_dashboard, name='dashboard'),
|
||||||
|
path('search/', standards_search, name='search'),
|
||||||
|
path('departments/<uuid:pk>/', department_standards_view, name='department_standards'),
|
||||||
|
path('departments/<uuid:department_id>/create-standard/', standard_create, name='standard_create'),
|
||||||
|
path('standards/create/', standard_create, name='standard_create_global'),
|
||||||
|
path('standards/<uuid:pk>/', standard_detail, name='standard_detail'),
|
||||||
|
path('compliance/<uuid:compliance_id>/update/', standard_compliance_update, name='standard_compliance_update'),
|
||||||
|
path('attachments/upload/<uuid:compliance_id>/', standard_attachment_upload, name='attachment_upload'),
|
||||||
|
|
||||||
|
# AJAX endpoints
|
||||||
|
path('api/compliance/create/', create_compliance_ajax, name='compliance_create_ajax'),
|
||||||
|
path('api/compliance/update/', update_compliance_ajax, name='compliance_update_ajax'),
|
||||||
|
]
|
||||||
423
apps/standards/views.py
Normal file
423
apps/standards/views.py
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.standards.models import (
|
||||||
|
StandardSource,
|
||||||
|
StandardCategory,
|
||||||
|
Standard,
|
||||||
|
StandardCompliance,
|
||||||
|
StandardAttachment
|
||||||
|
)
|
||||||
|
from apps.organizations.models import Department
|
||||||
|
from apps.standards.forms import (
|
||||||
|
StandardSourceForm,
|
||||||
|
StandardCategoryForm,
|
||||||
|
StandardForm,
|
||||||
|
StandardComplianceForm,
|
||||||
|
StandardAttachmentForm
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== API ViewSets ====================
|
||||||
|
|
||||||
|
class StandardSourceViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = StandardSource.objects.all()
|
||||||
|
filterset_fields = ['is_active']
|
||||||
|
search_fields = ['name', 'name_ar', 'code']
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
from apps.standards.serializers import StandardSourceSerializer
|
||||||
|
return StandardSourceSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class StandardCategoryViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = StandardCategory.objects.all()
|
||||||
|
filterset_fields = ['is_active']
|
||||||
|
search_fields = ['name', 'name_ar']
|
||||||
|
ordering = ['order', 'name']
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
from apps.standards.serializers import StandardCategorySerializer
|
||||||
|
return StandardCategorySerializer
|
||||||
|
|
||||||
|
|
||||||
|
class StandardViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Standard.objects.all()
|
||||||
|
filterset_fields = ['source', 'category', 'department', 'is_active']
|
||||||
|
search_fields = ['code', 'title', 'title_ar', 'description']
|
||||||
|
ordering = ['source', 'category', 'code']
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
from apps.standards.serializers import StandardSerializer
|
||||||
|
return StandardSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class StandardComplianceViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = StandardCompliance.objects.all()
|
||||||
|
filterset_fields = ['department', 'standard', 'status']
|
||||||
|
search_fields = ['department__name', 'standard__code', 'notes']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
from apps.standards.serializers import StandardComplianceSerializer
|
||||||
|
return StandardComplianceSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class StandardAttachmentViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = StandardAttachment.objects.all()
|
||||||
|
filterset_fields = ['compliance']
|
||||||
|
search_fields = ['filename', 'description']
|
||||||
|
ordering = ['-uploaded_at']
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
from apps.standards.serializers import StandardAttachmentSerializer
|
||||||
|
return StandardAttachmentSerializer
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== UI Views ====================
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def standards_dashboard(request):
|
||||||
|
"""Standards dashboard with statistics"""
|
||||||
|
# Get current hospital from tenant_hospital (set by middleware)
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None)
|
||||||
|
if not hospital:
|
||||||
|
return render(request, 'core/no_hospital_assigned.html')
|
||||||
|
|
||||||
|
departments = hospital.departments.filter(status='active')
|
||||||
|
|
||||||
|
# Get compliance statistics
|
||||||
|
compliance_records = StandardCompliance.objects.filter(
|
||||||
|
department__hospital=hospital
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'total_standards': Standard.objects.filter(is_active=True).count(),
|
||||||
|
'total_departments': departments.count(),
|
||||||
|
'met': compliance_records.filter(status='met').count(),
|
||||||
|
'partially_met': compliance_records.filter(status='partially_met').count(),
|
||||||
|
'not_met': compliance_records.filter(status='not_met').count(),
|
||||||
|
'not_assessed': compliance_records.filter(status='not_assessed').count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Recent compliance updates
|
||||||
|
recent_updates = compliance_records.order_by('-updated_at')[:10]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'hospital': hospital,
|
||||||
|
'departments': departments,
|
||||||
|
'stats': stats,
|
||||||
|
'recent_updates': recent_updates,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'standards/dashboard.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def department_standards_view(request, pk):
|
||||||
|
"""View all standards for a department"""
|
||||||
|
department = get_object_or_404(Department, pk=pk)
|
||||||
|
|
||||||
|
# Check if user is PX admin
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None)
|
||||||
|
is_px_admin = request.user.is_superuser or (
|
||||||
|
hasattr(request.user, 'hospital_user') and
|
||||||
|
request.user.hospital_user.hospital == hospital and
|
||||||
|
request.user.hospital_user.role == 'px_admin'
|
||||||
|
) if hospital else False
|
||||||
|
|
||||||
|
# Get all active standards (both department-specific and general)
|
||||||
|
department_standards = Standard.objects.filter(is_active=True).filter(
|
||||||
|
Q(department=department) | Q(department__isnull=True)
|
||||||
|
).order_by('source', 'category', 'code')
|
||||||
|
|
||||||
|
# Get compliance status for each standard
|
||||||
|
standards_data = []
|
||||||
|
for standard in department_standards:
|
||||||
|
compliance = StandardCompliance.objects.filter(
|
||||||
|
department=department,
|
||||||
|
standard=standard
|
||||||
|
).first()
|
||||||
|
|
||||||
|
standards_data.append({
|
||||||
|
'standard': standard,
|
||||||
|
'compliance': compliance,
|
||||||
|
'attachment_count': compliance.attachments.count() if compliance else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'department': department,
|
||||||
|
'standards_data': standards_data,
|
||||||
|
'is_px_admin': is_px_admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'standards/department_standards.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def standard_detail(request, pk):
|
||||||
|
"""View standard details and compliance history"""
|
||||||
|
standard = get_object_or_404(Standard, pk=pk)
|
||||||
|
|
||||||
|
# Get compliance records for all departments
|
||||||
|
compliance_records = StandardCompliance.objects.filter(
|
||||||
|
standard=standard
|
||||||
|
).select_related('department', 'assessor').order_by('-created_at')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'standard': standard,
|
||||||
|
'compliance_records': compliance_records,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'standards/standard_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def standard_compliance_update(request, compliance_id):
|
||||||
|
"""Update compliance status"""
|
||||||
|
compliance = get_object_or_404(StandardCompliance, pk=compliance_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = StandardComplianceForm(request.POST, instance=compliance)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect('standards:department_standards', pk=compliance.department.pk)
|
||||||
|
else:
|
||||||
|
form = StandardComplianceForm(instance=compliance)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'compliance': compliance,
|
||||||
|
'form': form,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'standards/compliance_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def standard_attachment_upload(request, compliance_id):
|
||||||
|
"""Upload attachment for compliance"""
|
||||||
|
compliance = get_object_or_404(StandardCompliance, pk=compliance_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = StandardAttachmentForm(request.POST, request.FILES)
|
||||||
|
if form.is_valid():
|
||||||
|
attachment = form.save(commit=False)
|
||||||
|
attachment.compliance = compliance
|
||||||
|
attachment.uploaded_by = request.user
|
||||||
|
attachment.filename = request.FILES['file'].name
|
||||||
|
attachment.save()
|
||||||
|
return redirect('standards:standard_compliance_update', compliance_id=compliance.pk)
|
||||||
|
else:
|
||||||
|
form = StandardAttachmentForm()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'compliance': compliance,
|
||||||
|
'form': form,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'standards/attachment_upload.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def standards_search(request):
|
||||||
|
"""Search standards"""
|
||||||
|
query = request.GET.get('q', '')
|
||||||
|
source_filter = request.GET.get('source', '')
|
||||||
|
category_filter = request.GET.get('category', '')
|
||||||
|
status_filter = request.GET.get('status', '')
|
||||||
|
|
||||||
|
# Get current hospital from tenant_hospital (set by middleware)
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None)
|
||||||
|
if not hospital:
|
||||||
|
return render(request, 'core/no_hospital_assigned.html')
|
||||||
|
|
||||||
|
# Build queryset
|
||||||
|
standards = Standard.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
if query:
|
||||||
|
standards = standards.filter(
|
||||||
|
Q(code__icontains=query) |
|
||||||
|
Q(title__icontains=query) |
|
||||||
|
Q(title_ar__icontains=query) |
|
||||||
|
Q(description__icontains=query)
|
||||||
|
)
|
||||||
|
|
||||||
|
if source_filter:
|
||||||
|
standards = standards.filter(source_id=source_filter)
|
||||||
|
|
||||||
|
if category_filter:
|
||||||
|
standards = standards.filter(category_id=category_filter)
|
||||||
|
|
||||||
|
standards = standards.select_related('source', 'category').order_by('source', 'category', 'code')
|
||||||
|
|
||||||
|
# Get filters
|
||||||
|
sources = StandardSource.objects.filter(is_active=True)
|
||||||
|
categories = StandardCategory.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'hospital': hospital,
|
||||||
|
'standards': standards,
|
||||||
|
'query': query,
|
||||||
|
'source_filter': source_filter,
|
||||||
|
'category_filter': category_filter,
|
||||||
|
'sources': sources,
|
||||||
|
'categories': categories,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'standards/search.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def standard_create(request, department_id=None):
|
||||||
|
"""Create a new standard (PX Admin only)"""
|
||||||
|
# Check if user is PX admin
|
||||||
|
hospital = getattr(request, 'tenant_hospital', None)
|
||||||
|
if not hospital:
|
||||||
|
return render(request, 'core/no_hospital_assigned.html')
|
||||||
|
|
||||||
|
is_px_admin = request.user.is_superuser or (
|
||||||
|
hasattr(request.user, 'hospital_user') and
|
||||||
|
request.user.hospital_user.hospital == hospital and
|
||||||
|
request.user.hospital_user.role == 'px_admin'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_px_admin:
|
||||||
|
from django.contrib import messages
|
||||||
|
messages.error(request, 'You do not have permission to create standards.')
|
||||||
|
if department_id:
|
||||||
|
return redirect('standards:department_standards', pk=department_id)
|
||||||
|
return redirect('standards:dashboard')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = StandardForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
standard = form.save(commit=False)
|
||||||
|
standard.save()
|
||||||
|
from django.contrib import messages
|
||||||
|
messages.success(request, 'Standard created successfully.')
|
||||||
|
if department_id:
|
||||||
|
return redirect('standards:department_standards', pk=department_id)
|
||||||
|
return redirect('standards:dashboard')
|
||||||
|
else:
|
||||||
|
form = StandardForm()
|
||||||
|
# If department_id is provided, pre-select that department
|
||||||
|
if department_id:
|
||||||
|
from apps.organizations.models import Department
|
||||||
|
department = Department.objects.filter(pk=department_id).first()
|
||||||
|
if department:
|
||||||
|
form.fields['department'].initial = department
|
||||||
|
|
||||||
|
# Get all departments for the hospital
|
||||||
|
departments = hospital.departments.filter(status='active')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'form': form,
|
||||||
|
'department_id': department_id,
|
||||||
|
'departments': departments,
|
||||||
|
'hospital': hospital,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'standards/standard_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def create_compliance_ajax(request):
|
||||||
|
"""Create compliance record via AJAX"""
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'success': False, 'error': 'Invalid request method'})
|
||||||
|
|
||||||
|
department_id = request.POST.get('department_id')
|
||||||
|
standard_id = request.POST.get('standard_id')
|
||||||
|
|
||||||
|
if not department_id or not standard_id:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Missing required fields'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
department = Department.objects.get(pk=department_id)
|
||||||
|
standard = Standard.objects.get(pk=standard_id)
|
||||||
|
|
||||||
|
# Check if compliance already exists
|
||||||
|
compliance, created = StandardCompliance.objects.get_or_create(
|
||||||
|
department=department,
|
||||||
|
standard=standard,
|
||||||
|
defaults={
|
||||||
|
'assessor': request.user,
|
||||||
|
'last_assessed_date': timezone.now().date(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'compliance_id': compliance.id,
|
||||||
|
'status': compliance.status,
|
||||||
|
'created': created,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def update_compliance_ajax(request):
|
||||||
|
"""Update compliance record via AJAX"""
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'success': False, 'error': 'Invalid request method'})
|
||||||
|
|
||||||
|
compliance_id = request.POST.get('compliance_id')
|
||||||
|
status = request.POST.get('status')
|
||||||
|
notes = request.POST.get('notes', '')
|
||||||
|
evidence_summary = request.POST.get('evidence_summary', '')
|
||||||
|
|
||||||
|
if not compliance_id or not status:
|
||||||
|
return JsonResponse({'success': False, 'error': 'Missing required fields'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
compliance = StandardCompliance.objects.get(pk=compliance_id)
|
||||||
|
compliance.status = status
|
||||||
|
compliance.notes = notes
|
||||||
|
compliance.evidence_summary = evidence_summary
|
||||||
|
compliance.assessor = request.user
|
||||||
|
compliance.last_assessed_date = timezone.now().date()
|
||||||
|
compliance.save()
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'status': compliance.status,
|
||||||
|
'status_display': compliance.get_status_display(),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def get_compliance_status(request, department_id, standard_id):
|
||||||
|
"""API endpoint to get compliance status"""
|
||||||
|
compliance = StandardCompliance.objects.filter(
|
||||||
|
department_id=department_id,
|
||||||
|
standard_id=standard_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if compliance:
|
||||||
|
data = {
|
||||||
|
'status': compliance.status,
|
||||||
|
'last_assessed_date': compliance.last_assessed_date,
|
||||||
|
'assessor': compliance.assessor.get_full_name() if compliance.assessor else None,
|
||||||
|
'notes': compliance.notes,
|
||||||
|
'attachment_count': compliance.attachments.count(),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
data = {
|
||||||
|
'status': 'not_assessed',
|
||||||
|
'last_assessed_date': None,
|
||||||
|
'assessor': None,
|
||||||
|
'notes': '',
|
||||||
|
'attachment_count': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse(data)
|
||||||
@ -65,6 +65,8 @@ LOCAL_APPS = [
|
|||||||
'apps.dashboard',
|
'apps.dashboard',
|
||||||
'apps.appreciation',
|
'apps.appreciation',
|
||||||
'apps.observations',
|
'apps.observations',
|
||||||
|
'apps.references',
|
||||||
|
'apps.standards',
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
|
|||||||
@ -40,6 +40,8 @@ urlpatterns = [
|
|||||||
path('ai-engine/', include('apps.ai_engine.urls')),
|
path('ai-engine/', include('apps.ai_engine.urls')),
|
||||||
path('appreciation/', include('apps.appreciation.urls', namespace='appreciation')),
|
path('appreciation/', include('apps.appreciation.urls', namespace='appreciation')),
|
||||||
path('observations/', include('apps.observations.urls', namespace='observations')),
|
path('observations/', include('apps.observations.urls', namespace='observations')),
|
||||||
|
path('references/', include('apps.references.urls', namespace='references')),
|
||||||
|
path('standards/', include('apps.standards.urls', namespace='standards')),
|
||||||
|
|
||||||
# API endpoints
|
# API endpoints
|
||||||
path('api/auth/', include('apps.accounts.urls')),
|
path('api/auth/', include('apps.accounts.urls')),
|
||||||
|
|||||||
168
docs/OBSERVATION_MODEL_FIXES.md
Normal file
168
docs/OBSERVATION_MODEL_FIXES.md
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
# Observation Model Fixes
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Fixed critical issues in the Observation model to align with the system's tenant isolation architecture and improve parity with the Complaint model.
|
||||||
|
|
||||||
|
## Issues Fixed
|
||||||
|
|
||||||
|
### 1. Missing Hospital ForeignKey (Critical)
|
||||||
|
**Problem:** The Observation model lacked a `hospital` field, breaking tenant isolation.
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- Cannot filter observations by hospital
|
||||||
|
- Cannot track which hospital owns the observation
|
||||||
|
- Prevents proper routing in multi-hospital environment
|
||||||
|
- Breaks the tenant isolation pattern used throughout the system
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Added `hospital` ForeignKey to `organizations.Hospital`
|
||||||
|
- Field is required for new observations
|
||||||
|
- Added database index for efficient filtering by hospital and status
|
||||||
|
|
||||||
|
### 2. Missing Staff ForeignKey
|
||||||
|
**Problem:** Observations couldn't track which staff member was mentioned in the report.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Added optional `staff` ForeignKey to `organizations.Staff`
|
||||||
|
- Enables AI-matching similar to Complaint model
|
||||||
|
- Useful for tracking observations about specific staff members
|
||||||
|
|
||||||
|
### 3. Missing Source Tracking
|
||||||
|
**Problem:** No way to track how observations were submitted.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Added `source` field with choices:
|
||||||
|
- `staff_portal` - Staff Portal (default)
|
||||||
|
- `web_form` - Web Form
|
||||||
|
- `mobile_app` - Mobile App
|
||||||
|
- `email` - Email
|
||||||
|
- `call_center` - Call Center
|
||||||
|
- `other` - Other
|
||||||
|
|
||||||
|
## Model Changes
|
||||||
|
|
||||||
|
### Added Fields to Observation Model
|
||||||
|
```python
|
||||||
|
hospital = models.ForeignKey(
|
||||||
|
'organizations.Hospital',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='observations',
|
||||||
|
help_text="Hospital where observation was made"
|
||||||
|
)
|
||||||
|
|
||||||
|
staff = models.ForeignKey(
|
||||||
|
'organizations.Staff',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='observations',
|
||||||
|
help_text="Staff member mentioned in observation"
|
||||||
|
)
|
||||||
|
|
||||||
|
source = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=[
|
||||||
|
('staff_portal', 'Staff Portal'),
|
||||||
|
('web_form', 'Web Form'),
|
||||||
|
('mobile_app', 'Mobile App'),
|
||||||
|
('email', 'Email'),
|
||||||
|
('call_center', 'Call Center'),
|
||||||
|
('other', 'Other'),
|
||||||
|
],
|
||||||
|
default='staff_portal',
|
||||||
|
help_text="How the observation was submitted"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Added Database Indexes
|
||||||
|
```python
|
||||||
|
models.Index(fields=['hospital', 'status', '-created_at'])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
### Migration 0002_add_missing_fields.py
|
||||||
|
- Adds `hospital`, `staff`, and `source` fields
|
||||||
|
- Initially makes `hospital` nullable to support existing data
|
||||||
|
- Since there are 0 existing observations, no data migration needed
|
||||||
|
|
||||||
|
### Future Migration
|
||||||
|
After deployment, a follow-up migration can make `hospital` required if needed:
|
||||||
|
```python
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='observation',
|
||||||
|
name='hospital',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name='observations',
|
||||||
|
to='organizations.hospital'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why Not Unify with Complaints?
|
||||||
|
|
||||||
|
Despite similarities, the models should remain separate:
|
||||||
|
|
||||||
|
### Key Differences
|
||||||
|
| Aspect | Complaint | Observation |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| **Purpose** | Patient-reported issues | Staff-reported issues |
|
||||||
|
| **Reporter** | Patient FK (required) | Anonymous or staff ID |
|
||||||
|
| **Workflow** | Open → In Progress → Resolved → Closed | New → Triaged → Assigned → In Progress → Resolved → Closed (plus REJECTED, DUPLICATE) |
|
||||||
|
| **SLA** | Response time SLAs with tracking | Triage timeline, not response time |
|
||||||
|
| **Tracking** | Reference number (optional) | Tracking code (required, OBS-XXXXXX) |
|
||||||
|
| **Location** | No location field | Location text field |
|
||||||
|
| **Incident Time** | No separate field | Incident datetime field |
|
||||||
|
| **Triage** | No triage fields | Triage tracking (triaged_by, triaged_at) |
|
||||||
|
| **Resolution Survey** | Links to SurveyInstance | No resolution survey |
|
||||||
|
| **Encounter ID** | Links to patient encounters | No encounter tracking |
|
||||||
|
|
||||||
|
### Different Business Logic
|
||||||
|
- **Complaints**: Customer service workflow, satisfaction surveys, SLA escalation
|
||||||
|
- **Observations**: Safety/quality workflow, triage process, anonymous reporting
|
||||||
|
|
||||||
|
### Different Stakeholders
|
||||||
|
- **Complaints**: Patient experience teams, call center
|
||||||
|
- **Observations**: Quality teams, safety officers, clinical governance
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Run Migration:**
|
||||||
|
```bash
|
||||||
|
python manage.py migrate observations
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update Forms:** Ensure observation forms include hospital selection
|
||||||
|
- Public observation form
|
||||||
|
- Staff portal observation form
|
||||||
|
- Admin observation form
|
||||||
|
|
||||||
|
3. **Update Views:** Add hospital filtering to observation list views
|
||||||
|
|
||||||
|
4. **Update APIs:** Include hospital field in observation APIs
|
||||||
|
|
||||||
|
5. **Update Templates:** Display hospital information in observation detail views
|
||||||
|
|
||||||
|
6. **Consider AI Integration:** Add staff name extraction similar to complaints
|
||||||
|
- Use `apps.core.ai_service.extract_person_name()`
|
||||||
|
- Match extracted names to Staff records
|
||||||
|
- Populate `staff` field automatically
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. **apps/observations/models.py**
|
||||||
|
- Added `hospital` ForeignKey
|
||||||
|
- Added `staff` ForeignKey
|
||||||
|
- Added `source` field
|
||||||
|
- Added hospital status index
|
||||||
|
|
||||||
|
2. **apps/observations/migrations/0002_add_missing_fields.py**
|
||||||
|
- Migration to add new fields
|
||||||
|
|
||||||
|
## Backwards Compatibility
|
||||||
|
|
||||||
|
- Migration makes `hospital` initially nullable
|
||||||
|
- Model code enforces `hospital` as required for new records
|
||||||
|
- Existing code without hospital selection will work but with validation errors for new observations
|
||||||
|
- Consider updating form validation to ensure hospital is always provided
|
||||||
416
docs/REFERENCES_IMPLEMENTATION.md
Normal file
416
docs/REFERENCES_IMPLEMENTATION.md
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
# References Section - Reference Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The References Section is a file server system for managing reference documents with folder categorization. It provides a simple and straightforward way to store files and group them by folders, accessible by employees at any time.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. **Folder Management**
|
||||||
|
- **Hierarchical Structure**: Create nested folders (folders within folders)
|
||||||
|
- **Bilingual Support**: Names and descriptions in both English and Arabic
|
||||||
|
- **Customization**: Icons and colors for visual organization
|
||||||
|
- **Access Control**: Role-based access control per folder
|
||||||
|
- **Soft Delete**: Folders can be marked as deleted without actual removal
|
||||||
|
- **Ordering**: Custom display order for folders
|
||||||
|
|
||||||
|
### 2. **Document Management**
|
||||||
|
- **Multiple File Types**: Support for PDF, Word, Excel, PowerPoint, Images, and more
|
||||||
|
- **Version Control**: Track multiple versions of the same document
|
||||||
|
- **Bilingual Titles**: Document titles and descriptions in both languages
|
||||||
|
- **Tags**: Comma-separated tags for easy searching
|
||||||
|
- **Access Control**: Role-based access control per document
|
||||||
|
- **Download Tracking**: Track how many times documents are downloaded
|
||||||
|
- **Publication Control**: Documents can be published/unpublished
|
||||||
|
|
||||||
|
### 3. **Search & Discovery**
|
||||||
|
- Full-text search across titles, descriptions, and tags
|
||||||
|
- Filter by folder
|
||||||
|
- Filter by file type
|
||||||
|
- Filter by tags
|
||||||
|
- Pagination for large result sets
|
||||||
|
|
||||||
|
### 4. **Audit Trail**
|
||||||
|
- Track all document accesses (view, download, preview)
|
||||||
|
- Log user actions with IP addresses
|
||||||
|
- Track download counts and last accessed dates
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
### ReferenceFolder
|
||||||
|
|
||||||
|
Main folder model with the following fields:
|
||||||
|
|
||||||
|
- `name`: English folder name (required)
|
||||||
|
- `name_ar`: Arabic folder name (optional)
|
||||||
|
- `description`: English description (optional)
|
||||||
|
- `description_ar`: Arabic description (optional)
|
||||||
|
- `parent`: Parent folder for nested structure
|
||||||
|
- `icon`: FontAwesome icon class (e.g., 'fa-folder')
|
||||||
|
- `color`: Hex color code (e.g., '#007bff')
|
||||||
|
- `order`: Display order
|
||||||
|
- `access_roles`: Groups that can access this folder
|
||||||
|
- `is_active`: Active status
|
||||||
|
- `is_deleted`: Soft delete flag
|
||||||
|
- `hospital`: Hospital tenant (via TenantModel)
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `get_full_path()`: Get full folder path as breadcrumb
|
||||||
|
- `has_access(user)`: Check if user has access
|
||||||
|
- `get_subfolders()`: Get immediate subfolders
|
||||||
|
- `get_documents()`: Get documents in this folder
|
||||||
|
- `get_document_count()`: Count documents including subfolders
|
||||||
|
|
||||||
|
### ReferenceDocument
|
||||||
|
|
||||||
|
Document model with the following fields:
|
||||||
|
|
||||||
|
- `folder`: Associated folder
|
||||||
|
- `title`: English title (required)
|
||||||
|
- `title_ar`: Arabic title (optional)
|
||||||
|
- `file`: File upload
|
||||||
|
- `filename`: Original filename
|
||||||
|
- `file_type`: File extension (e.g., pdf, docx)
|
||||||
|
- `file_size`: File size in bytes
|
||||||
|
- `description`: English description (optional)
|
||||||
|
- `description_ar`: Arabic description (optional)
|
||||||
|
- `version`: Version string (e.g., '1.0', '1.1')
|
||||||
|
- `is_latest_version`: Is this the latest version?
|
||||||
|
- `parent_document`: Previous version reference
|
||||||
|
- `uploaded_by`: User who uploaded
|
||||||
|
- `download_count`: Number of downloads
|
||||||
|
- `last_accessed_at`: Last access timestamp
|
||||||
|
- `is_published`: Visibility status
|
||||||
|
- `access_roles`: Groups that can access this document
|
||||||
|
- `tags`: Comma-separated tags
|
||||||
|
- `metadata`: Additional metadata (JSON)
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `has_access(user)`: Check if user has access
|
||||||
|
- `increment_download_count()`: Track downloads
|
||||||
|
- `get_tags_list()`: Get tags as list
|
||||||
|
- `get_file_icon()`: Get appropriate icon class
|
||||||
|
- `get_file_size_display()`: Human-readable file size
|
||||||
|
- `get_version_history()`: Get all versions
|
||||||
|
|
||||||
|
### ReferenceDocumentAccess
|
||||||
|
|
||||||
|
Access log model for tracking:
|
||||||
|
|
||||||
|
- `document`: Referenced document
|
||||||
|
- `user`: User who accessed
|
||||||
|
- `action`: Action type (view, download, preview)
|
||||||
|
- `ip_address`: User's IP address
|
||||||
|
- `user_agent`: Browser user agent
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
### UI Views (Server-Rendered)
|
||||||
|
|
||||||
|
1. **`reference_dashboard`** - Main entry point showing:
|
||||||
|
- Root folders
|
||||||
|
- Recent documents
|
||||||
|
- Statistics (total folders, total documents)
|
||||||
|
|
||||||
|
2. **`folder_view`** - Browse folder contents:
|
||||||
|
- Shows subfolders
|
||||||
|
- Shows documents
|
||||||
|
- Breadcrumb navigation
|
||||||
|
|
||||||
|
3. **`folder_create`** - Create new folder:
|
||||||
|
- Form with all folder fields
|
||||||
|
- Parent folder option
|
||||||
|
- Access control configuration
|
||||||
|
|
||||||
|
4. **`folder_edit`** - Edit existing folder:
|
||||||
|
- Update folder details
|
||||||
|
- Change parent folder
|
||||||
|
- Modify access control
|
||||||
|
|
||||||
|
5. **`folder_delete`** - Soft delete folder (AJAX):
|
||||||
|
- Mark folder as deleted
|
||||||
|
- Documents in folder remain
|
||||||
|
|
||||||
|
6. **`document_view`** - Document details:
|
||||||
|
- Document information
|
||||||
|
- Version history
|
||||||
|
- Related documents
|
||||||
|
- Download button
|
||||||
|
|
||||||
|
7. **`document_create`** - Upload new document:
|
||||||
|
- File upload
|
||||||
|
- Metadata input
|
||||||
|
- Folder selection
|
||||||
|
- New version option
|
||||||
|
|
||||||
|
8. **`document_edit`** - Edit document:
|
||||||
|
- Update metadata
|
||||||
|
- Upload new file (optional)
|
||||||
|
- Create new version option
|
||||||
|
|
||||||
|
9. **`document_delete`** - Soft delete document (AJAX)
|
||||||
|
|
||||||
|
10. **`search`** - Search documents:
|
||||||
|
- Full-text search
|
||||||
|
- Multiple filters
|
||||||
|
- Pagination
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Dashboard
|
||||||
|
/references/ # Main dashboard
|
||||||
|
|
||||||
|
# Folders
|
||||||
|
/references/folder/<pk>/ # View folder
|
||||||
|
/references/folder/create/ # Create folder
|
||||||
|
/references/folder/<pk>/create/ # Create subfolder
|
||||||
|
/references/folder/<pk>/edit/ # Edit folder
|
||||||
|
/references/folder/<pk>/delete/ # Delete folder
|
||||||
|
|
||||||
|
# Documents
|
||||||
|
/references/document/<pk>/ # View document
|
||||||
|
/references/document/create/ # Upload document
|
||||||
|
/references/document/<pk>/create/ # Upload to folder
|
||||||
|
/references/document/<pk>/edit/ # Edit document
|
||||||
|
/references/document/<pk>/delete/ # Delete document
|
||||||
|
|
||||||
|
# Search
|
||||||
|
/references/search/ # Search documents
|
||||||
|
```
|
||||||
|
|
||||||
|
## Access Control
|
||||||
|
|
||||||
|
The system implements role-based access control:
|
||||||
|
|
||||||
|
1. **Folder Access**: A folder can have specific access roles assigned
|
||||||
|
- If no roles assigned → Accessible to all users
|
||||||
|
- If roles assigned → Only users in those roles can access
|
||||||
|
|
||||||
|
2. **Document Access**: Similar to folder access
|
||||||
|
- Inherits folder access if not restricted
|
||||||
|
- Can have its own access restrictions
|
||||||
|
|
||||||
|
3. **Cascade Effect**: When accessing documents through folder navigation:
|
||||||
|
- User must have access to both folder AND document
|
||||||
|
- Document-specific access overrides folder access
|
||||||
|
|
||||||
|
## File Storage
|
||||||
|
|
||||||
|
Files are stored with the following path structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
references/<hospital_id>/YYYY/MM/DD/<uuid>.<ext>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
references/1/2026/01/07/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Creating a Folder
|
||||||
|
|
||||||
|
1. Navigate to `/references/`
|
||||||
|
2. Click "New Folder" button
|
||||||
|
3. Fill in folder information:
|
||||||
|
- Name (English) - Required
|
||||||
|
- Name (Arabic) - Optional
|
||||||
|
- Description (English) - Optional
|
||||||
|
- Description (Arabic) - Optional
|
||||||
|
- Parent Folder - Optional (for nested folders)
|
||||||
|
- Icon - Optional (e.g., 'fa-folder')
|
||||||
|
- Color - Optional (e.g., '#007bff')
|
||||||
|
- Order - Optional (default: 0)
|
||||||
|
- Access Roles - Optional (leave empty for public)
|
||||||
|
4. Click "Create Folder"
|
||||||
|
|
||||||
|
### Uploading a Document
|
||||||
|
|
||||||
|
1. Navigate to folder where you want to upload
|
||||||
|
2. Click "Upload Document" button
|
||||||
|
3. Fill in document information:
|
||||||
|
- Title (English) - Required
|
||||||
|
- Title (Arabic) - Optional
|
||||||
|
- File - Required
|
||||||
|
- Description (English) - Optional
|
||||||
|
- Description (Arabic) - Optional
|
||||||
|
- Version - Optional (default: '1.0')
|
||||||
|
- Access Roles - Optional
|
||||||
|
- Tags - Optional (comma-separated)
|
||||||
|
4. Click "Upload Document"
|
||||||
|
|
||||||
|
### Searching Documents
|
||||||
|
|
||||||
|
1. Navigate to `/references/search/`
|
||||||
|
2. Use search/filter options:
|
||||||
|
- Search by text
|
||||||
|
- Filter by folder
|
||||||
|
- Filter by file type
|
||||||
|
- Filter by tags
|
||||||
|
3. Results are paginated
|
||||||
|
|
||||||
|
### Creating Document Versions
|
||||||
|
|
||||||
|
1. Go to document detail page
|
||||||
|
2. Click "Edit Document"
|
||||||
|
3. Check "Upload as new version"
|
||||||
|
4. Select new file
|
||||||
|
5. Click "Update Document"
|
||||||
|
6. Old version is marked as not latest
|
||||||
|
7. New version becomes the current version
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
All templates are in `templates/references/`:
|
||||||
|
|
||||||
|
- `dashboard.html` - Main dashboard
|
||||||
|
- `folder_view.html` - Folder contents
|
||||||
|
- `folder_form.html` - Create/edit folder
|
||||||
|
- `document_view.html` - Document details
|
||||||
|
- `document_form.html` - Upload/edit document
|
||||||
|
- `search.html` - Search results
|
||||||
|
|
||||||
|
## Forms
|
||||||
|
|
||||||
|
- `ReferenceFolderForm` - Folder creation/editing
|
||||||
|
- `ReferenceDocumentForm` - Document upload/editing
|
||||||
|
- `ReferenceDocumentSearchForm` - Document search
|
||||||
|
|
||||||
|
## Admin Integration
|
||||||
|
|
||||||
|
All models are registered with Django Admin:
|
||||||
|
|
||||||
|
- ReferenceFolder - Full CRUD with inline actions
|
||||||
|
- ReferenceDocument - Full CRUD with file preview
|
||||||
|
- ReferenceDocumentAccess - Read-only access logs
|
||||||
|
|
||||||
|
## File Upload Configuration
|
||||||
|
|
||||||
|
The system supports the following file types:
|
||||||
|
|
||||||
|
- PDF (.pdf)
|
||||||
|
- Word (.doc, .docx)
|
||||||
|
- Excel (.xls, .xlsx)
|
||||||
|
- PowerPoint (.ppt, .pptx)
|
||||||
|
- Text (.txt, .rtf)
|
||||||
|
- CSV (.csv)
|
||||||
|
- Images (.jpg, .jpeg, .png, .gif)
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Folder Organization**
|
||||||
|
- Use meaningful names in both languages
|
||||||
|
- Keep structure simple and logical
|
||||||
|
- Limit nesting depth (3-4 levels max)
|
||||||
|
|
||||||
|
2. **Document Management**
|
||||||
|
- Use clear, descriptive titles
|
||||||
|
- Add meaningful tags for searchability
|
||||||
|
- Keep document versions organized
|
||||||
|
- Archive old versions, don't delete
|
||||||
|
|
||||||
|
3. **Access Control**
|
||||||
|
- Assign access roles at folder level for broad control
|
||||||
|
- Use document-level access for sensitive files
|
||||||
|
- Regularly review access permissions
|
||||||
|
|
||||||
|
4. **File Uploads**
|
||||||
|
- Keep file sizes reasonable (under 50MB recommended)
|
||||||
|
- Use PDF format for documents when possible
|
||||||
|
- Optimize images before uploading
|
||||||
|
|
||||||
|
## Integration with Hospital System
|
||||||
|
|
||||||
|
The References section is fully integrated with the hospital tenant system:
|
||||||
|
|
||||||
|
- Each hospital has its own set of folders and documents
|
||||||
|
- Files are segregated by hospital ID
|
||||||
|
- Users can only access their assigned hospital's references
|
||||||
|
- Context processors ensure hospital context is always available
|
||||||
|
|
||||||
|
## Sidebar Navigation
|
||||||
|
|
||||||
|
The References section is accessible from the sidebar:
|
||||||
|
|
||||||
|
```
|
||||||
|
Sidebar → References (icon: bi-folder)
|
||||||
|
```
|
||||||
|
|
||||||
|
This link navigates to `/references/` (dashboard).
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements for future versions:
|
||||||
|
|
||||||
|
1. **Bulk Operations**
|
||||||
|
- Bulk upload documents
|
||||||
|
- Bulk move/copy folders
|
||||||
|
- Bulk delete
|
||||||
|
|
||||||
|
2. **Advanced Search**
|
||||||
|
- Full-text indexing with search engine
|
||||||
|
- Document content search (PDF text extraction)
|
||||||
|
- Saved search queries
|
||||||
|
|
||||||
|
3. **Integration**
|
||||||
|
- Email documents directly from system
|
||||||
|
- Generate document download reports
|
||||||
|
- Integrate with external document storage (S3, etc.)
|
||||||
|
|
||||||
|
4. **Collaboration**
|
||||||
|
- Document comments/annotations
|
||||||
|
- Document sharing links
|
||||||
|
- Workflow approvals for documents
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: Folder creation not refreshing
|
||||||
|
|
||||||
|
**Solution**: The folder_form template has been fixed to use correct field names:
|
||||||
|
- `name` (not `name_en`)
|
||||||
|
- `description` (not `description_en`)
|
||||||
|
- Removed non-existent fields: `visibility`, `tags`
|
||||||
|
|
||||||
|
### Issue: Can't see folders/documents
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
1. User doesn't have hospital assigned → Check user.hospital
|
||||||
|
2. Access roles not configured → Check folder/document access_roles
|
||||||
|
3. Folder/document is inactive → Check is_active/is_published flags
|
||||||
|
4. Soft deleted → Check is_deleted flag
|
||||||
|
|
||||||
|
### Issue: File upload fails
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
1. File type not supported → Check allowed_types in model.clean()
|
||||||
|
2. File size too large → Check Django settings for FILE_UPLOAD_MAX_MEMORY_SIZE
|
||||||
|
3. Insufficient permissions → Check media directory write permissions
|
||||||
|
|
||||||
|
## Technical Implementation
|
||||||
|
|
||||||
|
### File Upload Handling
|
||||||
|
|
||||||
|
Files are handled via Django's FileField with custom upload path function:
|
||||||
|
- Path includes hospital ID for segregation
|
||||||
|
- Date-based organization (YYYY/MM/DD)
|
||||||
|
- UUID-based filename for uniqueness
|
||||||
|
|
||||||
|
### Soft Delete
|
||||||
|
|
||||||
|
Both folders and documents use soft delete:
|
||||||
|
- `is_deleted` flag instead of actual deletion
|
||||||
|
- Maintains audit trail
|
||||||
|
- Allows recovery if needed
|
||||||
|
|
||||||
|
### Version Control
|
||||||
|
|
||||||
|
Documents support versioning:
|
||||||
|
- `parent_document` links to previous version
|
||||||
|
- `is_latest_version` indicates current version
|
||||||
|
- `version` string for semantic versioning
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The References section provides a comprehensive document management system with folder-based organization, role-based access control, version tracking, and full audit capabilities. It's simple and straightforward to use while providing powerful features for managing organizational documents.
|
||||||
@ -74,6 +74,10 @@ msgid "Social Media"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/layouts/partials/sidebar.html:96
|
#: templates/layouts/partials/sidebar.html:96
|
||||||
|
msgid "References"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: templates/layouts/partials/sidebar.html:107
|
||||||
msgid "Analytics"
|
msgid "Analytics"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@ -245,17 +245,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if complaint.physician %}
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="info-label">{{ _("Physician") }}</div>
|
|
||||||
<div class="info-value">
|
|
||||||
Dr. {{ complaint.physician.first_name }} {{ complaint.physician.last_name }}
|
|
||||||
<span class="text-muted">({{ complaint.physician.specialty }})</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if complaint.department %}
|
{% if complaint.department %}
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
@ -315,6 +304,76 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Staff Suggestions Section -->
|
||||||
|
{% if complaint.metadata.ai_analysis.staff_matches and user.is_px_admin %}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="info-label">
|
||||||
|
<i class="bi bi-people me-1"></i>
|
||||||
|
Staff Suggestions
|
||||||
|
{% if complaint.metadata.ai_analysis.needs_staff_review %}
|
||||||
|
<span class="badge bg-warning ms-2">Needs Review</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="info-value">
|
||||||
|
{% if complaint.metadata.ai_analysis.extracted_staff_name %}
|
||||||
|
<p class="text-muted mb-2">
|
||||||
|
AI extracted name: <strong>"{{ complaint.metadata.ai_analysis.extracted_staff_name }}"</strong>
|
||||||
|
({% if complaint.metadata.ai_analysis.staff_match_count %}{{ complaint.metadata.ai_analysis.staff_match_count }} potential match{{ complaint.metadata.ai_analysis.staff_match_count|pluralize }}{% else %}No matches found{% endif %})
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="list-group">
|
||||||
|
{% for staff_match in complaint.metadata.ai_analysis.staff_matches %}
|
||||||
|
<div class="list-group-item list-group-item-action {% if complaint.staff and staff_match.id == complaint.staff.id|stringformat:"s" %}list-group-item-primary{% endif %}">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-person me-2 text-primary"></i>
|
||||||
|
<strong>{{ staff_match.name_en }}</strong>
|
||||||
|
{% if staff_match.name_ar %}
|
||||||
|
<span class="text-muted ms-2">({{ staff_match.name_ar }})</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if staff_match.job_title %}
|
||||||
|
<small class="text-muted">{{ staff_match.job_title }}</small>
|
||||||
|
{% endif %}
|
||||||
|
{% if staff_match.specialization %}
|
||||||
|
<small class="text-muted"> • {{ staff_match.specialization }}</small>
|
||||||
|
{% endif %}
|
||||||
|
{% if staff_match.department %}
|
||||||
|
<small class="text-muted"> • {{ staff_match.department }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<span class="badge {% if staff_match.confidence >= 0.7 %}bg-success{% elif staff_match.confidence >= 0.5 %}bg-warning{% else %}bg-danger{% endif %}">
|
||||||
|
{{ staff_match.confidence|mul:100|floatformat:0 }}% confidence
|
||||||
|
</span>
|
||||||
|
{% if complaint.staff and staff_match.id == complaint.staff.id|stringformat:"s" %}
|
||||||
|
<div class="small text-success mt-1">
|
||||||
|
<i class="bi bi-check-circle-fill"></i> Currently assigned
|
||||||
|
</div>
|
||||||
|
{% elif not complaint.staff %}
|
||||||
|
<button class="btn btn-sm btn-outline-primary mt-1" onclick="assignStaff('{{ staff_match.id }}', '{{ staff_match.name_en }}')">
|
||||||
|
<i class="bi bi-person-plus"></i> Select
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#staffSelectionModal">
|
||||||
|
<i class="bi bi-search me-1"></i> Search All Staff
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -348,7 +407,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 text-md-end">
|
<div class="col-md-6 text-md-end">
|
||||||
<small class="text-muted">Confidence: {{ complaint.emotion_confidence|floatformat:0 }}%</small>
|
<small class="text-muted">Confidence: {{ complaint.emotion_confidence|mul:100|floatformat:0 }}%</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-1">
|
<div class="mb-1">
|
||||||
@ -575,6 +634,46 @@
|
|||||||
<h6 class="mb-0"><i class="bi bi-lightning-fill me-2"></i>{% trans "Quick Actions" %}</h6>
|
<h6 class="mb-0"><i class="bi bi-lightning-fill me-2"></i>{% trans "Quick Actions" %}</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
{% if user.is_px_admin %}
|
||||||
|
<!-- Change Staff (PX Admin only) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{% trans "Change Staff" %}</label>
|
||||||
|
<button type="button" class="btn btn-outline-info w-100" data-bs-toggle="modal" data-bs-target="#staffSelectionModal">
|
||||||
|
<i class="bi bi-person-badge me-1"></i> {{ _("Assign/Change Staff")}}
|
||||||
|
</button>
|
||||||
|
{% if complaint.metadata.ai_analysis.needs_staff_review %}
|
||||||
|
<small class="text-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
This complaint needs staff review
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Change Department -->
|
||||||
|
{% if can_edit and hospital_departments %}
|
||||||
|
<form method="post" action="{% url 'complaints:complaint_change_department' complaint.id %}" class="mb-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<label class="form-label">{% trans "Change Department" %}</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<select name="department_id" class="form-select" required>
|
||||||
|
<option value="">{{ _("Select department...")}}</option>
|
||||||
|
{% for dept in hospital_departments %}
|
||||||
|
<option value="{{ dept.id }}"
|
||||||
|
{% if complaint.department and complaint.department.id == dept.id %}selected{% endif %}>
|
||||||
|
{{ dept.name_en }}
|
||||||
|
{% if dept.name_ar %}({{ dept.name_ar }}){% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-building"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Assign -->
|
<!-- Assign -->
|
||||||
<form method="post" action="{% url 'complaints:complaint_assign' complaint.id %}" class="mb-3">
|
<form method="post" action="{% url 'complaints:complaint_assign' complaint.id %}" class="mb-3">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@ -613,6 +712,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Send Notification -->
|
||||||
|
<button type="button" class="btn btn-info w-100 mb-2" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#sendNotificationModal">
|
||||||
|
<i class="bi bi-envelope me-1"></i> {{ _("Send Notification") }}
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Escalate -->
|
<!-- Escalate -->
|
||||||
<button type="button" class="btn btn-danger w-100" data-bs-toggle="modal"
|
<button type="button" class="btn btn-danger w-100" data-bs-toggle="modal"
|
||||||
data-bs-target="#escalateModal">
|
data-bs-target="#escalateModal">
|
||||||
@ -805,7 +910,416 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Staff Selection Modal -->
|
||||||
|
<div class="modal fade" id="staffSelectionModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="bi bi-person-badge me-2"></i>Select Staff Member
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Department Filter -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Filter by Department</label>
|
||||||
|
<select id="staffDepartmentFilter" class="form-select">
|
||||||
|
<option value="">All Departments</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Search Staff</label>
|
||||||
|
<input type="text" id="staffSearchInput" class="form-control" placeholder="Search by name or job title...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Staff List -->
|
||||||
|
<div id="staffListContainer" class="border rounded" style="max-height: 400px; overflow-y: auto;">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" id="assignSelectedStaffBtn" class="btn btn-primary" onclick="assignSelectedStaff()" disabled>
|
||||||
|
<i class="bi bi-person-check me-1"></i>Assign Selected Staff
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Send Notification Modal -->
|
||||||
|
<div class="modal fade" id="sendNotificationModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="bi bi-envelope me-2"></i>Send Complaint Notification
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- AI Summary (EDITABLE) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">
|
||||||
|
<i class="bi bi-robot me-1"></i>
|
||||||
|
AI Summary
|
||||||
|
<small class="text-muted">(you can edit this before sending)</small>
|
||||||
|
</label>
|
||||||
|
<textarea id="emailMessage" class="form-control" rows="5"
|
||||||
|
placeholder="Enter message to send...">{{ complaint.short_description }}</textarea>
|
||||||
|
<small class="text-muted">
|
||||||
|
This is AI-generated summary. You can edit it before sending.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipient Information -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">
|
||||||
|
<i class="bi bi-person-check me-1"></i>Recipient
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
{% if complaint.staff and complaint.staff.user %}
|
||||||
|
<!-- Staff has user account - will receive email -->
|
||||||
|
<div class="alert alert-success mb-2">
|
||||||
|
<i class="bi bi-check-circle-fill me-1"></i>
|
||||||
|
<strong>Primary Recipient:</strong> {{ complaint.staff.get_full_name }}
|
||||||
|
{% if complaint.staff.job_title %}
|
||||||
|
<br><small class="text-muted">{{ complaint.staff.job_title }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif complaint.staff %}
|
||||||
|
<!-- Staff exists but has no user account -->
|
||||||
|
<div class="alert alert-warning mb-2">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||||
|
<strong>Staff Member Assigned:</strong> {{ complaint.staff.get_full_name }}
|
||||||
|
{% if complaint.staff.job_title %}
|
||||||
|
<br><small class="text-muted">{{ complaint.staff.job_title }}</small>
|
||||||
|
{% endif %}
|
||||||
|
<hr class="my-2">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
This staff member has no user account in the system.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if complaint.department and complaint.department.manager %}
|
||||||
|
<!-- Department manager is the actual recipient -->
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
<i class="bi bi-person-badge me-1"></i>
|
||||||
|
<strong>Actual Recipient:</strong> {{ complaint.department.manager.get_full_name }}
|
||||||
|
<br><small class="text-muted">Department Head of {{ complaint.department.name_en }}</small>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- No fallback recipient -->
|
||||||
|
<div class="alert alert-danger mb-0">
|
||||||
|
<i class="bi bi-x-circle-fill me-1"></i>
|
||||||
|
<strong>No recipient available</strong>
|
||||||
|
<br><small>The assigned staff has no user account and no department manager is set.</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% elif complaint.department and complaint.department.manager %}
|
||||||
|
<!-- No staff, but department manager exists -->
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
<i class="bi bi-person-badge me-1"></i>
|
||||||
|
<strong>Department Head:</strong> {{ complaint.department.manager.get_full_name }}
|
||||||
|
<br><small class="text-muted">Manager of {{ complaint.department.name_en }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- No recipient at all -->
|
||||||
|
<div class="alert alert-danger mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||||
|
<strong>No recipient available</strong>
|
||||||
|
<br><small>No staff or department manager assigned to this complaint.</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Optional Additional Message -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">
|
||||||
|
Additional Message (Optional)
|
||||||
|
</label>
|
||||||
|
<textarea id="additionalMessage" class="form-control" rows="3"
|
||||||
|
placeholder="Add any additional notes to send..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" id="sendNotificationBtn" class="btn btn-primary"
|
||||||
|
onclick="sendNotification()">
|
||||||
|
<i class="bi bi-send me-1"></i>Send Email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
let selectedStaffId = null;
|
||||||
|
let allHospitalStaff = [];
|
||||||
|
let currentLanguage = document.documentElement.lang || 'en';
|
||||||
|
|
||||||
|
// Load hospital staff when modal opens
|
||||||
|
document.getElementById('staffSelectionModal').addEventListener('shown.bs.modal', function() {
|
||||||
|
loadHospitalDepartments();
|
||||||
|
loadHospitalStaff();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load hospital departments
|
||||||
|
function loadHospitalDepartments() {
|
||||||
|
const hospitalId = '{{ complaint.hospital.id }}';
|
||||||
|
const departmentSelect = document.getElementById('staffDepartmentFilter');
|
||||||
|
|
||||||
|
fetch(`/organizations/api/departments/?hospital=${hospitalId}`, {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Clear existing options except the first one
|
||||||
|
departmentSelect.innerHTML = '<option value="">All Departments</option>';
|
||||||
|
|
||||||
|
// Populate with departments
|
||||||
|
data.results.forEach(dept => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = dept.id;
|
||||||
|
const deptName = currentLanguage === 'ar' && dept.name_ar ? dept.name_ar : dept.name_en;
|
||||||
|
option.textContent = deptName;
|
||||||
|
departmentSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading departments:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load hospital staff from API
|
||||||
|
function loadHospitalStaff(departmentId = null, search = '') {
|
||||||
|
const container = document.getElementById('staffListContainer');
|
||||||
|
let url = `/complaints/api/complaints/{{ complaint.id }}/hospital_staff/`;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (departmentId) params.append('department_id', departmentId);
|
||||||
|
if (search) params.append('search', search);
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
url += '?' + params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCookie('csrftoken')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
allHospitalStaff = data.staff || [];
|
||||||
|
renderStaffList(allHospitalStaff);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading staff:', error);
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="alert alert-danger m-3">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
Failed to load staff. Please try again.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render staff list
|
||||||
|
function renderStaffList(staffList) {
|
||||||
|
const container = document.getElementById('staffListContainer');
|
||||||
|
|
||||||
|
if (!staffList || staffList.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-center py-4 text-muted">
|
||||||
|
<i class="bi bi-people" style="font-size: 3rem;"></i>
|
||||||
|
<p class="mt-3">No staff found</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="list-group list-group-flush">';
|
||||||
|
|
||||||
|
// Group by department
|
||||||
|
const grouped = {};
|
||||||
|
staffList.forEach(staff => {
|
||||||
|
const dept = staff.department || 'No Department';
|
||||||
|
if (!grouped[dept]) grouped[dept] = [];
|
||||||
|
grouped[dept].push(staff);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render groups
|
||||||
|
for (const [department, staffMembers] of Object.entries(grouped)) {
|
||||||
|
html += `<div class="list-group-item bg-light fw-bold"><i class="bi bi-building me-2"></i>${department}</div>`;
|
||||||
|
|
||||||
|
staffMembers.forEach(staff => {
|
||||||
|
html += `
|
||||||
|
<div class="list-group-item list-group-item-action staff-item"
|
||||||
|
data-staff-id="${staff.id}"
|
||||||
|
data-staff-name="${staff.name_en}"
|
||||||
|
onclick="selectStaff('${staff.id}', '${staff.name_en.replace(/'/g, "\\'")}')">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="selectedStaff"
|
||||||
|
id="staff_${staff.id}" value="${staff.id}"
|
||||||
|
${selectedStaffId === staff.id ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label w-100 cursor-pointer" for="staff_${staff.id}">
|
||||||
|
<div>
|
||||||
|
<strong>${staff.name_en}</strong>
|
||||||
|
${staff.name_ar ? `<span class="text-muted ms-2">(${staff.name_ar})</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
${staff.job_title || ''}
|
||||||
|
${staff.specialization ? '• ' + staff.specialization : ''}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select staff from list
|
||||||
|
function selectStaff(staffId, staffName) {
|
||||||
|
selectedStaffId = staffId;
|
||||||
|
|
||||||
|
// Update radio buttons
|
||||||
|
document.querySelectorAll('input[name="selectedStaff"]').forEach(radio => {
|
||||||
|
radio.checked = (radio.value === staffId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable assign button
|
||||||
|
document.getElementById('assignSelectedStaffBtn').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign staff directly from suggestions
|
||||||
|
function assignStaff(staffId, staffName) {
|
||||||
|
if (!confirm(`Assign ${staffName} to this complaint?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
staff_id: staffId,
|
||||||
|
reason: 'Selected from AI suggestions'
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(`/complaints/api/complaints/{{ complaint.id }}/assign_staff/`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCookie('csrftoken')
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.message) {
|
||||||
|
alert('Staff assigned successfully!');
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (result.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Failed to assign staff. Please try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign selected staff from modal
|
||||||
|
function assignSelectedStaff() {
|
||||||
|
if (!selectedStaffId) {
|
||||||
|
alert('Please select a staff member first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staffName = document.querySelector(`input[name="selectedStaff"]:checked`)?.closest('.staff-item')?.dataset?.staffName || 'selected staff';
|
||||||
|
const reason = prompt('Reason for assignment (optional):', 'Manual selection from hospital staff list');
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
staff_id: selectedStaffId,
|
||||||
|
reason: reason || 'Manual selection from hospital staff list'
|
||||||
|
};
|
||||||
|
|
||||||
|
const btn = document.getElementById('assignSelectedStaffBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Assigning...';
|
||||||
|
|
||||||
|
fetch(`/complaints/api/complaints/{{ complaint.id }}/assign_staff/`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCookie('csrftoken')
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.message) {
|
||||||
|
alert('Staff assigned successfully!');
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (result.error || 'Unknown error'));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-person-check me-1"></i>Assign Selected Staff';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Failed to assign staff. Please try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-person-check me-1"></i>Assign Selected Staff';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Department filter change handler
|
||||||
|
document.getElementById('staffDepartmentFilter')?.addEventListener('change', function(e) {
|
||||||
|
const departmentId = e.target.value || null;
|
||||||
|
const search = document.getElementById('staffSearchInput').value.trim();
|
||||||
|
loadHospitalStaff(departmentId, search);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search input handler (debounced)
|
||||||
|
let searchTimeout;
|
||||||
|
document.getElementById('staffSearchInput')?.addEventListener('input', function(e) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
const departmentId = document.getElementById('staffDepartmentFilter').value || null;
|
||||||
|
const search = e.target.value.trim();
|
||||||
|
loadHospitalStaff(departmentId, search);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
function createAction() {
|
function createAction() {
|
||||||
const assignTo = document.getElementById('actionAssignTo').value;
|
const assignTo = document.getElementById('actionAssignTo').value;
|
||||||
const btn = document.getElementById('createActionBtn');
|
const btn = document.getElementById('createActionBtn');
|
||||||
@ -819,8 +1333,9 @@ function createAction() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
|
||||||
|
|
||||||
fetch(`/api/complaints/{{ complaint.id }}/create_action_from_ai/`, {
|
fetch(`/complaints/api/complaints/{{ complaint.id }}/create_action_from_ai/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRFToken': getCookie('csrftoken')
|
'X-CSRFToken': getCookie('csrftoken')
|
||||||
@ -860,5 +1375,58 @@ function getCookie(name) {
|
|||||||
}
|
}
|
||||||
return cookieValue;
|
return cookieValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendNotification() {
|
||||||
|
const btn = document.getElementById('sendNotificationBtn');
|
||||||
|
const emailMessage = document.getElementById('emailMessage').value;
|
||||||
|
const additionalMessage = document.getElementById('additionalMessage').value;
|
||||||
|
|
||||||
|
// Validate email message is not empty
|
||||||
|
if (!emailMessage.trim()) {
|
||||||
|
alert('Please enter a message to send.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable button and show loading
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Sending...';
|
||||||
|
|
||||||
|
// Get CSRF token
|
||||||
|
const csrftoken = getCookie('csrftoken');
|
||||||
|
|
||||||
|
fetch(`/complaints/api/complaints/{{ complaint.id }}/send_notification/`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrftoken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email_message: emailMessage,
|
||||||
|
additional_message: additionalMessage
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert('Email sent successfully!');
|
||||||
|
// Close modal
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('sendNotificationModal'));
|
||||||
|
modal.hide();
|
||||||
|
// Reload page to show timeline entry
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.error || 'Unknown error'));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-send me-1"></i>Send Email';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Failed to send email. Please try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-send me-1"></i>Send Email';
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -97,9 +97,9 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">{% trans "Physician" %}</label>
|
<label class="form-label">{% trans "Staff" %}</label>
|
||||||
<select name="physician_id" class="form-select" id="physicianSelect">
|
<select name="staff_id" class="form-select" id="staffSelect">
|
||||||
<option value="">{{ _("Select physician")}}</option>
|
<option value="">{{ _("Select staff")}}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -340,11 +340,24 @@ function getName(category) {
|
|||||||
return category.name_en;
|
return category.name_en;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load categories
|
// Load categories based on hospital
|
||||||
function loadCategories() {
|
function loadCategories(hospitalId) {
|
||||||
|
if (!hospitalId) {
|
||||||
|
// Clear categories if no hospital selected
|
||||||
|
const categorySelect = $('#id_category');
|
||||||
|
categorySelect.find('option:not(:first)').remove();
|
||||||
|
$('#subcategory_container').hide();
|
||||||
|
$('#subcategory_description').hide();
|
||||||
|
$('#category_description').hide();
|
||||||
|
$('#id_subcategory').find('option:not(:first)').remove();
|
||||||
|
$('#id_subcategory').prop('required', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '{% url "complaints:api_load_categories" %}',
|
url: '{% url "complaints:api_load_categories" %}',
|
||||||
type: 'GET',
|
type: 'GET',
|
||||||
|
data: { hospital_id: hospitalId },
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
// Store all categories
|
// Store all categories
|
||||||
allCategories = response.categories;
|
allCategories = response.categories;
|
||||||
@ -429,17 +442,17 @@ function loadSubcategories(categoryId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize complaint form - called when form is loaded
|
// Handle hospital change
|
||||||
function initializeComplaintForm() {
|
$('#id_hospital').on('change', function() {
|
||||||
// Detect current language from HTML
|
const hospitalId = $(this).val();
|
||||||
const htmlLang = document.documentElement.lang;
|
loadCategories(hospitalId);
|
||||||
if (htmlLang === 'ar') {
|
// Clear category and subcategory when hospital changes
|
||||||
currentLanguage = 'ar';
|
$('#id_category').val('');
|
||||||
}
|
$('#id_subcategory').val('');
|
||||||
|
$('#subcategory_container').hide();
|
||||||
// Load categories immediately
|
$('#subcategory_description').hide();
|
||||||
loadCategories();
|
$('#category_description').hide();
|
||||||
}
|
});
|
||||||
|
|
||||||
// Handle category change
|
// Handle category change
|
||||||
$('#id_category').on('change', function() {
|
$('#id_category').on('change', function() {
|
||||||
@ -510,9 +523,6 @@ $('#public_complaint_form').on('submit', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize form on page load
|
|
||||||
initializeComplaintForm();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -121,6 +121,24 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!-- References -->
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if 'references' in request.path %}active{% endif %}"
|
||||||
|
href="{% url 'references:dashboard' %}">
|
||||||
|
<i class="bi bi-folder"></i>
|
||||||
|
{% trans "References" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Standards -->
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if 'standards' in request.path %}active{% endif %}"
|
||||||
|
href="{% url 'standards:dashboard' %}">
|
||||||
|
<i class="bi bi-shield-check"></i>
|
||||||
|
{% trans "Standards" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
<hr class="my-2" style="border-color: rgba(255,255,255,0.1);">
|
||||||
|
|
||||||
<!-- Analytics -->
|
<!-- Analytics -->
|
||||||
|
|||||||
@ -48,7 +48,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="public-header">
|
{% comment %} <header class="public-header">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="d-flex align-items-center justify-content-between">
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
<h3 class="mb-0">
|
<h3 class="mb-0">
|
||||||
@ -59,7 +59,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header> {% endcomment %}
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main>
|
<main>
|
||||||
@ -69,13 +69,13 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="public-footer">
|
{% comment %} <footer class="public-footer">
|
||||||
<div class="container text-center">
|
<div class="container text-center">
|
||||||
<p class="mb-0 text-muted">
|
<p class="mb-0 text-muted">
|
||||||
© {% now "Y" %} PX360. {% trans "All rights reserved." %}
|
© {% now "Y" %} PX360. {% trans "All rights reserved." %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer> {% endcomment %}
|
||||||
|
|
||||||
<!-- jQuery -->
|
<!-- jQuery -->
|
||||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
|||||||
162
templates/references/dashboard.html
Normal file
162
templates/references/dashboard.html
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Reference Section" %} - {% trans "PX360" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{% trans "Reference Section" %}</h1>
|
||||||
|
<p class="text-muted small mb-0">{% trans "Access and manage reference documents" %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% url 'references:search' %}" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-search me-2"></i>{% trans "Search" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'references:folder_create' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-folder-plus me-2"></i>{% trans "New Folder" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'references:document_create' %}" class="btn btn-success">
|
||||||
|
<i class="fas fa-upload me-2"></i>{% trans "Upload Document" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-primary bg-opacity-10 rounded-3 p-3">
|
||||||
|
<i class="fas fa-folder text-primary fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<h6 class="text-muted mb-1">{% trans "Total Folders" %}</h6>
|
||||||
|
<h2 class="mb-0">{{ total_folders }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="bg-success bg-opacity-10 rounded-3 p-3">
|
||||||
|
<i class="fas fa-file-alt text-success fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<h6 class="text-muted mb-1">{% trans "Total Documents" %}</h6>
|
||||||
|
<h2 class="mb-0">{{ total_documents }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Folders -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% trans "Folders" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if folders %}
|
||||||
|
<div class="row">
|
||||||
|
{% for folder in folders %}
|
||||||
|
<div class="col-md-6 col-lg-4 mb-3">
|
||||||
|
<a href="{% url 'references:folder_view' folder.id %}" class="card h-100 text-decoration-none text-dark folder-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{% if folder.icon %}
|
||||||
|
<i class="{{ folder.icon }} fa-2x text-primary"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-folder fa-2x text-primary"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<h6 class="mb-0">{{ folder.name }}</h6>
|
||||||
|
<small class="text-muted">{{ folder.document_count }} {% trans "documents" %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if folder.description %}
|
||||||
|
<p class="small text-muted mb-0">{{ folder.description|truncatewords:10 }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted text-center py-5">
|
||||||
|
<i class="fas fa-folder-open fa-3x mb-3 d-block text-muted"></i>
|
||||||
|
{% trans "No folders yet. Create your first folder to get started." %}
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fas fa-folder-open fa-3x mb-3 text-muted"></i>
|
||||||
|
<p class="text-muted mb-3">{% trans "No folders yet" %}</p>
|
||||||
|
<a href="{% url 'references:folder_create' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-plus me-2"></i>{% trans "Create Folder" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Documents -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% trans "Recent Documents" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if recent_documents %}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for document in recent_documents %}
|
||||||
|
<a href="{% url 'references:document_view' document.id %}" class="list-group-item list-group-item-action text-decoration-none">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="{{ document.get_file_icon }} fa-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<h6 class="mb-1 small">{{ document.title|truncatechars:40 }}</h6>
|
||||||
|
<small class="text-muted">{{ document.get_file_size_display }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'references:search' %}" class="btn btn-outline-primary btn-sm w-100 mt-3">
|
||||||
|
{% trans "View All Documents" %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center py-4">
|
||||||
|
{% trans "No documents uploaded yet" %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.folder-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
372
templates/references/document_form.html
Normal file
372
templates/references/document_form.html
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{% if document %}{{ _("Edit Document") }}{% else %}{{ _("Upload Document") }}{% endif %} - PX360{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.form-section {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 25px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.form-section-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #667eea;
|
||||||
|
}
|
||||||
|
.required-field::after {
|
||||||
|
content: " *";
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
.file-upload-wrapper {
|
||||||
|
border: 2px dashed #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.file-upload-wrapper:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.current-file-info {
|
||||||
|
background: #e7f3ff;
|
||||||
|
border-left: 4px solid #0066cc;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="mb-4">
|
||||||
|
{% if folder %}
|
||||||
|
<a href="{% url 'references:folder_view' folder.id %}" class="btn btn-outline-secondary btn-sm mb-3">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i> {{ _("Back to Folder")}}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'references:dashboard' %}" class="btn btn-outline-secondary btn-sm mb-3">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i> {{ _("Back to Dashboard")}}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<h2 class="mb-1">
|
||||||
|
{% if document %}
|
||||||
|
<i class="bi bi-pencil-square text-primary me-2"></i>{{ _("Edit Document")}}
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-upload text-primary me-2"></i>{{ _("Upload Document")}}
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
{% if document %}
|
||||||
|
{{ _("Update document information or upload a new version")}}
|
||||||
|
{% else %}
|
||||||
|
{{ _("Upload a new document to the reference library")}}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data" id="documentForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<!-- Django Messages -->
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mb-4" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Form Errors -->
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger mb-4">
|
||||||
|
<strong><i class="bi bi-exclamation-triangle me-2"></i>{% trans "Please fix the following errors:" %}</strong>
|
||||||
|
<ul class="mb-0 mt-2">
|
||||||
|
{% for error in form.non_field_errors %}
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<!-- Document File -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h5 class="form-section-title">
|
||||||
|
<i class="bi bi-file-earmark me-2"></i>{{ _("Document File")}}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
{% if document %}
|
||||||
|
<div class="current-file-info">
|
||||||
|
<h6 class="mb-2"><i class="bi bi-file-earmark-text me-2"></i>{{ _("Current File")}}</h6>
|
||||||
|
<ul class="mb-0 small">
|
||||||
|
<li><strong>{{ _("Filename:") }}</strong> {{ document.filename }}</li>
|
||||||
|
<li><strong>{{ _("File Size:") }}</strong> {{ document.file_size|filesizeformat }}</li>
|
||||||
|
<li><strong>{{ _("Version:") }}</strong> {{ document.version }}</li>
|
||||||
|
<li><strong>{{ _("Uploaded:") }}</strong> {{ document.created_at|date:"M d, Y H:i" }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="file-upload-wrapper">
|
||||||
|
<i class="bi bi-cloud-upload display-4 text-muted mb-3 d-block"></i>
|
||||||
|
<p class="mb-3">
|
||||||
|
<strong>{% trans "Drag & drop your file here" %}</strong><br>
|
||||||
|
<span class="text-muted">{% trans "or click to browse" %}</span>
|
||||||
|
</p>
|
||||||
|
<input type="file" name="file" class="form-control{% if form.file.errors %} is-invalid{% endif %}" id="fileInput"
|
||||||
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png"
|
||||||
|
{% if not document %}required{% endif %}>
|
||||||
|
{% if form.file.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.file.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{% trans "Supported formats:" %} PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, JPG, PNG<br>
|
||||||
|
{% trans "Maximum file size:" %} 50 MB
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if document %}
|
||||||
|
<div class="form-check mt-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="new_version" id="newVersionCheck">
|
||||||
|
<label class="form-check-label" for="newVersionCheck">
|
||||||
|
{% trans "Upload as new version (increment version number)" %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Document Details -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h5 class="form-section-title">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>{{ _("Document Information")}}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label required-field">{% trans "Title (English)" %}</label>
|
||||||
|
<input type="text" name="title" class="form-control{% if form.title.errors %} is-invalid{% endif %}"
|
||||||
|
value="{% if form.title.value %}{{ form.title.value }}{% elif document %}{{ document.title }}{% endif %}"
|
||||||
|
placeholder="{% trans 'Enter document title in English' %}" required>
|
||||||
|
{% if form.title.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.title.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{% trans "Title (Arabic)" %}</label>
|
||||||
|
<input type="text" name="title_ar" class="form-control"
|
||||||
|
value="{% if form.title_ar.value %}{{ form.title_ar.value }}{% elif document %}{{ document.title_ar }}{% endif %}"
|
||||||
|
placeholder="{% trans 'Enter document title in Arabic (optional)' %}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{% trans "Description (English)" %}</label>
|
||||||
|
<textarea name="description" class="form-control" rows="4"
|
||||||
|
placeholder="{% trans 'Detailed description of the document' %}">{% if form.description.value %}{{ form.description.value }}{% elif document %}{{ document.description }}{% endif %}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{% trans "Description (Arabic)" %}</label>
|
||||||
|
<textarea name="description_ar" class="form-control" rows="4"
|
||||||
|
placeholder="{% trans 'Arabic description (optional)' %}">{% if form.description_ar.value %}{{ form.description_ar.value }}{% elif document %}{{ document.description_ar }}{% endif %}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label{% if not folder %} required-field{% endif %}">{% trans "Folder" %}</label>
|
||||||
|
<select name="folder" class="form-select{% if form.folder.errors %} is-invalid{% endif %}"
|
||||||
|
{% if not folder %}required{% endif %}>
|
||||||
|
<option value="">{{ _("Select folder")}}</option>
|
||||||
|
{% for folder_option in all_folders %}
|
||||||
|
<option value="{{ folder_option.id }}"
|
||||||
|
{% if document and document.folder_id == folder_option.id %}selected{% endif %}
|
||||||
|
{% if folder and folder.id == folder_option.id %}selected{% endif %}>
|
||||||
|
{{ folder_option.level_display }}{{ folder_option.name }}
|
||||||
|
{% if folder_option.name_ar %}/ {{ folder_option.name_ar }}{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if form.folder.errors %}
|
||||||
|
<div class="invalid-feedback d-block">
|
||||||
|
{% for error in form.folder.errors %}
|
||||||
|
{{ error }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{% trans "Tags" %}</label>
|
||||||
|
<input type="text" name="tags" class="form-control"
|
||||||
|
value="{% if form.tags.value %}{{ form.tags.value }}{% elif document %}{{ document.tags }}{% endif %}"
|
||||||
|
placeholder="{% trans 'Comma-separated tags (e.g., HR, Safety, 2024)' %}">
|
||||||
|
<small class="form-text text-muted">{{ _("Use tags to categorize and search documents easily")}}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Access Control -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h5 class="form-section-title">
|
||||||
|
<i class="bi bi-shield-lock me-2"></i>{{ _("Access Control")}}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{% trans "Access Roles" %}</label>
|
||||||
|
{% for choice in form.access_roles %}
|
||||||
|
<div class="form-check">
|
||||||
|
{{ choice.tag }}
|
||||||
|
<label class="form-check-label" for="{{ choice.id_for_label }}">
|
||||||
|
{{ choice.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted small">{{ _("No access roles available")}}</p>
|
||||||
|
{% endfor %}
|
||||||
|
<small class="form-text text-muted">{{ _("Select roles that can access this document")}}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_published" id="isPublishedCheck"
|
||||||
|
{% if not document or document.is_published %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="isPublishedCheck">
|
||||||
|
{% trans "Publish this document (make it visible to others)" %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Information -->
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>{{ _("Document Guidelines")}}
|
||||||
|
</h6>
|
||||||
|
<p class="mb-0 small">
|
||||||
|
{{ _("Upload important documents, policies, procedures, and reference materials for easy access by your team.")}}
|
||||||
|
</p>
|
||||||
|
<hr class="my-2">
|
||||||
|
<ul class="mb-0 mt-2 small">
|
||||||
|
<li>{{ _("Use descriptive titles in both languages")}}</li>
|
||||||
|
<li>{{ _("Add clear descriptions")}}</li>
|
||||||
|
<li>{{ _("Choose the right document type")}}</li>
|
||||||
|
<li>{{ _("Set appropriate visibility levels")}}</li>
|
||||||
|
<li>{{ _("Use tags for easy searching")}}</li>
|
||||||
|
<li>{{ _("Upload new versions when updating")}}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Document Info (if editing) -->
|
||||||
|
{% if document %}
|
||||||
|
<div class="alert alert-secondary">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="bi bi-file-earmark me-2"></i>{{ _("Current Document")}}
|
||||||
|
</h6>
|
||||||
|
<ul class="mb-0 small">
|
||||||
|
<li><strong>{{ _("Created:") }}</strong> {{ document.created_at|date:"M d, Y H:i" }}</li>
|
||||||
|
<li><strong>{{ _("Updated:") }}</strong> {{ document.updated_at|date:"M d, Y H:i" }}</li>
|
||||||
|
<li><strong>{{ _("Version:") }}</strong> {{ document.version }}</li>
|
||||||
|
<li><strong>{{ _("File Size:") }}</strong> {{ document.file_size|filesizeformat }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>
|
||||||
|
{% if document %}{{ _("Update Document")}}{% else %}{{ _("Upload Document")}}{% endif %}
|
||||||
|
</button>
|
||||||
|
{% if folder %}
|
||||||
|
<a href="{% url 'references:folder_view' folder.id %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-circle me-2"></i>{{ _("Cancel")}}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'references:dashboard' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-circle me-2"></i>{{ _("Cancel")}}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('documentForm');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const fileUploadWrapper = document.querySelector('.file-upload-wrapper');
|
||||||
|
|
||||||
|
// Drag and drop functionality
|
||||||
|
fileUploadWrapper.addEventListener('dragover', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.style.borderColor = '#667eea';
|
||||||
|
this.style.backgroundColor = '#e7f3ff';
|
||||||
|
});
|
||||||
|
|
||||||
|
fileUploadWrapper.addEventListener('dragleave', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.style.borderColor = '#dee2e6';
|
||||||
|
this.style.backgroundColor = '#fff';
|
||||||
|
});
|
||||||
|
|
||||||
|
fileUploadWrapper.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.style.borderColor = '#dee2e6';
|
||||||
|
this.style.backgroundColor = '#fff';
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
fileInput.files = files;
|
||||||
|
updateFileName(files[0].name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle file selection via click
|
||||||
|
fileInput.addEventListener('change', function(e) {
|
||||||
|
if (this.files.length > 0) {
|
||||||
|
updateFileName(this.files[0].name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateFileName(fileName) {
|
||||||
|
const icon = fileUploadWrapper.querySelector('i');
|
||||||
|
const p = fileUploadWrapper.querySelector('p');
|
||||||
|
|
||||||
|
icon.className = 'bi bi-file-earmark-check display-4 text-success mb-3 d-block';
|
||||||
|
p.innerHTML = `<strong>${fileName}</strong><br><span class="text-muted">{% trans "File selected" %}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form validation
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
form.classList.add('was-validated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
213
templates/references/document_view.html
Normal file
213
templates/references/document_view.html
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{{ document.title }} - {% trans "Reference Section" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-1">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{% url 'references:dashboard' %}">{% trans "Reference Section" %}</a>
|
||||||
|
</li>
|
||||||
|
{% if document.folder %}
|
||||||
|
{% for folder in document.folder.get_full_path_folders %}
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{% url 'references:folder_view' folder.id %}">{{ folder.name }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
<li class="breadcrumb-item active">{{ document.title }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<h1 class="h3 mb-0">
|
||||||
|
<i class="{{ document.get_file_icon }} me-2"></i>
|
||||||
|
{{ document.title }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted small mb-0">{{ document.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% url 'references:document_edit' document.id %}" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-edit me-2"></i>{% trans "Edit" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'references:api_document_download' document.id %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-download me-2"></i>{% trans "Download" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Document Details -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% trans "Document Details" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-sm-4">{% trans "File Name" %}</dt>
|
||||||
|
<dd class="col-sm-8">{{ document.filename }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">{% trans "File Type" %}</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
<span class="badge bg-light text-dark">{{ document.file_type|upper }}</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">{% trans "File Size" %}</dt>
|
||||||
|
<dd class="col-sm-8">{{ document.get_file_size_display }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">{% trans "Version" %}</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
{% if document.is_latest_version %}
|
||||||
|
<span class="badge bg-success">{{ document.version }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ document.version }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">{% trans "Downloads" %}</dt>
|
||||||
|
<dd class="col-sm-8">{{ document.download_count }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">{% trans "Uploaded By" %}</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
{% if document.uploaded_by %}
|
||||||
|
{{ document.uploaded_by.get_full_name }}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Unknown" %}
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">{% trans "Created" %}</dt>
|
||||||
|
<dd class="col-sm-8">{{ document.created_at|date:"M d, Y H:i" }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">{% trans "Last Modified" %}</dt>
|
||||||
|
<dd class="col-sm-8">{{ document.updated_at|date:"M d, Y H:i" }}</dd>
|
||||||
|
|
||||||
|
{% if document.tags %}
|
||||||
|
<dt class="col-sm-4">{% trans "Tags" %}</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
{% for tag in document.tags_list %}
|
||||||
|
<span class="badge bg-info text-white me-1">{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version History -->
|
||||||
|
{% if versions %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% trans "Version History" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Version" %}</th>
|
||||||
|
<th>{% trans "File" %}</th>
|
||||||
|
<th>{% trans "Size" %}</th>
|
||||||
|
<th>{% trans "Created" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for version in versions %}
|
||||||
|
<tr{% if version.is_latest_version %} class="table-light"{% endif %}>
|
||||||
|
<td>
|
||||||
|
{% if version.is_latest_version %}
|
||||||
|
<span class="badge bg-success">{{ version.version }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ version.version }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ version.filename }}</td>
|
||||||
|
<td>{{ version.get_file_size_display }}</td>
|
||||||
|
<td>{{ version.created_at|date:"M d, Y H:i" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% trans "Actions" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="{% url 'references:api_document_download' document.id %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-download me-2"></i>{% trans "Download Document" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'references:document_edit' document.id %}" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-edit me-2"></i>{% trans "Edit Metadata" %}
|
||||||
|
</a>
|
||||||
|
{% if document.folder %}
|
||||||
|
<a href="{% url 'references:document_create_in_folder' folder_pk=document.folder.id %}" class="btn btn-outline-success">
|
||||||
|
<i class="fas fa-upload me-2"></i>{% trans "Upload New Version" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-outline-danger" onclick="confirmDelete()">
|
||||||
|
<i class="fas fa-trash me-2"></i>{% trans "Delete Document" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
{% if document.description %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% trans "Description" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-0">{{ document.description|linebreaks }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Related Documents -->
|
||||||
|
{% if related_documents %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% trans "Related Documents" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for related in related_documents %}
|
||||||
|
<a href="{% url 'references:document_view' related.id %}" class="list-group-item list-group-item-action text-decoration-none">
|
||||||
|
<i class="{{ related.get_file_icon }} me-2"></i>
|
||||||
|
{{ related.title|truncatechars:30 }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="deleteForm" method="post" action="{% url 'references:document_delete' document.id %}" style="display: none;">
|
||||||
|
{% csrf_token %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function confirmDelete() {
|
||||||
|
if (confirm('{% trans "Are you sure you want to delete this document?" %}')) {
|
||||||
|
document.getElementById('deleteForm').submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
256
templates/references/folder_form.html
Normal file
256
templates/references/folder_form.html
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{% if folder %}{{ _("Edit Folder") }}{% else %}{{ _("New Folder") }}{% endif %} - PX360{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.form-section {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 25px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.form-section-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #667eea;
|
||||||
|
}
|
||||||
|
.required-field::after {
|
||||||
|
content: " *";
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="mb-4">
|
||||||
|
{% if folder %}
|
||||||
|
<a href="{% url 'references:folder_view' folder.id %}" class="btn btn-outline-secondary btn-sm mb-3">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i> {{ _("Back to Folder")}}
|
||||||
|
</a>
|
||||||
|
{% elif parent %}
|
||||||
|
<a href="{% url 'references:folder_view' parent.id %}" class="btn btn-outline-secondary btn-sm mb-3">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i> {{ _("Back to Folder")}}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'references:dashboard' %}" class="btn btn-outline-secondary btn-sm mb-3">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i> {{ _("Back to Dashboard")}}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<h2 class="mb-1">
|
||||||
|
{% if folder %}
|
||||||
|
<i class="bi bi-pencil-square text-primary me-2"></i>{{ _("Edit Folder")}}
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-folder-plus text-primary me-2"></i>{{ _("New Folder")}}
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
{% if folder %}
|
||||||
|
{{ _("Update folder information")}}
|
||||||
|
{% else %}
|
||||||
|
{{ _("Create a new folder to organize your documents")}}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" id="folderForm">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<!-- Folder Details -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h5 class="form-section-title">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>{{ _("Folder Information")}}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label required-field">{% trans "Name (English)" %}</label>
|
||||||
|
<input type="text" name="name" class="form-control"
|
||||||
|
value="{% if folder %}{{ folder.name }}{% endif %}"
|
||||||
|
placeholder="{% trans 'Enter folder name in English' %}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{% trans "Name (Arabic)" %}</label>
|
||||||
|
<input type="text" name="name_ar" class="form-control"
|
||||||
|
value="{% if folder %}{{ folder.name_ar }}{% endif %}"
|
||||||
|
placeholder="{% trans 'Enter folder name in Arabic (optional)' %}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{% trans "Description (English)" %}</label>
|
||||||
|
<textarea name="description" class="form-control" rows="4"
|
||||||
|
placeholder="{% trans 'Optional description of this folder' %}">{% if folder %}{{ folder.description }}{% endif %}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{% trans "Description (Arabic)" %}</label>
|
||||||
|
<textarea name="description_ar" class="form-control" rows="4"
|
||||||
|
placeholder="{% trans 'Optional Arabic description' %}">{% if folder %}{{ folder.description_ar }}{% endif %}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{% trans "Parent Folder" %}</label>
|
||||||
|
<select name="parent" class="form-select">
|
||||||
|
<option value="">{{ _("Root Level (No Parent)")}}</option>
|
||||||
|
{% for folder_option in all_folders %}
|
||||||
|
{% if folder_option.id != folder.id %}
|
||||||
|
<option value="{{ folder_option.id }}"
|
||||||
|
{% if folder and folder.parent_id == folder_option.id %}selected{% endif %}
|
||||||
|
{% if parent and parent.id == folder_option.id %}selected{% endif %}>
|
||||||
|
{{ folder_option.level_display }}{{ folder_option.name }}
|
||||||
|
{% if folder_option.name_ar %}/ {{ folder_option.name_ar }}{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">{{ _("Leave empty to create at root level")}}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">{% trans "Icon" %}</label>
|
||||||
|
<input type="text" name="icon" class="form-control"
|
||||||
|
value="{% if folder %}{{ folder.icon }}{% endif %}"
|
||||||
|
placeholder="{% trans 'e.g., fa-folder, fa-file-pdf' %}">
|
||||||
|
<small class="form-text text-muted">{{ _("FontAwesome icon class")}}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">{% trans "Color" %}</label>
|
||||||
|
<input type="text" name="color" class="form-control"
|
||||||
|
value="{% if folder %}{{ folder.color }}{% endif %}"
|
||||||
|
placeholder="{% trans 'e.g., #007bff' %}">
|
||||||
|
<small class="form-text text-muted">{{ _("Hex color code")}}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">{% trans "Order" %}</label>
|
||||||
|
<input type="number" name="order" class="form-control"
|
||||||
|
value="{% if folder %}{{ folder.order }}{% else %}0{% endif %}"
|
||||||
|
min="0">
|
||||||
|
<small class="form-text text-muted">{{ _("Display order")}}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Access Control -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h5 class="form-section-title">
|
||||||
|
<i class="bi bi-shield-lock me-2"></i>{{ _("Access Control")}}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{% trans "Access Roles" %}</label>
|
||||||
|
<div class="border p-3 rounded" style="max-height: 200px; overflow-y: auto;">
|
||||||
|
{% for choice in form.access_roles %}
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
{{ choice.tag }}
|
||||||
|
<label class="form-check-label" for="{{ choice.id_for_label }}">
|
||||||
|
{{ choice.choice_label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{{ _("Leave all unchecked to make folder accessible to all users")}}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" id="isActiveCheck"
|
||||||
|
{% if not folder or folder.is_active %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="isActiveCheck">
|
||||||
|
{% trans "Active" %}
|
||||||
|
</label>
|
||||||
|
<small class="form-text text-muted d-block">
|
||||||
|
{{ _("Uncheck to hide this folder")}}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Information -->
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>{{ _("Folder Organization")}}
|
||||||
|
</h6>
|
||||||
|
<p class="mb-0 small">
|
||||||
|
{{ _("Create folders to organize documents by category, department, or any structure that works for your team.")}}
|
||||||
|
</p>
|
||||||
|
<hr class="my-2">
|
||||||
|
<ul class="mb-0 mt-2 small">
|
||||||
|
<li>{{ _("Use meaningful names in both languages")}}</li>
|
||||||
|
<li>{{ _("Add descriptions for clarity")}}</li>
|
||||||
|
<li>{{ _("Set access roles to control visibility")}}</li>
|
||||||
|
<li>{{ _("Use icons and colors for visual organization")}}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Folder Info (if editing) -->
|
||||||
|
{% if folder %}
|
||||||
|
<div class="alert alert-secondary">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="bi bi-folder me-2"></i>{{ _("Current Folder")}}
|
||||||
|
</h6>
|
||||||
|
<ul class="mb-0 small">
|
||||||
|
<li><strong>{{ _("Created:") }} </strong>{{ folder.created_at|date:"M d, Y" }}</li>
|
||||||
|
<li><strong>{{ _("Last Modified:") }} </strong>{{ folder.updated_at|date:"M d, Y" }}</li>
|
||||||
|
<li><strong>{{ _("Documents:") }} </strong>{{ folder.documents.count }}</li>
|
||||||
|
<li><strong>{{ _("Subfolders:") }} </strong>{{ folder.subfolders.count }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>
|
||||||
|
{% if folder %}{{ _("Update Folder")}}{% else %}{{ _("Create Folder")}}{% endif %}
|
||||||
|
</button>
|
||||||
|
{% if folder %}
|
||||||
|
<a href="{% url 'references:folder_view' folder.id %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-circle me-2"></i>{{ _("Cancel")}}
|
||||||
|
</a>
|
||||||
|
{% elif parent %}
|
||||||
|
<a href="{% url 'references:folder_view' parent.id %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-circle me-2"></i>{{ _("Cancel")}}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'references:dashboard' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-circle me-2"></i>{{ _("Cancel")}}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('folderForm');
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
form.classList.add('was-validated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
176
templates/references/folder_view.html
Normal file
176
templates/references/folder_view.html
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% if current_folder %}{{ current_folder.name }}{% else %}{% trans "Folders" %}{% endif %} - {% trans "Reference Section" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-1">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{% url 'references:dashboard' %}">{% trans "Reference Section" %}</a>
|
||||||
|
</li>
|
||||||
|
{% for item in breadcrumb %}
|
||||||
|
<li class="breadcrumb-item{% if forloop.last %} active{% endif %}">
|
||||||
|
{% if not forloop.last %}
|
||||||
|
<a href="{% url 'references:folder_view' item.id %}">{{ item.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ item.name }}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{% if current_folder %}
|
||||||
|
<h1 class="h3 mb-0">{{ current_folder.name }}</h1>
|
||||||
|
<p class="text-muted small mb-0">{{ current_folder.description }}</p>
|
||||||
|
{% else %}
|
||||||
|
<h1 class="h3 mb-0">{% trans "Folders" %}</h1>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% url 'references:search' %}" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-search me-2"></i>{% trans "Search" %}
|
||||||
|
</a>
|
||||||
|
{% if current_folder %}
|
||||||
|
<a href="{% url 'references:folder_create_in_parent' parent_pk=current_folder.id %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-folder-plus me-2"></i>{% trans "New Folder" %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'references:folder_create' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-folder-plus me-2"></i>{% trans "New Folder" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_folder %}
|
||||||
|
<a href="{% url 'references:document_create_in_folder' folder_pk=current_folder.id %}" class="btn btn-success">
|
||||||
|
<i class="fas fa-upload me-2"></i>{% trans "Upload Document" %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'references:document_create' %}" class="btn btn-success">
|
||||||
|
<i class="fas fa-upload me-2"></i>{% trans "Upload Document" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subfolders -->
|
||||||
|
{% if subfolders %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% trans "Folders" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
{% for folder in subfolders %}
|
||||||
|
<div class="col-md-4 col-lg-3 mb-3">
|
||||||
|
<a href="{% url 'references:folder_view' folder.id %}" class="card h-100 text-decoration-none text-dark folder-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{% if folder.icon %}
|
||||||
|
<i class="{{ folder.icon }} fa-2x text-primary"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-folder fa-2x text-primary"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<h6 class="mb-0">{{ folder.name }}</h6>
|
||||||
|
<small class="text-muted">{{ folder.document_count }} {% trans "documents" %}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if folder.description %}
|
||||||
|
<p class="small text-muted mb-0">{{ folder.description|truncatewords:8 }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Documents -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% trans "Documents" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if documents %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Name" %}</th>
|
||||||
|
<th>{% trans "Type" %}</th>
|
||||||
|
<th>{% trans "Size" %}</th>
|
||||||
|
<th>{% trans "Version" %}</th>
|
||||||
|
<th>{% trans "Downloads" %}</th>
|
||||||
|
<th>{% trans "Modified" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for document in documents %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'references:document_view' document.id %}" class="text-decoration-none">
|
||||||
|
<i class="{{ document.get_file_icon }} me-2"></i>
|
||||||
|
{{ document.title }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-light text-dark">{{ document.file_type|upper }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ document.get_file_size_display }}</td>
|
||||||
|
<td>
|
||||||
|
{% if document.is_latest_version %}
|
||||||
|
<span class="badge bg-success">{{ document.version }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ document.version }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ document.download_count }}</td>
|
||||||
|
<td>{{ document.updated_at|date:"M d, Y" }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="{% url 'references:api_document_download' document.id %}" class="btn btn-outline-primary" title="{% trans "Download" %}">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fas fa-file-alt fa-3x mb-3 text-muted"></i>
|
||||||
|
<p class="text-muted mb-3">{% trans "No documents in this folder" %}</p>
|
||||||
|
{% if current_folder %}
|
||||||
|
<a href="{% url 'references:document_create_in_folder' folder_pk=current_folder.id %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-upload me-2"></i>{% trans "Upload Document" %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'references:document_create' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-upload me-2"></i>{% trans "Upload Document" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.folder-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
195
templates/references/search.html
Normal file
195
templates/references/search.html
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Search Documents" %} - {% trans "Reference Section" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-1">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="{% url 'references:dashboard' %}">{% trans "Reference Section" %}</a>
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item active">{% trans "Search" %}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<h1 class="h3 mb-0">{% trans "Search Documents" %}</h1>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'references:dashboard' %}" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Dashboard" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Form -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% trans "Filter Documents" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="{{ form.search.id_for_label }}" class="form-label">{{ form.search.label }}</label>
|
||||||
|
{{ form.search }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="{{ form.folder.id_for_label }}" class="form-label">{{ form.folder.label }}</label>
|
||||||
|
{{ form.folder }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label for="{{ form.file_type.id_for_label }}" class="form-label">{{ form.file_type.label }}</label>
|
||||||
|
{{ form.file_type }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="{{ form.tags.id_for_label }}" class="form-label">{{ form.tags.label }}</label>
|
||||||
|
{{ form.tags }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-search me-2"></i>{% trans "Search" %}
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'references:search' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-times me-2"></i>{% trans "Clear" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
{% trans "Results" %}
|
||||||
|
{% if page_obj %}
|
||||||
|
<span class="badge bg-primary ms-2">{{ page_obj.paginator.count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if documents %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Name" %}</th>
|
||||||
|
<th>{% trans "Folder" %}</th>
|
||||||
|
<th>{% trans "Type" %}</th>
|
||||||
|
<th>{% trans "Size" %}</th>
|
||||||
|
<th>{% trans "Version" %}</th>
|
||||||
|
<th>{% trans "Downloads" %}</th>
|
||||||
|
<th>{% trans "Modified" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for document in documents %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'references:document_view' document.id %}" class="text-decoration-none">
|
||||||
|
<i class="{{ document.get_file_icon }} me-2"></i>
|
||||||
|
{{ document.title }}
|
||||||
|
</a>
|
||||||
|
{% if document.description %}
|
||||||
|
<br><small class="text-muted">{{ document.description|truncatechars:50 }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if document.folder %}
|
||||||
|
<a href="{% url 'references:folder_view' document.folder.id %}" class="text-decoration-none">
|
||||||
|
{{ document.folder.name }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-light text-dark">{{ document.file_type|upper }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ document.get_file_size_display }}</td>
|
||||||
|
<td>
|
||||||
|
{% if document.is_latest_version %}
|
||||||
|
<span class="badge bg-success">{{ document.version }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{{ document.version }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ document.download_count }}</td>
|
||||||
|
<td>{{ document.updated_at|date:"M d, Y" }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<a href="{% url 'references:api_document_download' document.id %}" class="btn btn-outline-primary" title="{% trans "Download" %}">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'references:document_view' document.id %}" class="btn btn-outline-secondary" title="{% trans "View" %}">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if page_obj.has_other_pages %}
|
||||||
|
<nav aria-label="Page navigation" class="mt-3">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page=1{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
|
||||||
|
{% trans "First" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
|
||||||
|
{% trans "Previous" %}
|
||||||
|
</a>
|
||||||
|
</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 }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">{{ 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 }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
|
||||||
|
{% trans "Next" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">
|
||||||
|
{% trans "Last" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fas fa-search fa-3x mb-3 text-muted"></i>
|
||||||
|
<p class="text-muted mb-3">{% trans "No documents found matching your search criteria" %}</p>
|
||||||
|
<a href="{% url 'references:search' %}" class="btn btn-outline-primary">
|
||||||
|
<i class="fas fa-times me-2"></i>{% trans "Clear Filters" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
142
templates/standards/attachment_upload.html
Normal file
142
templates/standards/attachment_upload.html
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
{% extends 'layouts/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Upload Evidence" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{% trans "Upload Evidence Attachment" %}</h1>
|
||||||
|
<p class="text-muted mb-0">{{ compliance.standard.code }} - {{ compliance.standard.title }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'standards:standard_compliance_update' compliance_id=compliance.id %}"
|
||||||
|
class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Assessment" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Upload New Attachment" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.file.id_for_label }}" class="form-label">
|
||||||
|
{% trans "File" %}
|
||||||
|
</label>
|
||||||
|
{{ form.file }}
|
||||||
|
{% if form.file.errors %}
|
||||||
|
<div class="text-danger">{{ form.file.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{% trans "Accepted formats: PDF, DOC, DOCX, XLS, XLSX, JPG, PNG" %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.description.id_for_label }}" class="form-label">
|
||||||
|
{% trans "Description" %}
|
||||||
|
</label>
|
||||||
|
{{ form.description }}
|
||||||
|
{% if form.description.errors %}
|
||||||
|
<div class="text-danger">{{ form.description.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-upload me-2"></i>{% trans "Upload" %}
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'standards:standard_compliance_update' compliance_id=compliance.id %}"
|
||||||
|
class="btn btn-outline-secondary ms-2">
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Compliance Details" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tr>
|
||||||
|
<th width="40%">{% trans "Standard" %}</th>
|
||||||
|
<td>{{ compliance.standard.code }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Title" %}</th>
|
||||||
|
<td>{{ compliance.standard.title }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Department" %}</th>
|
||||||
|
<td>{{ compliance.department.name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if compliance.status == 'met' %}bg-success{% elif compliance.status == 'partially_met' %}bg-warning{% elif compliance.status == 'not_met' %}bg-danger{% else %}bg-secondary{% endif %}">
|
||||||
|
{{ compliance.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Attachments -->
|
||||||
|
{% if compliance.attachments.all %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Existing Attachments" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "File Name" %}</th>
|
||||||
|
<th>{% trans "Description" %}</th>
|
||||||
|
<th>{% trans "Uploaded By" %}</th>
|
||||||
|
<th>{% trans "Date" %}</th>
|
||||||
|
<th>{% trans "Actions" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for attachment in compliance.attachments.all %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i class="fas fa-file me-2"></i>{{ attachment.filename }}
|
||||||
|
</td>
|
||||||
|
<td>{{ attachment.description|default:"-" }}</td>
|
||||||
|
<td>{{ attachment.uploaded_by.get_full_name }}</td>
|
||||||
|
<td>{{ attachment.created_at|date:"Y-m-d H:i" }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ attachment.file.url }}" target="_blank"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'standards:attachment_delete' pk=attachment.id %}"
|
||||||
|
class="btn btn-sm btn-outline-danger">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
164
templates/standards/compliance_form.html
Normal file
164
templates/standards/compliance_form.html
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
{% extends 'layouts/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Update Compliance" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{% trans "Update Compliance Assessment" %}</h1>
|
||||||
|
<p class="text-muted mb-0">{{ compliance.standard.code }} - {{ compliance.standard.title }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'standards:department_standards' pk=compliance.department.id %}"
|
||||||
|
class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Standards" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<!-- Standard Details -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Standard Details" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tr>
|
||||||
|
<th width="30%">{% trans "Code" %}</th>
|
||||||
|
<td>{{ compliance.standard.code }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Title" %}</th>
|
||||||
|
<td>{{ compliance.standard.title }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Source" %}</th>
|
||||||
|
<td>{{ compliance.standard.source.name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Category" %}</th>
|
||||||
|
<td>{{ compliance.standard.category.name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Description" %}</th>
|
||||||
|
<td>{{ compliance.standard.description|linebreaks }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compliance Form -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Compliance Assessment" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.status.id_for_label }}" class="form-label">
|
||||||
|
{% trans "Status" %}
|
||||||
|
</label>
|
||||||
|
{{ form.status }}
|
||||||
|
{% if form.status.errors %}
|
||||||
|
<div class="text-danger">{{ form.status.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.last_assessed_date.id_for_label }}" class="form-label">
|
||||||
|
{% trans "Last Assessed Date" %}
|
||||||
|
</label>
|
||||||
|
{{ form.last_assessed_date }}
|
||||||
|
{% if form.last_assessed_date.errors %}
|
||||||
|
<div class="text-danger">{{ form.last_assessed_date.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.assessor.id_for_label }}" class="form-label">
|
||||||
|
{% trans "Assessor" %}
|
||||||
|
</label>
|
||||||
|
{{ form.assessor }}
|
||||||
|
{% if form.assessor.errors %}
|
||||||
|
<div class="text-danger">{{ form.assessor.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.notes.id_for_label }}" class="form-label">
|
||||||
|
{% trans "Assessment Notes" %}
|
||||||
|
</label>
|
||||||
|
{{ form.notes }}
|
||||||
|
{% if form.notes.errors %}
|
||||||
|
<div class="text-danger">{{ form.notes.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.evidence_summary.id_for_label }}" class="form-label">
|
||||||
|
{% trans "Evidence Summary" %}
|
||||||
|
</label>
|
||||||
|
{{ form.evidence_summary }}
|
||||||
|
{% if form.evidence_summary.errors %}
|
||||||
|
<div class="text-danger">{{ form.evidence_summary.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save me-2"></i>{% trans "Save Assessment" %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Department Info -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Department" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p><strong>{{ compliance.department.name }}</strong></p>
|
||||||
|
{% if compliance.department.hospital %}
|
||||||
|
<p class="text-muted mb-0">{{ compliance.department.hospital.name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Evidence Attachments -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Evidence Attachments" %}</h5>
|
||||||
|
<a href="{% url 'standards:attachment_upload' compliance_id=compliance.id %}"
|
||||||
|
class="btn btn-sm btn-success">
|
||||||
|
<i class="fas fa-upload me-1"></i>{% trans "Upload" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if compliance.attachments.all %}
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for attachment in compliance.attachments.all %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<i class="fas fa-file me-2"></i>
|
||||||
|
{{ attachment.filename }}
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ attachment.uploaded_at|date:"Y-m-d" }}
|
||||||
|
</small>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center mb-0">{% trans "No attachments" %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
156
templates/standards/dashboard.html
Normal file
156
templates/standards/dashboard.html
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
{% extends 'layouts/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Standards Compliance Dashboard" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{% trans "Standards Compliance Dashboard" %}</h1>
|
||||||
|
<p class="text-muted mb-0">{{ hospital.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'standards:search' %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-search me-2"></i>{% trans "Search Standards" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card bg-info text-white h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">{% trans "Total Standards" %}</h6>
|
||||||
|
<h2 class="mb-0">{{ stats.total_standards }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card bg-success text-white h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">{% trans "Met" %}</h6>
|
||||||
|
<h2 class="mb-0">{{ stats.met }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card bg-warning text-dark h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">{% trans "Partially Met" %}</h6>
|
||||||
|
<h2 class="mb-0">{{ stats.partially_met }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card bg-danger text-white h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">{% trans "Not Met" %}</h6>
|
||||||
|
<h2 class="mb-0">{{ stats.not_met }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card bg-secondary text-white h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">{% trans "Not Assessed" %}</h6>
|
||||||
|
<h2 class="mb-0">{{ stats.not_assessed }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card bg-primary text-white h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">{% trans "Departments" %}</h6>
|
||||||
|
<h2 class="mb-0">{{ stats.total_departments }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Departments List -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Departments" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Department" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th>{% trans "Actions" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for department in departments %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ department.name }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-info">
|
||||||
|
<i class="fas fa-folder-open me-1"></i>
|
||||||
|
{% trans "View Standards" %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'standards:department_standards' pk=department.id %}"
|
||||||
|
class="btn btn-sm btn-primary">
|
||||||
|
<i class="fas fa-list me-1"></i>{% trans "View" %}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center text-muted">
|
||||||
|
{% trans "No departments found" %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Updates -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Recent Compliance Updates" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if recent_updates %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Standard" %}</th>
|
||||||
|
<th>{% trans "Department" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th>{% trans "Last Updated" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for update in recent_updates %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ update.standard.code }}</td>
|
||||||
|
<td>{{ update.department.name }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if update.status == 'met' %}bg-success{% elif update.status == 'partially_met' %}bg-warning{% elif update.status == 'not_met' %}bg-danger{% else %}bg-secondary{% endif %}">
|
||||||
|
{{ update.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ update.updated_at|date:"Y-m-d H:i" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center">{% trans "No recent updates" %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
296
templates/standards/department_standards.html
Normal file
296
templates/standards/department_standards.html
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
{% extends 'layouts/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load standards_filters %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Department Standards" %} - {{ department.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{{ department.name }}</h1>
|
||||||
|
<p class="text-muted mb-0">{% trans "Standards Compliance" %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if is_px_admin %}
|
||||||
|
<a href="{% url 'standards:standard_create' department_id=department.id %}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-plus me-2"></i>{% trans "Add Standard" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'standards:dashboard' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Dashboard" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<input type="text" class="form-control" id="searchInput"
|
||||||
|
placeholder="{% trans 'Search standards...' %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Standards Table -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Standards List" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover" id="standardsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Code" %}</th>
|
||||||
|
<th>{% trans "Title" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th>{% trans "Evidence" %}</th>
|
||||||
|
<th>{% trans "Actions" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in standards_data %}
|
||||||
|
<tr data-standard-id="{{ item.standard.id }}"
|
||||||
|
data-compliance-id="{% if item.compliance %}{{ item.compliance.id }}{% endif %}">
|
||||||
|
<td><strong>{{ item.standard.code }}</strong></td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'standards:standard_detail' pk=item.standard.id %}">
|
||||||
|
{{ item.standard.title }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.compliance %}
|
||||||
|
<span class="badge {% if item.compliance.status == 'met' %}bg-success{% elif item.compliance.status == 'partially_met' %}bg-warning{% elif item.compliance.status == 'not_met' %}bg-danger{% else %}bg-secondary{% endif %}">
|
||||||
|
{{ item.compliance.get_status_display }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">{% trans "Not Assessed" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-info">
|
||||||
|
<i class="fas fa-paperclip me-1"></i>
|
||||||
|
{{ item.attachment_count }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.compliance %}
|
||||||
|
<button class="btn btn-sm btn-primary"
|
||||||
|
onclick="openAssessModal('{{ item.compliance.id }}')">
|
||||||
|
<i class="fas fa-edit me-1"></i>{% trans "Assess" %}
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-sm btn-success"
|
||||||
|
onclick="createAndAssess('{{ item.standard.id }}')">
|
||||||
|
<i class="fas fa-plus me-1"></i>{% trans "Assess" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center text-muted">
|
||||||
|
{% trans "No standards found" %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compliance Assessment Modal -->
|
||||||
|
<div class="modal fade" id="assessmentModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{% trans "Compliance Assessment" %}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form id="assessmentForm">
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="complianceId" name="compliance_id">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="status" class="form-label">
|
||||||
|
{% trans "Compliance Status" %} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="status" name="status" required>
|
||||||
|
<option value="not_assessed">{% trans "Not Assessed" %}</option>
|
||||||
|
<option value="met">{% trans "Met" %}</option>
|
||||||
|
<option value="partially_met">{% trans "Partially Met" %}</option>
|
||||||
|
<option value="not_met">{% trans "Not Met" %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="last_assessed_date" class="form-label">
|
||||||
|
{% trans "Assessment Date" %}
|
||||||
|
</label>
|
||||||
|
<input type="date" class="form-control" id="last_assessed_date" name="last_assessed_date">
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{% trans "Auto-filled with today's date" %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="assessor" class="form-label">{% trans "Assessor" %}</label>
|
||||||
|
<input type="text" class="form-control" id="assessor" name="assessor" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="notes" class="form-label">{% trans "Notes" %}</label>
|
||||||
|
<textarea class="form-control" id="notes" name="notes" rows="3"
|
||||||
|
placeholder="{% trans 'Add any notes about the assessment...' %}"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="evidence_summary" class="form-label">{% trans "Evidence Summary" %}</label>
|
||||||
|
<textarea class="form-control" id="evidence_summary" name="evidence_summary" rows="3"
|
||||||
|
placeholder="{% trans 'Summarize the evidence supporting this assessment...' %}"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save me-2"></i>{% trans "Save Assessment" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let modalInstance = null;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initialize Bootstrap modal
|
||||||
|
const modalElement = document.getElementById('assessmentModal');
|
||||||
|
if (modalElement) {
|
||||||
|
modalInstance = new bootstrap.Modal(modalElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set today's date
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
document.getElementById('last_assessed_date').value = today;
|
||||||
|
document.getElementById('assessor').value = '{{ user.get_full_name|default:user.username }}';
|
||||||
|
|
||||||
|
// Filter functionality
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
const table = document.getElementById('standardsTable');
|
||||||
|
|
||||||
|
function filterTable() {
|
||||||
|
const searchText = searchInput.value.toLowerCase();
|
||||||
|
const rows = table.querySelectorAll('tbody tr');
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const text = row.textContent.toLowerCase();
|
||||||
|
const matchesSearch = text.includes(searchText);
|
||||||
|
|
||||||
|
if (matchesSearch) {
|
||||||
|
row.style.display = '';
|
||||||
|
} else {
|
||||||
|
row.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', filterTable);
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
const form = document.getElementById('assessmentForm');
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitAssessment();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
let cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie !== '') {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i].trim();
|
||||||
|
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAndAssess(standardId) {
|
||||||
|
// Create compliance record first
|
||||||
|
fetch(`/standards/api/compliance/create/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCookie('csrftoken'),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
standard_id: standardId,
|
||||||
|
department_id: '{{ department.id }}'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Open modal with new compliance ID
|
||||||
|
openAssessModal(data.compliance_id);
|
||||||
|
} else {
|
||||||
|
alert('{% trans "Error creating compliance record" %}: ' + (data.error || ''));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('{% trans "Error creating compliance record" %}: ' + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAssessModal(complianceId) {
|
||||||
|
document.getElementById('complianceId').value = complianceId;
|
||||||
|
modalInstance.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitAssessment() {
|
||||||
|
const form = document.getElementById('assessmentForm');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
fetch(`/standards/api/compliance/update/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCookie('csrftoken'),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
compliance_id: formData.get('compliance_id'),
|
||||||
|
status: formData.get('status'),
|
||||||
|
notes: formData.get('notes'),
|
||||||
|
evidence_summary: formData.get('evidence_summary'),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
modalInstance.hide();
|
||||||
|
// Reload page to show updated status
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('{% trans "Error updating compliance" %}: ' + (data.error || ''));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('{% trans "Error updating compliance" %}: ' + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
129
templates/standards/search.html
Normal file
129
templates/standards/search.html
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
{% extends 'layouts/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Search Standards" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{% trans "Search Standards" %}</h1>
|
||||||
|
<p class="text-muted mb-0">{{ hospital.name }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'standards:dashboard' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Dashboard" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Form -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="q" class="form-label">{% trans "Search" %}</label>
|
||||||
|
<input type="text" class="form-control" name="q" id="q"
|
||||||
|
value="{{ query }}" placeholder="{% trans 'Search by code, title, or description...' %}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="source" class="form-label">{% trans "Source" %}</label>
|
||||||
|
<select class="form-select" name="source" id="source">
|
||||||
|
<option value="">{% trans "All Sources" %}</option>
|
||||||
|
{% for source in sources %}
|
||||||
|
<option value="{{ source.id }}" {% if source_filter == source.id|stringformat:"s" %}selected{% endif %}>
|
||||||
|
{{ source.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="category" class="form-label">{% trans "Category" %}</label>
|
||||||
|
<select class="form-select" name="category" id="category">
|
||||||
|
<option value="">{% trans "All Categories" %}</option>
|
||||||
|
{% for category in categories %}
|
||||||
|
<option value="{{ category.id }}" {% if category_filter == category.id|stringformat:"s" %}selected{% endif %}>
|
||||||
|
{{ category.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-search me-2"></i>{% trans "Search" %}
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'standards:search' %}" class="btn btn-outline-secondary ms-2">
|
||||||
|
{% trans "Clear" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
{% if query or source_filter or category_filter %}
|
||||||
|
{% trans "Search Results" %} ({{ standards.count }})
|
||||||
|
{% else %}
|
||||||
|
{% trans "All Standards" %} ({{ standards.count }})
|
||||||
|
{% endif %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if standards %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Code" %}</th>
|
||||||
|
<th>{% trans "Title" %}</th>
|
||||||
|
<th>{% trans "Source" %}</th>
|
||||||
|
<th>{% trans "Category" %}</th>
|
||||||
|
<th>{% trans "Department" %}</th>
|
||||||
|
<th>{% trans "Actions" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for standard in standards %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ standard.code }}</strong></td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'standards:standard_detail' pk=standard.id %}">
|
||||||
|
{{ standard.title }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ standard.source.name }}</td>
|
||||||
|
<td>{{ standard.category.name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if standard.department %}
|
||||||
|
{{ standard.department.name }}
|
||||||
|
{% else %}
|
||||||
|
<em>{% trans "All Departments" %}</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'standards:standard_detail' pk=standard.id %}"
|
||||||
|
class="btn btn-sm btn-primary">
|
||||||
|
<i class="fas fa-eye me-1"></i>{% trans "View" %}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fas fa-search fa-3x text-muted mb-3"></i>
|
||||||
|
<p class="text-muted">{% trans "No standards found" %}</p>
|
||||||
|
{% if query or source_filter or category_filter %}
|
||||||
|
<a href="{% url 'standards:search' %}" class="btn btn-outline-primary">
|
||||||
|
{% trans "Clear Filters" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
170
templates/standards/standard_detail.html
Normal file
170
templates/standards/standard_detail.html
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
{% extends 'layouts/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{{ standard.code }} - {% trans "Standard Details" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{{ standard.code }}</h1>
|
||||||
|
<p class="text-muted mb-0">{{ standard.title }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'standards:dashboard' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Dashboard" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Standard Information -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Standard Information" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tr>
|
||||||
|
<th width="30%">{% trans "Source" %}</th>
|
||||||
|
<td>{{ standard.source.name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Category" %}</th>
|
||||||
|
<td>{{ standard.category.name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Department" %}</th>
|
||||||
|
<td>
|
||||||
|
{% if standard.department %}
|
||||||
|
{{ standard.department.name }}
|
||||||
|
{% else %}
|
||||||
|
<em>{% trans "All Departments" %}</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Effective Date" %}</th>
|
||||||
|
<td>
|
||||||
|
{% if standard.effective_date %}
|
||||||
|
{{ standard.effective_date|date:"Y-m-d" }}
|
||||||
|
{% else %}
|
||||||
|
<em>{% trans "Not specified" %}</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Review Date" %}</th>
|
||||||
|
<td>
|
||||||
|
{% if standard.review_date %}
|
||||||
|
{{ standard.review_date|date:"Y-m-d" }}
|
||||||
|
{% else %}
|
||||||
|
<em>{% trans "Not specified" %}</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Compliance Summary" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-center">
|
||||||
|
<h2>{{ compliance_records.count }}</h2>
|
||||||
|
<p class="text-muted">{% trans "Assessments" %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<span class="badge bg-success me-1">
|
||||||
|
{{ compliance_records|count_by:"status:met" }} {% trans "Met" %}
|
||||||
|
</span>
|
||||||
|
<span class="badge bg-warning me-1">
|
||||||
|
{{ compliance_records|count_by:"status:partially_met" }} {% trans "Partially Met" %}
|
||||||
|
</span>
|
||||||
|
<span class="badge bg-danger me-1">
|
||||||
|
{{ compliance_records|count_by:"status:not_met" }} {% trans "Not Met" %}
|
||||||
|
</span>
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
{{ compliance_records|count_by:"status:not_assessed" }} {% trans "Not Assessed" %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Description" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ standard.description|linebreaks }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compliance Records -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Compliance by Department" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if compliance_records %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Department" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th>{% trans "Last Assessed" %}</th>
|
||||||
|
<th>{% trans "Assessor" %}</th>
|
||||||
|
<th>{% trans "Evidence" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for record in compliance_records %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'standards:department_standards' pk=record.department.id %}">
|
||||||
|
{{ record.department.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if record.status == 'met' %}bg-success{% elif record.status == 'partially_met' %}bg-warning{% elif record.status == 'not_met' %}bg-danger{% else %}bg-secondary{% endif %}">
|
||||||
|
{{ record.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if record.last_assessed_date %}
|
||||||
|
{{ record.last_assessed_date|date:"Y-m-d" }}
|
||||||
|
{% else %}
|
||||||
|
<em>{% trans "Not assessed" %}</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if record.assessor %}
|
||||||
|
{{ record.assessor.get_full_name }}
|
||||||
|
{% else %}
|
||||||
|
<em>-</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-info">
|
||||||
|
<i class="fas fa-paperclip me-1"></i>
|
||||||
|
{{ record.attachments.count }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center">{% trans "No compliance assessments yet" %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
193
templates/standards/standard_form.html
Normal file
193
templates/standards/standard_form.html
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
{% extends 'layouts/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Create Standard" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{% trans "Create New Standard" %}</h1>
|
||||||
|
<p class="text-muted mb-0">{% trans "Add a new compliance standard" %}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if department_id %}
|
||||||
|
<a href="{% url 'standards:department_standards' pk=department_id %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Department Standards" %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'standards:dashboard' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>{% trans "Back to Dashboard" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Standard Information" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.code.id_for_label }}" class="form-label">
|
||||||
|
{{ form.code.label }} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.code }}
|
||||||
|
{% if form.code.help_text %}
|
||||||
|
<small class="form-text text-muted">{{ form.code.help_text }}</small>
|
||||||
|
{% endif %}
|
||||||
|
{% if form.code.errors %}
|
||||||
|
<div class="text-danger">{{ form.code.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.title.id_for_label }}" class="form-label">
|
||||||
|
{{ form.title.label }} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.title }}
|
||||||
|
{% if form.title.errors %}
|
||||||
|
<div class="text-danger">{{ form.title.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.title_ar.id_for_label }}" class="form-label">
|
||||||
|
{{ form.title_ar.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.title_ar }}
|
||||||
|
{% if form.title_ar.errors %}
|
||||||
|
<div class="text-danger">{{ form.title_ar.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.description.id_for_label }}" class="form-label">
|
||||||
|
{{ form.description.label }} <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.description }}
|
||||||
|
{% if form.description.errors %}
|
||||||
|
<div class="text-danger">{{ form.description.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.department.id_for_label }}" class="form-label">
|
||||||
|
{{ form.department.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.department }}
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{% trans "Leave empty to apply to all departments" %}
|
||||||
|
</small>
|
||||||
|
{% if form.department.errors %}
|
||||||
|
<div class="text-danger">{{ form.department.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="{{ form.effective_date.id_for_label }}" class="form-label">
|
||||||
|
{{ form.effective_date.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.effective_date }}
|
||||||
|
{% if form.effective_date.errors %}
|
||||||
|
<div class="text-danger">{{ form.effective_date.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="{{ form.review_date.id_for_label }}" class="form-label">
|
||||||
|
{{ form.review_date.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.review_date }}
|
||||||
|
{% if form.review_date.errors %}
|
||||||
|
<div class="text-danger">{{ form.review_date.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.is_active }}
|
||||||
|
<label for="{{ form.is_active.id_for_label }}" class="form-check-label">
|
||||||
|
{{ form.is_active.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save me-2"></i>{% trans "Create Standard" %}
|
||||||
|
</button>
|
||||||
|
{% if department_id %}
|
||||||
|
<a href="{% url 'standards:department_standards' pk=department_id %}" class="btn btn-secondary">
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'standards:dashboard' %}" class="btn btn-secondary">
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">{% trans "Help" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>{% trans "Standard Code Format" %}</h6>
|
||||||
|
<p class="small text-muted">
|
||||||
|
{% trans "Use a unique code to identify this standard" %}<br>
|
||||||
|
<strong>{% trans "Examples:" %}</strong><br>
|
||||||
|
- STD-001<br>
|
||||||
|
- QM-05<br>
|
||||||
|
- PS-12
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h6 class="mt-3">{% trans "Department Assignment" %}</h6>
|
||||||
|
<p class="small text-muted">
|
||||||
|
{% trans "Leave the department field empty if this standard applies to all departments. Select a specific department only if the standard is department-specific." %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h6 class="mt-3">{% trans "Dates" %}</h6>
|
||||||
|
<p class="small text-muted">
|
||||||
|
{% trans "Effective date: When the standard becomes mandatory" %}<br>
|
||||||
|
{% trans "Review date: When the standard should be reviewed for updates" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initialize form fields with Bootstrap classes
|
||||||
|
const formInputs = document.querySelectorAll('input[type="text"], input[type="date"], select, textarea');
|
||||||
|
formInputs.forEach(input => {
|
||||||
|
if (!input.classList.contains('form-control')) {
|
||||||
|
input.classList.add('form-control');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize checkboxes with Bootstrap classes
|
||||||
|
const checkboxes = document.querySelectorAll('input[type="checkbox"]');
|
||||||
|
checkboxes.forEach(checkbox => {
|
||||||
|
if (!checkbox.classList.contains('form-check-input')) {
|
||||||
|
checkbox.classList.add('form-check-input');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user