diff --git a/FusionIIIT/Fusion/__init__.py b/FusionIIIT/Fusion/__init__.py new file mode 100644 index 000000000..9e0d95fd7 --- /dev/null +++ b/FusionIIIT/Fusion/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) \ No newline at end of file diff --git a/FusionIIIT/Fusion/settings/common.py b/FusionIIIT/Fusion/settings/common.py index bc97f1548..f2dec900e 100644 --- a/FusionIIIT/Fusion/settings/common.py +++ b/FusionIIIT/Fusion/settings/common.py @@ -89,6 +89,14 @@ 'leave-migration-task': { 'task': 'applications.leave.tasks.execute_leave_migrations', 'schedule': crontab(minute='1', hour='0') + }, + 'complaint-auto-escalation-task': { + 'task': 'applications.complaint_system.tasks.escalate_overdue_complaints', + 'schedule': crontab(minute='*/15') + }, + 'complaint-sla-reminder-task': { + 'task': 'applications.complaint_system.tasks.send_sla_deadline_reminders', + 'schedule': crontab(minute='*/30') } } diff --git a/FusionIIIT/applications/central_mess/migrations/0001_initial.py b/FusionIIIT/applications/central_mess/migrations/0001_initial.py index 7e80bedf5..bbac3081f 100644 --- a/FusionIIIT/applications/central_mess/migrations/0001_initial.py +++ b/FusionIIIT/applications/central_mess/migrations/0001_initial.py @@ -149,17 +149,6 @@ class Migration(migrations.Migration): ('student_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='academic_information.student')), ], ), - migrations.CreateModel( - name='Payments', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('amount_paid', models.IntegerField(default=0)), - ('payment_month', models.CharField(default=applications.central_mess.models.current_month, max_length=20)), - ('payment_year', models.IntegerField(default=applications.central_mess.models.current_year)), - ('payment_date', models.DateField(default=datetime.date(2024, 6, 19))), - ('student_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='academic_information.student')), - ], - ), migrations.CreateModel( name='Mess_minutes', fields=[ diff --git a/FusionIIIT/applications/complaint_system/api/serializers.py b/FusionIIIT/applications/complaint_system/api/serializers.py index 8a2b97439..ba466625d 100644 --- a/FusionIIIT/applications/complaint_system/api/serializers.py +++ b/FusionIIIT/applications/complaint_system/api/serializers.py @@ -1,37 +1,226 @@ -from django.contrib.auth import get_user_model -from rest_framework.authtoken.models import Token from rest_framework import serializers -from notifications.models import Notification -from applications.complaint_system.models import Caretaker, StudentComplain, Supervisor, Workers -from applications.globals.models import ExtraInfo,User +from applications.complaint_system.models import ( + Caretaker, + ComplaintEvent, + ComplaintPriority, + ComplaintStatus, + StudentComplain, + VerificationStatus, + Supervisor, + Workers, +) +from applications.globals.models import ExtraInfo, User + + +COMPLAINT_SLA_HOURS = { + ComplaintPriority.URGENT: 24, + ComplaintPriority.STANDARD: 72, + ComplaintPriority.LOW: 168, +} + +ALLOWED_ATTACHMENT_TYPES = { + 'image/jpeg', + 'image/png', + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +} + +MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 class StudentComplainSerializers(serializers.ModelSerializer): + assigned_to_name = serializers.SerializerMethodField() + status_label = serializers.SerializerMethodField() + verification_status_label = serializers.SerializerMethodField() + reopen_allowed_until = serializers.SerializerMethodField() + reopen_window_open = serializers.SerializerMethodField() class Meta: - model=StudentComplain - fields=('__all__') + model = StudentComplain + fields = '__all__' + read_only_fields = ( + 'complainer', + 'complaint_date', + 'complaint_ref', + 'submitted_at', + 'sla_deadline', + 'assigned_to', + 'assigned_team', + 'resolved_at', + 'closed_at', + 'verification_status', + 'verification_source', + 'verification_notes', + 'reopen_requested_at', + 'reopened_at', + 'updated_at', + 'progress_notes', + 'progress_attachment', + 'estimated_resolution_time', + ) + + def validate(self, attrs): + from datetime import timedelta + from django.utils import timezone + + draft_mode = bool( + self.context.get('draft_mode') + or attrs.get('is_draft') + or (self.instance is not None and getattr(self.instance, 'is_draft', False)) + ) + + def _is_empty(value): + return value is None or (isinstance(value, str) and not value.strip()) + + # Keep default server-side values predictable even when frontend sends partial payloads. + if self.instance is None: + required_fields = { + 'complaint_type': 'Category is required', + 'location': 'Location is required', + 'details': 'Description is required', + } + if not draft_mode: + for field, message in required_fields.items(): + if _is_empty(attrs.get(field)): + raise serializers.ValidationError({field: message}) + + priority = attrs.get('priority', ComplaintPriority.STANDARD) + if priority not in dict(ComplaintPriority.CHOICES): + raise serializers.ValidationError({'priority': 'Invalid priority'}) + + attrs.setdefault('complaint_type', 'internet') # Use valid complaint type, not priority + attrs.setdefault('status', ComplaintStatus.PENDING) + attrs.setdefault('remarks', 'Pending') + attrs.setdefault('reason', 'None') + attrs.setdefault('comment', 'None') + attrs.setdefault('priority', ComplaintPriority.STANDARD) + attrs.setdefault('is_draft', draft_mode) + # In draft mode, allow empty details + if draft_mode and 'details' not in attrs: + attrs['details'] = '' + priority = attrs['priority'] + if not draft_mode: + attrs['sla_deadline'] = timezone.now() + timedelta(hours=COMPLAINT_SLA_HOURS.get(priority, 72)) + attrs.setdefault('submitted_at', timezone.now()) + else: + attrs['sla_deadline'] = None + attrs['complaint_finish'] = None + else: + for field, message in { + 'complaint_type': 'Category cannot be empty', + 'location': 'Location cannot be empty', + 'details': 'Description cannot be empty', + }.items(): + if not draft_mode and field in attrs and _is_empty(attrs.get(field)): + raise serializers.ValidationError({field: message}) + + if 'priority' in attrs: + if attrs.get('priority') not in dict(ComplaintPriority.CHOICES): + raise serializers.ValidationError({'priority': 'Invalid priority'}) + + if self.instance is not None and 'priority' in attrs and 'sla_deadline' not in attrs and not draft_mode: + priority = attrs.get('priority', getattr(self.instance, 'priority', ComplaintPriority.STANDARD)) + attrs['sla_deadline'] = timezone.now() + timedelta(hours=COMPLAINT_SLA_HOURS.get(priority, 72)) + + if not attrs.get('complaint_finish') and attrs.get('sla_deadline'): + attrs['complaint_finish'] = attrs['sla_deadline'].date() + + return attrs + + def _validate_attachment(self, file_obj, field_label): + if file_obj is None: + return file_obj + + content_type = getattr(file_obj, 'content_type', None) + if content_type not in ALLOWED_ATTACHMENT_TYPES: + raise serializers.ValidationError( + f'{field_label} must be JPG, PNG, PDF, or DOCX' + ) + + file_size = getattr(file_obj, 'size', 0) or 0 + if file_size > MAX_ATTACHMENT_SIZE: + raise serializers.ValidationError( + f'{field_label} must be 5 MB or smaller' + ) + + return file_obj + + def validate_upload_complaint(self, value): + return self._validate_attachment(value, 'upload_complaint') + + def validate_progress_attachment(self, value): + return self._validate_attachment(value, 'progress_attachment') + + def get_assigned_to_name(self, obj): + if obj.assigned_to_id and obj.assigned_to: + return obj.assigned_to.name + if obj.worker_id_id and obj.worker_id: + return obj.worker_id.name + return '' + + def get_status_label(self, obj): + return dict(ComplaintStatus.CHOICES).get(obj.status, 'Unknown') + + def get_verification_status_label(self, obj): + return dict(VerificationStatus.CHOICES).get(obj.verification_status, obj.verification_status) + + def get_reopen_allowed_until(self, obj): + from datetime import timedelta + from django.utils import timezone + + reference_time = obj.closed_at or obj.resolved_at or obj.updated_at or obj.complaint_date + if not reference_time: + return None + return (reference_time + timedelta(days=7)).isoformat() if reference_time else None + + def get_reopen_window_open(self, obj): + from datetime import timedelta + from django.utils import timezone + + reference_time = obj.closed_at or obj.resolved_at or obj.updated_at or obj.complaint_date + if not reference_time: + return False + return timezone.now() <= (reference_time + timedelta(days=7)) + class WorkersSerializers(serializers.ModelSerializer): class Meta: model = Workers - fields=('__all__') + fields = '__all__' + class CaretakerSerializers(serializers.ModelSerializer): class Meta: model = Caretaker - fields=('__all__') + fields = '__all__' + class SupervisorSerializers(serializers.ModelSerializer): class Meta: - model=Supervisor - fields=('__all__') + model = Supervisor + fields = '__all__' + class ExtraInfoSerializers(serializers.ModelSerializer): class Meta: - model=ExtraInfo - fields=('__all__') + model = ExtraInfo + fields = ('id', 'user', 'user_type', 'department') + class UserSerializers(serializers.ModelSerializer): class Meta: - model=User - fields=('__all__') \ No newline at end of file + model = User + fields = ('id', 'username', 'first_name', 'last_name', 'email', 'is_superuser') + + +class ComplaintEventSerializer(serializers.ModelSerializer): + actor = ExtraInfoSerializers(read_only=True) + actor_name = serializers.SerializerMethodField() + + class Meta: + model = ComplaintEvent + fields = '__all__' + + def get_actor_name(self, obj): + if obj.actor and obj.actor.user: + return obj.actor.user.username + return 'System' \ No newline at end of file diff --git a/FusionIIIT/applications/complaint_system/api/urls.py b/FusionIIIT/applications/complaint_system/api/urls.py index 480cd9af7..8792b9bca 100644 --- a/FusionIIIT/applications/complaint_system/api/urls.py +++ b/FusionIIIT/applications/complaint_system/api/urls.py @@ -5,25 +5,33 @@ urlpatterns = [ url(r'^user/detail/(?P[0-9]+)/$', views.complaint_details_api,name='complain-detail-get-api'), - url(r'^studentcomplain',views.student_complain_api,name='complain-detail2-get-api'), - url(r'^newcomplain',views.create_complain_api,name='complain-post-api'), - url(r'^updatecomplain/(?P[0-9]+)',views.edit_complain_api,name='complain-put-api'), - url(r'^removecomplain/(?P[0-9]+)',views.edit_complain_api,name='complain-delete-api'), + url(r'^studentcomplain/?$', views.student_complain_api, name='complain-detail2-get-api'), + url(r'^newcomplain/?$', views.create_complain_api, name='complain-post-api'), + url(r'^submitdraft/(?P[0-9]+)/?$', views.submit_draft_api, name='complain-draft-submit-api'), + url(r'^updatecomplain/(?P[0-9]+)/?$', views.edit_complain_api, name='complain-put-api'), + url(r'^removecomplain/(?P[0-9]+)/?$', views.edit_complain_api, name='complain-delete-api'), + url(r'^escalate/(?P[0-9]+)/?$', views.escalate_complaint_api, name='complain-escalate-api'), + url(r'^history/(?P[0-9]+)/?$', views.complaint_history_api, name='complain-history-api'), + url(r'^report-analytics/?$', views.report_analytics_api, name='complain-report-analytics-api'), + url(r'^verify/(?P[0-9]+)/?$', views.verify_complaint_api, name='complain-verify-api'), + url(r'^feedback/(?P[0-9]+)/?$', views.submit_feedback_api, name='complain-feedback-api'), + url(r'^reopen/(?P[0-9]+)/?$', views.reopen_complaint_api, name='complain-reopen-api'), + url(r'^caretaker-action/(?P[0-9]+)/?$', views.caretaker_action_api, name='complain-caretaker-action-api'), + url(r'^bulk-action/?$', views.bulk_complaint_action_api, name='complain-bulk-action-api'), - - url(r'^workers',views.worker_api,name='worker-get-api'), - url(r'^addworker',views.worker_api,name='worker-post-api'), - url(r'^removeworker/(?P[0-9]+)',views.edit_worker_api,name='worker-delete-api'), - url(r'updateworker/(?P[0-9]+)',views.edit_worker_api,name='worker-put-api'), + url(r'^workers/?$', views.worker_api, name='worker-get-api'), + url(r'^addworker/?$', views.worker_api, name='worker-post-api'), + url(r'^removeworker/(?P[0-9]+)/?$', views.edit_worker_api, name='worker-delete-api'), + url(r'^updateworker/(?P[0-9]+)/?$', views.edit_worker_api, name='worker-put-api'), - url(r'^caretakers',views.caretaker_api,name='caretaker-get-api'), - url(r'^addcaretaker',views.caretaker_api,name='caretaker-post-api'), - url(r'^removecaretaker/(?P[0-9]+)',views.edit_caretaker_api,name='caretaker-delete-api'), - url(r'^updatecaretaker/(?P[0-9]+)',views.edit_caretaker_api,name='caretaker-put-api'), + url(r'^caretakers/?$', views.caretaker_api, name='caretaker-get-api'), + url(r'^addcaretaker/?$', views.caretaker_api, name='caretaker-post-api'), + url(r'^removecaretaker/(?P[0-9]+)/?$', views.edit_caretaker_api, name='caretaker-delete-api'), + url(r'^updatecaretaker/(?P[0-9]+)/?$', views.edit_caretaker_api, name='caretaker-put-api'), - url(r'^supervisors',views.supervisor_api,name='supervisor-get-api'), - url(r'^addsupervisor',views.supervisor_api,name='supervisor-post-api'), - url(r'^removesupervisor/(?P[0-9]+)',views.edit_supervisor_api,name='supervisor-delete-api'), - url(r'^updatesupervisor/(?P[0-9]+)',views.edit_supervisor_api,name='supervisor-put-api'), + url(r'^supervisors/?$', views.supervisor_api, name='supervisor-get-api'), + url(r'^addsupervisor/?$', views.supervisor_api, name='supervisor-post-api'), + url(r'^removesupervisor/(?P[0-9]+)/?$', views.edit_supervisor_api, name='supervisor-delete-api'), + url(r'^updatesupervisor/(?P[0-9]+)/?$', views.edit_supervisor_api, name='supervisor-put-api'), ] diff --git a/FusionIIIT/applications/complaint_system/api/views.py b/FusionIIIT/applications/complaint_system/api/views.py index 304697017..883c1b12c 100644 --- a/FusionIIIT/applications/complaint_system/api/views.py +++ b/FusionIIIT/applications/complaint_system/api/views.py @@ -1,91 +1,609 @@ -from django.contrib.auth import get_user_model -from django.contrib.auth.decorators import login_required -from applications.globals.models import (HoldsDesignation,Designation) from django.shortcuts import get_object_or_404 -from django.forms.models import model_to_dict +from django.db import transaction +from datetime import timedelta +from collections import Counter, defaultdict +import logging +from django.db.models import Count, Q from rest_framework.permissions import IsAuthenticated from rest_framework.authentication import TokenAuthentication from rest_framework import status -from rest_framework.decorators import api_view, permission_classes,authentication_classes -from rest_framework.permissions import AllowAny +from rest_framework.decorators import api_view, permission_classes, authentication_classes from rest_framework.response import Response -from applications.globals.models import User,ExtraInfo -from applications.complaint_system.models import Caretaker, StudentComplain, Supervisor, Workers +from django.utils import timezone +from django.utils.dateparse import parse_date, parse_datetime +from applications.globals.models import User, ExtraInfo +from applications.complaint_system.models import ( + Caretaker, + ComplaintEvent, + ComplaintPriority, + ComplaintStatus, + StudentComplain, + VerificationStatus, + Supervisor, + Workers, +) +from applications.complaint_system.escalation import escalate_complaint_record +from applications.complaint_system.assignment_policy import lookup_assignment_policy +from applications.complaint_system.notifications import ( + notify_assignment_change, + notify_complaint_created, + notify_reopen_approved, + notify_reopen_requested, + notify_status_change, + notify_verification_result, +) from . import serializers +PRIORITY_SLA_HOURS = { + ComplaintPriority.URGENT: 24, + ComplaintPriority.STANDARD: 72, + ComplaintPriority.LOW: 168, +} + +REOPEN_WINDOW_DAYS = 7 +MAX_REPORT_ROWS = 1000 + + +logger = logging.getLogger(__name__) + +ALLOWED_TRANSITIONS = { + ComplaintStatus.PENDING: {ComplaintStatus.IN_PROGRESS, ComplaintStatus.ESCALATED}, + ComplaintStatus.IN_PROGRESS: {ComplaintStatus.RESOLVED, ComplaintStatus.ESCALATED}, + ComplaintStatus.RESOLVED: {ComplaintStatus.CLOSED, ComplaintStatus.REOPENED, ComplaintStatus.ESCALATED}, + ComplaintStatus.CLOSED: {ComplaintStatus.REOPENED}, + ComplaintStatus.ESCALATED: {ComplaintStatus.IN_PROGRESS, ComplaintStatus.RESOLVED}, + ComplaintStatus.REOPENED: {ComplaintStatus.IN_PROGRESS, ComplaintStatus.RESOLVED, ComplaintStatus.ESCALATED}, +} + + +def _serialize_events(complaint): + return serializers.ComplaintEventSerializer(complaint.events.select_related('actor', 'actor__user').all(), many=True).data + + +def _log_complaint_event(complaint, action, actor=None, from_status=None, to_status=None, note='', metadata=None): + ComplaintEvent.objects.create( + complaint=complaint, + actor=actor, + action=action, + from_status=from_status, + to_status=to_status, + note=note, + metadata=metadata or {}, + ) + + +def _priority_to_deadline(priority): + hours = PRIORITY_SLA_HOURS.get(priority, PRIORITY_SLA_HOURS[ComplaintPriority.STANDARD]) + return timezone.now() + timedelta(hours=hours) + + +def _resolution_reference_time(complaint): + return complaint.closed_at or complaint.resolved_at or complaint.updated_at or complaint.complaint_date + + +def _reopen_deadline(complaint): + reference_time = _resolution_reference_time(complaint) + if reference_time is None: + return None + return reference_time + timedelta(days=REOPEN_WINDOW_DAYS) + + +def _resolve_assigned_worker(complaint): + policy = lookup_assignment_policy(complaint.complaint_type, complaint.location) + fallback_chain = policy.get('fallback_chain', ()) + + caretaker = Caretaker.objects.filter(area=complaint.location).select_related('staff_id').first() + + for strategy in fallback_chain: + worker = None + if strategy == 'area_and_category' and caretaker is not None: + worker = Workers.objects.filter( + secincharge_id__staff_id=caretaker.staff_id, + worker_type=complaint.complaint_type, + ).select_related('secincharge_id').first() + + elif strategy == 'category_only': + worker = Workers.objects.filter( + worker_type=complaint.complaint_type, + ).select_related('secincharge_id').first() + + elif strategy == 'any_worker': + worker = Workers.objects.all().select_related('secincharge_id').first() + + if worker is not None: + if strategy != 'area_and_category': + logger.warning( + 'Complaint assignment used fallback strategy', + extra={ + 'complaint_id': complaint.id, + 'complaint_type': complaint.complaint_type, + 'location': complaint.location, + 'strategy': strategy, + 'policy_source': policy.get('source'), + }, + ) + return worker, { + 'policy_source': policy.get('source'), + 'strategy': strategy, + 'team': policy.get('team', ''), + } + + logger.warning( + 'Complaint assignment found no worker', + extra={ + 'complaint_id': complaint.id, + 'complaint_type': complaint.complaint_type, + 'location': complaint.location, + 'policy_source': policy.get('source'), + }, + ) + return None, { + 'policy_source': policy.get('source'), + 'strategy': 'unassigned', + 'team': policy.get('team', ''), + } + + +def _apply_create_defaults(complaint): + assignment_meta = {'policy_source': 'global-default', 'strategy': 'unassigned', 'team': ''} + + if not complaint.sla_deadline: + complaint.sla_deadline = _priority_to_deadline(complaint.priority) + + if not complaint.complaint_finish: + complaint.complaint_finish = complaint.sla_deadline.date() + + if complaint.assigned_to is None: + worker, assignment_meta = _resolve_assigned_worker(complaint) + complaint.assigned_to = worker + else: + assignment_meta = { + 'policy_source': 'manual', + 'strategy': 'manual', + 'team': complaint.assigned_team or '', + } + + if not complaint.assigned_team: + complaint.assigned_team = assignment_meta.get('team', '') + + complaint.verification_status = VerificationStatus.PENDING + + return assignment_meta + + +def _can_transition(current_status, next_status): + return next_status in ALLOWED_TRANSITIONS.get(current_status, set()) + + +def _is_closed_status(status_value): + return int(status_value) == ComplaintStatus.CLOSED + + +def _is_complainant(extra, complaint): + return bool(extra and complaint.complainer_id == extra.id) + + +def _complaint_payload(complaint): + complaint_data = serializers.StudentComplainSerializers(instance=complaint).data + complaint_data['events'] = _serialize_events(complaint) + return complaint_data + + +def _get_request_extra_info(request): + user = get_object_or_404(User, username=request.user.username) + extra = ExtraInfo.objects.filter(user=user).first() + return user, extra + + +def _is_superuser(user): + return bool(user and user.is_superuser) + + +def _can_manage_complaint(user, extra, complaint): + # Owner can always access their own complaint. + if extra and complaint.complainer_id == extra.id: + return True + + if _is_superuser(user): + return True + + if complaint.is_draft: + return False + + caretaker = Caretaker.objects.filter(staff_id=extra).first() if extra else None + if caretaker and complaint.location == caretaker.area: + return True + + if _has_supervisor_access(extra, complaint): + return True + + return False + + +def _can_change_status(user, extra): + if _is_superuser(user): + return True + + if extra is None: + return False + + if Caretaker.objects.filter(staff_id=extra).exists(): + return True + + if Supervisor.objects.filter(sup_id=extra).exists(): + return True + + return False + + +def _can_escalate_complaint(user, extra): + """Only caretakers can escalate complaints""" + if _is_superuser(user): + return True + + if extra is None: + return False + + if Caretaker.objects.filter(staff_id=extra).exists(): + return True + + return False + + +def _can_oversee_complaint(user, extra, complaint): + if _is_superuser(user): + return True + + if complaint.is_draft: + return False + + return _has_supervisor_access(extra, complaint) + + +def _has_supervisor_access(extra, complaint): + if extra is None or complaint is None: + return False + return Supervisor.objects.filter( + sup_id=extra, + type=complaint.complaint_type, + ).filter( + Q(area='') | Q(area=complaint.location) + ).exists() + + +def _can_supervisor_manage_escalated(extra, complaint): + if complaint is None or complaint.status != ComplaintStatus.ESCALATED: + return False + return _has_supervisor_access(extra, complaint) + + +def _supervisor_scope_query(extra): + if extra is None: + return Q(pk__in=[]) + + mappings = Supervisor.objects.filter(sup_id=extra).values_list('type', 'area') + scope_query = Q(pk__in=[]) + for complaint_type, area in mappings: + if area: + scope_query |= Q(complaint_type=complaint_type, location=area, is_draft=False) + else: + scope_query |= Q(complaint_type=complaint_type, is_draft=False) + return scope_query + + +def _caretaker_for_user(extra): + if extra is None: + return None + return Caretaker.objects.filter(staff_id=extra).first() + + +def _is_assigned_caretaker(extra, complaint): + if extra is None: + return False + + # Allow if caretaker oversees the complaint's area + caretaker = Caretaker.objects.filter(staff_id=extra).first() + if caretaker and caretaker.area == complaint.location: + return True + + # Allow if caretaker is the secincharge of the assigned worker + if complaint.assigned_to is None: + return False + sec = complaint.assigned_to.secincharge_id + if sec is None or sec.staff_id_id is None: + return False + return sec.staff_id_id == extra.id + + +def _parse_datetime_input(value): + if value in (None, ''): + return None + if hasattr(value, 'isoformat'): + return value + parsed = parse_datetime(str(value)) + return parsed or value + + +def _to_float(value): + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _draft_submission_errors(payload): + missing = [] + for field in ('complaint_type', 'location', 'details'): + value = payload.get(field) + if value is None or (isinstance(value, str) and not value.strip()): + missing.append(field) + return missing + + @api_view(['GET']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def complaint_details_api(request,detailcomp_id1): - complaint_detail = StudentComplain.objects.get(id=detailcomp_id1) - complaint_detail_serialized = serializers.StudentComplainSerializers(instance=complaint_detail).data + user, extra = _get_request_extra_info(request) + complaint_detail = get_object_or_404(StudentComplain, id=detailcomp_id1) + + if not _can_manage_complaint(user, extra, complaint_detail): + return Response({'message': 'Access denied'}, status=status.HTTP_403_FORBIDDEN) + + complaint_detail_serialized = _complaint_payload(complaint_detail) if complaint_detail.worker_id is None: worker_detail_serialized = {} - else : + else: worker_detail = Workers.objects.get(id=complaint_detail.worker_id.id) worker_detail_serialized = serializers.WorkersSerializers(instance=worker_detail).data + if complaint_detail.assigned_to is None: + assigned_worker_serialized = {} + else: + assigned_worker_serialized = serializers.WorkersSerializers(instance=complaint_detail.assigned_to).data complainer = User.objects.get(username=complaint_detail.complainer.user.username) complainer_serialized = serializers.UserSerializers(instance=complainer).data - complainer_extra_info = ExtraInfo.objects.get(user = complainer) + complainer_extra_info = ExtraInfo.objects.get(user=complainer) complainer_extra_info_serialized = serializers.ExtraInfoSerializers(instance=complainer_extra_info).data response = { - 'complainer' : complainer_serialized, + 'complainer': complainer_serialized, 'complainer_extra_info':complainer_extra_info_serialized, - 'complaint_details' : complaint_detail_serialized, - 'worker_details' : worker_detail_serialized + 'complaint_details': complaint_detail_serialized, + 'worker_details' : worker_detail_serialized, + 'assigned_worker_details': assigned_worker_serialized, + 'status_timeline': complaint_detail_serialized.get('events', []), } - return Response(data=response,status=status.HTTP_200_OK) + return Response(data=response, status=status.HTTP_200_OK) @api_view(['GET']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def student_complain_api(request): - user = get_object_or_404(User,username = request.user.username) - user = ExtraInfo.objects.all().filter(user = user).first() - if user.user_type == 'student': - complain = StudentComplain.objects.filter(complainer = user) - elif user.user_type == 'staff': - staff = ExtraInfo.objects.get(id=user.id) - staff = Caretaker.objects.get(staff_id=staff) - complain = StudentComplain.objects.filter(location = staff.area) - elif user.user_type == 'faculty': - faculty = ExtraInfo.objects.get(id=user.id) - faculty = Supervisor.objects.get(sup_id=faculty) - complain = StudentComplain.objects.filter(location = faculty.area) - complains = serializers.StudentComplainSerializers(complain,many=True).data + user, extra = _get_request_extra_info(request) + if extra is None: + return Response({'student_complain': []}, status=status.HTTP_200_OK) + + if _is_superuser(user): + complain = StudentComplain.objects.all().order_by('-complaint_date') + elif extra.user_type in ('student', 'staff', 'faculty'): + # Start with own complaints (always visible to complainant) + base_query = Q(complainer=extra) + + # Add role-specific expanded access: caretaker and supervisor scope + scope_query = Q() + caretaker = Caretaker.objects.filter(staff_id=extra).first() + if caretaker: + scope_query |= Q(location=caretaker.area, is_draft=False) + + supervisor_scope = _supervisor_scope_query(extra) + if supervisor_scope.children: + scope_query |= supervisor_scope + + # Combine own complaints with scope access + if scope_query: + base_query |= scope_query + + complain = StudentComplain.objects.filter(base_query) + else: + complain = StudentComplain.objects.none() + + complains = serializers.StudentComplainSerializers(complain.order_by('-complaint_date'), many=True).data resp = { - 'student_complain' : complains, + 'student_complain': complains, } - return Response(data=resp,status=status.HTTP_200_OK) + return Response(data=resp, status=status.HTTP_200_OK) @api_view(['POST']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def create_complain_api(request): - serializer = serializers.StudentComplainSerializers(data=request.data) + _, extra = _get_request_extra_info(request) + if extra is None: + return Response({'message': 'User profile not found'}, status=status.HTTP_400_BAD_REQUEST) + + is_draft = str(request.data.get('is_draft', '')).lower() in ('1', 'true', 'yes') + + serializer = serializers.StudentComplainSerializers(data=request.data, context={'draft_mode': is_draft}) if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) + with transaction.atomic(): + complaint = serializer.save( + complainer=extra, + is_draft=is_draft, + submitted_at=None if is_draft else timezone.now(), + ) + assignment_meta = {'policy_source': 'draft', 'strategy': 'draft', 'team': ''} + if not is_draft: + assignment_meta = _apply_create_defaults(complaint) + complaint.save() + _log_complaint_event( + complaint, + action='draft_saved' if is_draft else 'created', + actor=extra, + to_status=complaint.status, + metadata={ + 'is_draft': complaint.is_draft, + 'priority': complaint.priority, + 'sla_deadline': complaint.sla_deadline.isoformat() if complaint.sla_deadline else None, + 'assigned_to': complaint.assigned_to_id, + 'assigned_team': complaint.assigned_team, + 'assignment_policy_source': assignment_meta.get('policy_source'), + 'assignment_strategy': assignment_meta.get('strategy'), + }, + ) + if not is_draft: + transaction.on_commit(lambda: notify_complaint_created(complaint, actor=extra)) + return Response(_complaint_payload(complaint), status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def submit_draft_api(request, c_id): + user, extra = _get_request_extra_info(request) + complaint = get_object_or_404(StudentComplain, id=c_id) + + if not complaint.is_draft: + return Response({'message': 'Complaint is already submitted'}, status=status.HTTP_400_BAD_REQUEST) + + if not _is_complainant(extra, complaint) and not _is_superuser(user): + return Response({'message': 'Only owner can submit this draft'}, status=status.HTTP_403_FORBIDDEN) + + incoming_data = request.data or {} + serializer = serializers.StudentComplainSerializers( + complaint, + data=incoming_data, + partial=True, + context={'draft_mode': False}, + ) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + pending_payload = {**{k: getattr(complaint, k) for k in ('complaint_type', 'location', 'details')}, **serializer.validated_data} + missing_fields = _draft_submission_errors(pending_payload) + if missing_fields: + return Response( + {'message': 'Draft is missing required fields', 'missing_fields': missing_fields}, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + before_status = complaint.status + complaint = serializer.save() + complaint.is_draft = False + complaint.submitted_at = timezone.now() + if str(complaint.complaint_ref).startswith('DRF-'): + complaint.complaint_ref = '' + assignment_meta = _apply_create_defaults(complaint) + complaint.save() + + _log_complaint_event( + complaint, + action='draft_submitted', + actor=extra, + from_status=before_status, + to_status=complaint.status, + metadata={ + 'priority': complaint.priority, + 'sla_deadline': complaint.sla_deadline.isoformat() if complaint.sla_deadline else None, + 'assigned_to': complaint.assigned_to_id, + 'assigned_team': complaint.assigned_team, + 'assignment_policy_source': assignment_meta.get('policy_source'), + 'assignment_strategy': assignment_meta.get('strategy'), + }, + ) + transaction.on_commit(lambda: notify_complaint_created(complaint, actor=extra)) + + return Response(_complaint_payload(complaint), status=status.HTTP_200_OK) + @api_view(['DELETE','PUT']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def edit_complain_api(request,c_id): - try: - complain = StudentComplain.objects.get(id = c_id) - except StudentComplain.DoesNotExist: - return Response({'message': 'The Complain does not exist'}, status=status.HTTP_404_NOT_FOUND) + user, extra = _get_request_extra_info(request) + try: + complain = StudentComplain.objects.get(id=c_id) + except StudentComplain.DoesNotExist: + return Response({'message': 'The complaint does not exist'}, status=status.HTTP_404_NOT_FOUND) + + if not _can_manage_complaint(user, extra, complain): + return Response({'message': 'Access denied'}, status=status.HTTP_403_FORBIDDEN) + if request.method == 'DELETE': complain.delete() - return Response({'message': 'Complain deleted'},status=status.HTTP_404_NOT_FOUND) - elif request.method == 'PUT': - serializer = serializers.StudentComplainSerializers(complain,data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data,status=status.HTTP_200_OK) - return Response(serializer.errors,status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_204_NO_CONTENT) + + if complain.is_draft and 'status' in request.data: + return Response({'message': 'Draft complaints cannot change status'}, status=status.HTTP_400_BAD_REQUEST) + + if 'status' in request.data and not _can_change_status(user, extra): + return Response( + {'message': 'Only caretaker/supervisor can change complaint status'}, + status=status.HTTP_403_FORBIDDEN, + ) + + incoming_status = request.data.get('status') + if incoming_status is not None: + try: + incoming_status = int(incoming_status) + except (TypeError, ValueError): + return Response({'message': 'Invalid complaint status'}, status=status.HTTP_400_BAD_REQUEST) + + if not _can_transition(complain.status, incoming_status): + return Response({'message': 'Invalid complaint status transition'}, status=status.HTTP_400_BAD_REQUEST) + + if _is_closed_status(incoming_status): + return Response( + {'message': 'Use /verify/ to close after verification'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if incoming_status in (ComplaintStatus.IN_PROGRESS, ComplaintStatus.RESOLVED): + remarks = request.data.get('remarks', '') + if not str(remarks).strip(): + return Response( + {'message': 'remarks are required when updating complaint progress'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + before_status = complain.status + + serializer = serializers.StudentComplainSerializers(complain, data=request.data, partial=True) + if serializer.is_valid(): + with transaction.atomic(): + complaint = serializer.save() + if incoming_status == ComplaintStatus.RESOLVED: + complaint.resolved_at = timezone.now() + complaint.closed_at = None + complaint.verification_status = VerificationStatus.PENDING + elif incoming_status == ComplaintStatus.REOPENED: + complaint.reopened_at = timezone.now() + complaint.verification_status = VerificationStatus.PENDING + if complaint.sla_deadline and not complaint.complaint_finish: + complaint.complaint_finish = complaint.sla_deadline.date() + complaint.save() + if incoming_status is not None and incoming_status != before_status: + _log_complaint_event( + complaint, + action='status_updated', + actor=extra, + from_status=before_status, + to_status=incoming_status, + note=request.data.get('remarks', ''), + metadata={'verification_source': request.data.get('verification_source', '')}, + ) + remarks = request.data.get('remarks', '') + transaction.on_commit( + lambda: notify_status_change( + complaint, + before_status, + incoming_status, + actor=extra, + remarks=remarks, + ) + ) + return Response(_complaint_payload(complaint), status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @api_view(['GET','POST']) @permission_classes([IsAuthenticated]) @@ -94,19 +612,18 @@ def worker_api(request): if request.method == 'GET': worker = Workers.objects.all() - workers = serializers.WorkersSerializers(worker,many=True).data + workers = serializers.WorkersSerializers(worker, many=True).data resp = { - 'workers' : workers, + 'workers': workers, } - return Response(data=resp,status=status.HTTP_200_OK) + return Response(data=resp, status=status.HTTP_200_OK) - elif request.method =='POST': - user = get_object_or_404(User ,username=request.user.username) - user = ExtraInfo.objects.all().filter(user = user).first() - try : - caretaker = Caretaker.objects.get(staff_id=user) + elif request.method == 'POST': + _, extra = _get_request_extra_info(request) + try: + caretaker = Caretaker.objects.get(staff_id=extra) except Caretaker.DoesNotExist: - return Response({'message':'Logged in user does not have the permissions'},status=status.HTTP_203_NON_AUTHORITATIVE_INFORMATION) + return Response({'message': 'Logged in user does not have permission'}, status=status.HTTP_403_FORBIDDEN) serializer = serializers.WorkersSerializers(data=request.data) if serializer.is_valid(): serializer.save() @@ -117,25 +634,23 @@ def worker_api(request): @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def edit_worker_api(request,w_id): - user = get_object_or_404(User ,username=request.user.username) - user = ExtraInfo.objects.all().filter(user = user).first() - try : - caretaker = Caretaker.objects.get(staff_id=user) + _, extra = _get_request_extra_info(request) + try: + caretaker = Caretaker.objects.get(staff_id=extra) except Caretaker.DoesNotExist: - return Response({'message':'Logged in user does not have the permissions'},status=status.HTTP_203_NON_AUTHORITATIVE_INFORMATION) - try: - worker = Workers.objects.get(id = w_id) - except Workers.DoesNotExist: + return Response({'message': 'Logged in user does not have permission'}, status=status.HTTP_403_FORBIDDEN) + try: + worker = Workers.objects.get(id=w_id) + except Workers.DoesNotExist: return Response({'message': 'The worker does not exist'}, status=status.HTTP_404_NOT_FOUND) if request.method == 'DELETE': worker.delete() - return Response({'message': 'Worker deleted'},status=status.HTTP_404_NOT_FOUND) - elif request.method == 'PUT': - serializer = serializers.WorkersSerializers(worker,data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data,status=status.HTTP_200_OK) - return Response(serializer.errors,status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_204_NO_CONTENT) + serializer = serializers.WorkersSerializers(worker, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @api_view(['GET','POST']) @permission_classes([IsAuthenticated]) @@ -144,48 +659,45 @@ def caretaker_api(request): if request.method == 'GET': caretaker = Caretaker.objects.all() - caretakers = serializers.CaretakerSerializers(caretaker,many=True).data + caretakers = serializers.CaretakerSerializers(caretaker, many=True).data resp = { - 'caretakers' : caretakers, + 'caretakers': caretakers, } - return Response(data=resp,status=status.HTTP_200_OK) - + return Response(data=resp, status=status.HTTP_200_OK) + elif request.method == 'POST': - user = get_object_or_404(User ,username=request.user.username) - user = ExtraInfo.objects.all().filter(user = user).first() - try : - supervisor = Supervisor.objects.get(sup_id=user) + _, extra = _get_request_extra_info(request) + try: + supervisor = Supervisor.objects.get(sup_id=extra) except Supervisor.DoesNotExist: - return Response({'message':'Logged in user does not have the permissions'},status=status.HTTP_203_NON_AUTHORITATIVE_INFORMATION) + return Response({'message': 'Logged in user does not have permission'}, status=status.HTTP_403_FORBIDDEN) serializer = serializers.CaretakerSerializers(data=request.data) if serializer.is_valid(): serializer.save() - return Response(serializer.data,status=status.HTTP_201_CREATED) - return Response(serializer.errors,status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @api_view(['DELETE','PUT']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def edit_caretaker_api(request,c_id): - user = get_object_or_404(User ,username=request.user.username) - user = ExtraInfo.objects.all().filter(user = user).first() - try : - supervisor = Supervisor.objects.get(sup_id=user) + _, extra = _get_request_extra_info(request) + try: + supervisor = Supervisor.objects.get(sup_id=extra) except Supervisor.DoesNotExist: - return Response({'message':'Logged in user does not have the permissions'},status=status.HTTP_203_NON_AUTHORITATIVE_INFORMATION) - try: - caretaker = Caretaker.objects.get(id = c_id) - except Caretaker.DoesNotExist: + return Response({'message': 'Logged in user does not have permission'}, status=status.HTTP_403_FORBIDDEN) + try: + caretaker = Caretaker.objects.get(id=c_id) + except Caretaker.DoesNotExist: return Response({'message': 'The Caretaker does not exist'}, status=status.HTTP_404_NOT_FOUND) if request.method == 'DELETE': caretaker.delete() - return Response({'message': 'Caretaker deleted'},status=status.HTTP_404_NOT_FOUND) - elif request.method == 'PUT': - serializer = serializers.CaretakerSerializers(caretaker,data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data,status=status.HTTP_200_OK) - return Response(serializer.errors,status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_204_NO_CONTENT) + serializer = serializers.CaretakerSerializers(caretaker, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @api_view(['GET','POST']) @permission_classes([IsAuthenticated]) @@ -194,40 +706,693 @@ def supervisor_api(request): if request.method == 'GET': supervisor = Supervisor.objects.all() - supervisors = serializers.SupervisorSerializers(supervisor,many=True).data + supervisors = serializers.SupervisorSerializers(supervisor, many=True).data resp = { - 'supervisors' : supervisors, + 'supervisors': supervisors, } - return Response(data=resp,status=status.HTTP_200_OK) - + return Response(data=resp, status=status.HTTP_200_OK) + elif request.method == 'POST': - user = get_object_or_404(User,username=request.user.username) - if user.is_superuser == False: - return Response({'message':'Logged in user does not have permission'},status=status.HTTP_203_NON_AUTHORITATIVE_INFORMATION) + user, _ = _get_request_extra_info(request) + if not _is_superuser(user): + return Response({'message': 'Logged in user does not have permission'}, status=status.HTTP_403_FORBIDDEN) serializer = serializers.SupervisorSerializers(data=request.data) if serializer.is_valid(): serializer.save() - return Response(serializer.data,status=status.HTTP_201_CREATED) - return Response(serializer.errors,status=status.HTTP_400_BAD_REQUEST) - + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @api_view(['DELETE','PUT']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def edit_supervisor_api(request,s_id): - user = get_object_or_404(User,username=request.user.username) - if user.is_superuser == False: - return Response({'message':'Logged in user does not have permission'},status=status.HTTP_203_NON_AUTHORITATIVE_INFORMATION) - try: - supervisor = Supervisor.objects.get(id = s_id) - except Supervisor.DoesNotExist: - return Response({'message': 'The Caretaker does not exist'}, status=status.HTTP_404_NOT_FOUND) + user, _ = _get_request_extra_info(request) + if not _is_superuser(user): + return Response({'message': 'Logged in user does not have permission'}, status=status.HTTP_403_FORBIDDEN) + try: + supervisor = Supervisor.objects.get(id=s_id) + except Supervisor.DoesNotExist: + return Response({'message': 'The Supervisor does not exist'}, status=status.HTTP_404_NOT_FOUND) if request.method == 'DELETE': supervisor.delete() - return Response({'message': 'Caretaker deleted'},status=status.HTTP_404_NOT_FOUND) - elif request.method == 'PUT': - serializer = serializers.SupervisorSerializers(supervisor,data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data,status=status.HTTP_200_OK) - return Response(serializer.errors,status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_204_NO_CONTENT) + serializer = serializers.SupervisorSerializers(supervisor, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def escalate_complaint_api(request, c_id): + """Escalate a complaint to supervisor""" + user, extra = _get_request_extra_info(request) + + # Check if user can escalate + if not _can_escalate_complaint(user, extra): + return Response( + {'message': 'Only caretaker can escalate complaints'}, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + complaint = StudentComplain.objects.get(id=c_id) + except StudentComplain.DoesNotExist: + return Response({'message': 'The complaint does not exist'}, status=status.HTTP_404_NOT_FOUND) + + if complaint.is_draft: + return Response({'message': 'Draft complaints cannot be escalated'}, status=status.HTTP_400_BAD_REQUEST) + + # Check if user can manage this complaint + if not _can_manage_complaint(user, extra, complaint): + return Response({'message': 'Access denied'}, status=status.HTTP_403_FORBIDDEN) + + escalation_reason = request.data.get('escalation_reason', '') + if not str(escalation_reason).strip(): + return Response({'message': 'escalation_reason is required'}, status=status.HTTP_400_BAD_REQUEST) + + if not _can_transition(complaint.status, ComplaintStatus.ESCALATED): + return Response({'message': 'Invalid complaint status transition'}, status=status.HTTP_400_BAD_REQUEST) + + escalate_complaint_record(complaint, escalation_reason, actor=extra, automatic=False) + + return Response(_complaint_payload(complaint), status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def complaint_history_api(request, c_id): + user, extra = _get_request_extra_info(request) + complaint = get_object_or_404(StudentComplain, id=c_id) + if not _can_manage_complaint(user, extra, complaint): + return Response({'message': 'Access denied'}, status=status.HTTP_403_FORBIDDEN) + return Response({'events': _serialize_events(complaint)}, status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def reopen_complaint_api(request, c_id): + user, extra = _get_request_extra_info(request) + complaint = get_object_or_404(StudentComplain, id=c_id) + + if complaint.is_draft: + return Response({'message': 'Draft complaints cannot be reopened'}, status=status.HTTP_400_BAD_REQUEST) + + if not _is_complainant(extra, complaint) and not _is_superuser(user): + if not _has_supervisor_access(extra, complaint): + return Response({'message': 'Access denied'}, status=status.HTTP_403_FORBIDDEN) + + reopen_reason = request.data.get('reopen_reason', '') + if not str(reopen_reason).strip(): + return Response({'message': 'reopen_reason is required'}, status=status.HTTP_400_BAD_REQUEST) + + if complaint.status not in (ComplaintStatus.RESOLVED, ComplaintStatus.CLOSED): + return Response({'message': 'Only resolved or closed complaints can be reopened'}, status=status.HTTP_400_BAD_REQUEST) + + reopen_deadline = _reopen_deadline(complaint) + if reopen_deadline is not None and timezone.now() > reopen_deadline and not _is_superuser(user): + return Response({'message': 'Reopen request window has expired'}, status=status.HTTP_400_BAD_REQUEST) + + complaint.reopen_requested = True + complaint.reopen_reason = reopen_reason + complaint.reopen_requested_at = timezone.now() + + approve = str(request.data.get('approve', '')).lower() in ('1', 'true', 'yes') + if approve: + before_status = complaint.status + complaint.status = ComplaintStatus.REOPENED + complaint.reopened_at = timezone.now() + complaint.verification_status = VerificationStatus.PENDING + if complaint.assigned_to is None: + assigned_worker, assignment_meta = _resolve_assigned_worker(complaint) + complaint.assigned_to = assigned_worker + complaint.assigned_team = assignment_meta.get('team', complaint.assigned_team or '') + complaint.save() + _log_complaint_event( + complaint, + action='reopen_approved', + actor=extra, + from_status=before_status, + to_status=ComplaintStatus.REOPENED, + note=reopen_reason, + ) + transaction.on_commit(lambda: notify_reopen_approved(complaint, actor=extra, reason=reopen_reason)) + else: + complaint.save() + _log_complaint_event( + complaint, + action='reopen_requested', + actor=extra, + from_status=complaint.status, + note=reopen_reason, + ) + transaction.on_commit(lambda: notify_reopen_requested(complaint, actor=extra, reason=reopen_reason)) + + return Response(_complaint_payload(complaint), status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def verify_complaint_api(request, c_id): + user, extra = _get_request_extra_info(request) + complaint = get_object_or_404(StudentComplain, id=c_id) + if complaint.is_draft: + return Response({'message': 'Draft complaints cannot be verified'}, status=status.HTTP_400_BAD_REQUEST) + if not _can_manage_complaint(user, extra, complaint) and not _is_complainant(extra, complaint): + return Response({'message': 'Access denied'}, status=status.HTTP_403_FORBIDDEN) + + verification_source = str(request.data.get('verification_source', '')).strip().lower() + if not verification_source: + return Response({'message': 'verification_source is required'}, status=status.HTTP_400_BAD_REQUEST) + + valid_sources = {'complainant', 'supervisor'} + if verification_source not in valid_sources: + return Response({'message': 'verification_source must be complainant or supervisor'}, status=status.HTTP_400_BAD_REQUEST) + + if complaint.status != ComplaintStatus.RESOLVED: + return Response({'message': 'Only resolved complaints can be verified and closed'}, status=status.HTTP_400_BAD_REQUEST) + + reopen_deadline = _reopen_deadline(complaint) + if reopen_deadline is not None and timezone.now() > reopen_deadline and not _is_superuser(user): + return Response({'message': 'Verification window has expired'}, status=status.HTTP_400_BAD_REQUEST) + + verification_decision = str(request.data.get('verification_decision', 'approve')).strip().lower() + if verification_decision not in {'approve', 'reject'}: + return Response({'message': 'verification_decision must be approve or reject'}, status=status.HTTP_400_BAD_REQUEST) + + notes = request.data.get('verification_notes', request.data.get('remarks', '')) + if verification_decision == 'reject' and not str(notes).strip(): + return Response({'message': 'verification_notes are required when rejecting resolution'}, status=status.HTTP_400_BAD_REQUEST) + + if verification_source == 'complainant' and not _is_complainant(extra, complaint): + return Response({'message': 'Only the complainant can verify as complainant'}, status=status.HTTP_403_FORBIDDEN) + + if verification_source == 'supervisor': + if not _has_supervisor_access(extra, complaint) and not _is_superuser(user): + return Response({'message': 'Only matching supervisor can verify as supervisor'}, status=status.HTTP_403_FORBIDDEN) + + before_status = complaint.status + + complaint.verification_source = verification_source + complaint.verification_notes = notes + + if verification_decision == 'approve': + complaint.verification_status = VerificationStatus.APPROVED + complaint.status = ComplaintStatus.CLOSED + complaint.closed_at = timezone.now() + complaint.reopen_requested = False + complaint.reopen_reason = '' + complaint.reopen_requested_at = None + action = 'verified_and_closed' + to_status = ComplaintStatus.CLOSED + else: + complaint.verification_status = VerificationStatus.REJECTED + complaint.status = ComplaintStatus.REOPENED + complaint.reopened_at = timezone.now() + complaint.reopen_requested = True + complaint.reopen_reason = notes or 'Resolution rejected during verification' + complaint.reopen_requested_at = timezone.now() + complaint.closed_at = None + action = 'verification_rejected' + to_status = ComplaintStatus.REOPENED + + complaint.save() + _log_complaint_event( + complaint, + action=action, + actor=extra, + from_status=before_status, + to_status=to_status, + note=notes, + metadata={ + 'verification_source': verification_source, + 'verification_decision': verification_decision, + }, + ) + transaction.on_commit( + lambda: notify_verification_result( + complaint, + actor=extra, + decision=verification_decision, + notes=notes, + ) + ) + return Response(_complaint_payload(complaint), status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def caretaker_action_api(request, c_id): + user, extra = _get_request_extra_info(request) + complaint = get_object_or_404(StudentComplain, id=c_id) + + if complaint.is_draft: + return Response({'message': 'Draft complaints cannot be updated by caretaker'}, status=status.HTTP_400_BAD_REQUEST) + + caretaker = _caretaker_for_user(extra) + can_supervisor_manage = _can_supervisor_manage_escalated(extra, complaint) + if caretaker is None and not _is_superuser(user) and not can_supervisor_manage: + return Response({'message': 'Only caretakers can update complaint progress'}, status=status.HTTP_403_FORBIDDEN) + + can_update = _is_assigned_caretaker(extra, complaint) or can_supervisor_manage + if not _is_superuser(user) and not can_update: + if complaint.status == ComplaintStatus.ESCALATED: + return Response( + {'message': 'Only assigned caretaker or matching supervisor can update this escalated complaint'}, + status=status.HTTP_403_FORBIDDEN, + ) + return Response({'message': 'Only assigned caretaker can update this complaint'}, status=status.HTTP_403_FORBIDDEN) + + incoming_status = request.data.get('status') + if incoming_status is None: + return Response({'message': 'status is required'}, status=status.HTTP_400_BAD_REQUEST) + try: + incoming_status = int(incoming_status) + except (TypeError, ValueError): + return Response({'message': 'Invalid complaint status'}, status=status.HTTP_400_BAD_REQUEST) + + if incoming_status not in (ComplaintStatus.IN_PROGRESS, ComplaintStatus.RESOLVED): + return Response({'message': 'Caretaker can only set status to In Progress or Resolved'}, status=status.HTTP_400_BAD_REQUEST) + + if not _can_transition(complaint.status, incoming_status): + return Response({'message': 'Invalid complaint status transition'}, status=status.HTTP_400_BAD_REQUEST) + + notes = request.data.get('remarks', '') + if not str(notes).strip(): + return Response({'message': 'remarks are required'}, status=status.HTTP_400_BAD_REQUEST) + + before_status = complaint.status + complaint.status = incoming_status + complaint.remarks = notes + complaint.progress_notes = request.data.get('progress_notes', notes) + complaint.comment = str(notes)[:100] + + eta = request.data.get('estimated_resolution_time') + if eta not in (None, ''): + complaint.estimated_resolution_time = _parse_datetime_input(eta) + + attachment = request.FILES.get('progress_attachment') + if attachment is not None: + complaint.progress_attachment = attachment + + complaint.save() + + _log_complaint_event( + complaint, + action='caretaker_progress_update', + actor=extra, + from_status=before_status, + to_status=incoming_status, + note=notes, + metadata={ + 'estimated_resolution_time': complaint.estimated_resolution_time.isoformat() if complaint.estimated_resolution_time else None, + 'has_progress_attachment': bool(attachment), + }, + ) + + transaction.on_commit( + lambda: notify_status_change( + complaint, + before_status, + incoming_status, + actor=extra, + remarks=notes, + ) + ) + + return Response(_complaint_payload(complaint), status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def bulk_complaint_action_api(request): + user, extra = _get_request_extra_info(request) + action = str(request.data.get('action', '')).strip().lower() + complaint_ids = request.data.get('complaint_ids', []) + + if isinstance(complaint_ids, str): + complaint_ids = [item for item in complaint_ids.split(',') if item] + + if not isinstance(complaint_ids, (list, tuple)) or not complaint_ids: + return Response({'message': 'complaint_ids are required'}, status=status.HTTP_400_BAD_REQUEST) + + complaints = list(StudentComplain.objects.filter(id__in=complaint_ids).select_related( + 'complainer', + 'complainer__user', + 'assigned_to', + 'assigned_to__secincharge_id', + 'assigned_to__secincharge_id__staff_id', + )) + complaints_by_id = {str(complaint.id): complaint for complaint in complaints} + missing_ids = [str(complaint_id) for complaint_id in complaint_ids if str(complaint_id) not in complaints_by_id] + if missing_ids: + return Response( + {'message': 'Some complaints were not found', 'missing_ids': missing_ids}, + status=status.HTTP_404_NOT_FOUND, + ) + + if action not in {'reassign', 'intervene'}: + return Response({'message': 'Invalid bulk action'}, status=status.HTTP_400_BAD_REQUEST) + + if action == 'reassign': + worker_id = request.data.get('assigned_to') + if not worker_id: + return Response({'message': 'assigned_to is required for bulk reassignment'}, status=status.HTTP_400_BAD_REQUEST) + + try: + worker = Workers.objects.select_related('secincharge_id', 'secincharge_id__staff_id').get(id=worker_id) + except Workers.DoesNotExist: + return Response({'message': 'Selected worker does not exist'}, status=status.HTTP_404_NOT_FOUND) + + else: + worker = None + + updated = [] + with transaction.atomic(): + for complaint_id in complaint_ids: + complaint = complaints_by_id[str(complaint_id)] + + if complaint.is_draft: + return Response({'message': 'Draft complaints are not eligible for bulk action'}, status=status.HTTP_400_BAD_REQUEST) + + if not _can_oversee_complaint(user, extra, complaint): + return Response({'message': 'Access denied'}, status=status.HTTP_403_FORBIDDEN) + + if action == 'reassign': + previous_worker = complaint.assigned_to + complaint.assigned_to = worker + complaint.worker_id = worker + complaint.assigned_team = str(request.data.get('assigned_team', '')).strip() or complaint.assigned_team or '' + + reassignment_note = str(request.data.get('remarks', '')).strip() + if reassignment_note: + complaint.remarks = reassignment_note + complaint.comment = reassignment_note[:100] + + complaint.save() + _log_complaint_event( + complaint, + action='bulk_reassigned', + actor=extra, + note=reassignment_note, + metadata={ + 'previous_assigned_to': getattr(previous_worker, 'id', None), + 'assigned_to': worker.id, + 'assigned_team': complaint.assigned_team, + }, + ) + transaction.on_commit( + lambda complaint=complaint, previous_worker=previous_worker, reassignment_note=reassignment_note: notify_assignment_change( + complaint, + actor=extra, + previous_worker=previous_worker, + note=reassignment_note, + ) + ) + + else: + incoming_status = request.data.get('status') + if incoming_status is None: + return Response({'message': 'status is required for bulk intervention'}, status=status.HTTP_400_BAD_REQUEST) + + try: + incoming_status = int(incoming_status) + except (TypeError, ValueError): + return Response({'message': 'Invalid complaint status'}, status=status.HTTP_400_BAD_REQUEST) + + if not _can_transition(complaint.status, incoming_status): + return Response({'message': 'Invalid complaint status transition'}, status=status.HTTP_400_BAD_REQUEST) + + remarks = str(request.data.get('remarks', '')).strip() + if not remarks: + return Response({'message': 'remarks are required for bulk intervention'}, status=status.HTTP_400_BAD_REQUEST) + + before_status = complaint.status + complaint.status = incoming_status + complaint.remarks = remarks + complaint.progress_notes = str(request.data.get('progress_notes', remarks)) + complaint.comment = remarks[:100] + + eta = request.data.get('estimated_resolution_time') + if eta not in (None, ''): + complaint.estimated_resolution_time = _parse_datetime_input(eta) + + if incoming_status == ComplaintStatus.RESOLVED: + complaint.resolved_at = timezone.now() + complaint.closed_at = None + complaint.verification_status = VerificationStatus.PENDING + elif incoming_status == ComplaintStatus.REOPENED: + complaint.reopened_at = timezone.now() + complaint.verification_status = VerificationStatus.PENDING + + complaint.save() + _log_complaint_event( + complaint, + action='bulk_intervention', + actor=extra, + from_status=before_status, + to_status=incoming_status, + note=remarks, + metadata={ + 'estimated_resolution_time': complaint.estimated_resolution_time.isoformat() if complaint.estimated_resolution_time else None, + }, + ) + transaction.on_commit( + lambda complaint=complaint, before_status=before_status, incoming_status=incoming_status, remarks=remarks: notify_status_change( + complaint, + before_status, + incoming_status, + actor=extra, + remarks=remarks, + ) + ) + + updated.append(_complaint_payload(complaint)) + + return Response( + { + 'action': action, + 'updated_count': len(updated), + 'complaints': updated, + }, + status=status.HTTP_200_OK, + ) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def report_analytics_api(request): + user, extra = _get_request_extra_info(request) + if not _is_superuser(user): + if not (extra and Supervisor.objects.filter(sup_id=extra).exists()): + return Response({'message': 'Only admin/supervisor can generate reports'}, status=status.HTTP_403_FORBIDDEN) + + date_from = parse_date(str(request.query_params.get('date_from', '')).strip()) + date_to = parse_date(str(request.query_params.get('date_to', '')).strip()) + category = str(request.query_params.get('category', '')).strip() + location = str(request.query_params.get('location', '')).strip() + + if date_from and date_to and date_from > date_to: + return Response({'message': 'date_from cannot be after date_to'}, status=status.HTTP_400_BAD_REQUEST) + + queryset = StudentComplain.objects.filter(is_draft=False).order_by('-complaint_date') + + if date_from: + queryset = queryset.filter(complaint_date__date__gte=date_from) + if date_to: + queryset = queryset.filter(complaint_date__date__lte=date_to) + if category: + queryset = queryset.filter(complaint_type=category) + if location: + queryset = queryset.filter(location=location) + + if not _is_superuser(user): + supervisor_scope = _supervisor_scope_query(extra) + queryset = queryset.filter(supervisor_scope) + + total_matched = queryset.count() + complaints = list(queryset[:MAX_REPORT_ROWS]) + is_truncated = total_matched > len(complaints) + + total_resolution_hours = 0.0 + resolved_count = 0 + compliant_count = 0 + reopen_count = 0 + feedback_count = 0 + + for complaint in complaints: + if str(getattr(complaint, 'feedback', '')).strip(): + feedback_count += 1 + + if complaint.reopen_requested or complaint.reopened_at or complaint.status == ComplaintStatus.REOPENED: + reopen_count += 1 + + resolution_time = complaint.closed_at or complaint.resolved_at + if resolution_time: + resolved_count += 1 + total_resolution_hours += (resolution_time - complaint.complaint_date).total_seconds() / 3600.0 + if complaint.sla_deadline and resolution_time <= complaint.sla_deadline: + compliant_count += 1 + + average_resolution = (total_resolution_hours / resolved_count) if resolved_count else 0.0 + sla_compliance_rate = ((compliant_count * 100.0) / resolved_count) if resolved_count else 0.0 + reopen_rate = ((reopen_count * 100.0) / len(complaints)) if complaints else 0.0 + feedback_response_rate = ((feedback_count * 100.0) / len(complaints)) if complaints else 0.0 + + status_logs = list( + ComplaintEvent.objects.filter(complaint__in=complaints) + .values('action') + .annotate(count=Count('id')) + .order_by('-count', 'action')[:10] + ) + + category_counter = Counter() + location_counter = Counter() + issue_cluster_counter = Counter() + trend = defaultdict(lambda: {'created': 0, 'resolved': 0, 'closed': 0, 'escalated': 0}) + + complaint_ids = [complaint.id for complaint in complaints] + escalation_events = ComplaintEvent.objects.filter( + complaint_id__in=complaint_ids, + action__in=('escalated', 'auto_escalated'), + ).values('created_at') + + for complaint in complaints: + category_counter[complaint.complaint_type] += 1 + location_counter[complaint.location] += 1 + issue_cluster_counter[(complaint.complaint_type, complaint.location)] += 1 + + created_day = complaint.complaint_date.date().isoformat() + trend[created_day]['created'] += 1 + + if complaint.resolved_at: + resolved_day = complaint.resolved_at.date().isoformat() + trend[resolved_day]['resolved'] += 1 + + if complaint.closed_at: + closed_day = complaint.closed_at.date().isoformat() + trend[closed_day]['closed'] += 1 + + for event in escalation_events: + event_day = event['created_at'].date().isoformat() + trend[event_day]['escalated'] += 1 + + recurring_issue_clusters = [ + { + 'complaint_type': complaint_type, + 'location': issue_location, + 'count': count, + } + for (complaint_type, issue_location), count in issue_cluster_counter.most_common(10) + if count > 1 + ] + + trend_series = [ + { + 'date': day, + 'created': values['created'], + 'resolved': values['resolved'], + 'closed': values['closed'], + 'escalated': values['escalated'], + } + for day, values in sorted(trend.items()) + ] + + return Response( + { + 'report_generated_at': timezone.now().isoformat(), + 'filters': { + 'date_from': date_from.isoformat() if date_from else '', + 'date_to': date_to.isoformat() if date_to else '', + 'category': category, + 'location': location, + }, + 'totals': { + 'complaint_count': len(complaints), + 'total_matched_count': total_matched, + 'is_truncated': is_truncated, + 'resolved_count': resolved_count, + 'feedback_count': feedback_count, + }, + 'kpis': { + 'avg_resolution_time_hours': round(_to_float(average_resolution), 2), + 'sla_compliance_rate': round(_to_float(sla_compliance_rate), 2), + 'reopen_rate': round(_to_float(reopen_rate), 2), + 'feedback_response_rate': round(_to_float(feedback_response_rate), 2), + }, + 'status_logs': status_logs, + 'analytics': { + 'category_hotspots': [ + {'category': category_name, 'count': count} + for category_name, count in category_counter.most_common(5) + ], + 'location_hotspots': [ + {'location': location_name, 'count': count} + for location_name, count in location_counter.most_common(5) + ], + 'recurring_issue_clusters': recurring_issue_clusters, + 'time_series': trend_series, + }, + 'complaints': serializers.StudentComplainSerializers(complaints, many=True).data, + }, + status=status.HTTP_200_OK, + ) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def submit_feedback_api(request, c_id): + user, extra = _get_request_extra_info(request) + complaint = get_object_or_404(StudentComplain, id=c_id) + + if complaint.is_draft: + return Response({'message': 'Draft complaints cannot accept feedback'}, status=status.HTTP_400_BAD_REQUEST) + + if not _is_complainant(extra, complaint) and not _is_superuser(user): + return Response({'message': 'Only complainant can submit feedback'}, status=status.HTTP_403_FORBIDDEN) + + if complaint.status != ComplaintStatus.CLOSED: + return Response({'message': 'Feedback can only be submitted after closure'}, status=status.HTTP_400_BAD_REQUEST) + + feedback = str(request.data.get('feedback', '')).strip() + if not feedback: + return Response({'message': 'feedback is required'}, status=status.HTTP_400_BAD_REQUEST) + + if len(feedback) > 500: + return Response({'message': 'feedback must be 500 characters or fewer'}, status=status.HTTP_400_BAD_REQUEST) + + rating = request.data.get('rating', None) + if rating in (None, ''): + rating = complaint.flag + else: + try: + rating = int(rating) + except (TypeError, ValueError): + return Response({'message': 'rating must be a number between 1 and 5'}, status=status.HTTP_400_BAD_REQUEST) + if rating < 1 or rating > 5: + return Response({'message': 'rating must be between 1 and 5'}, status=status.HTTP_400_BAD_REQUEST) + + complaint.feedback = feedback + complaint.flag = rating + complaint.save(update_fields=['feedback', 'flag', 'updated_at']) + + _log_complaint_event( + complaint, + action='feedback_submitted', + actor=extra, + from_status=complaint.status, + to_status=complaint.status, + note=feedback[:120], + metadata={'rating': rating}, + ) + return Response(_complaint_payload(complaint), status=status.HTTP_200_OK) diff --git a/FusionIIIT/applications/complaint_system/assignment_policy.py b/FusionIIIT/applications/complaint_system/assignment_policy.py new file mode 100644 index 000000000..8387e4461 --- /dev/null +++ b/FusionIIIT/applications/complaint_system/assignment_policy.py @@ -0,0 +1,62 @@ +"""Assignment policy lookup for complaint auto-assignment. + +This module keeps assignment policy decisions isolated from API views. +""" + +# Explicit policy overrides for high-signal routes. +# Key: (complaint_type, location) +ASSIGNMENT_POLICIES = { + ("internet", "hall-3"): { + "team": "hall-3-caretaker-team", + "strict_area": True, + "fallback_chain": ("area_and_category", "category_only", "any_worker"), + }, + ("Electricity", "Admin building"): { + "team": "admin-maintenance-team", + "strict_area": True, + "fallback_chain": ("area_and_category", "category_only", "any_worker"), + }, +} + +# Per-location defaults used when no explicit (category, location) policy is found. +LOCATION_DEFAULT_POLICY = { + "hall-1": { + "team": "hall-1-caretaker-team", + "strict_area": True, + "fallback_chain": ("area_and_category", "category_only", "any_worker"), + }, + "hall-3": { + "team": "hall-3-caretaker-team", + "strict_area": True, + "fallback_chain": ("area_and_category", "category_only", "any_worker"), + }, + "hall-4": { + "team": "hall-4-caretaker-team", + "strict_area": True, + "fallback_chain": ("area_and_category", "category_only", "any_worker"), + }, +} + + +def lookup_assignment_policy(complaint_type, location): + """Return assignment policy and source marker. + + Source values: + - explicit: exact category + location rule found + - location-default: location-wide default found + - global-default: safe catch-all fallback + """ + explicit = ASSIGNMENT_POLICIES.get((complaint_type, location)) + if explicit: + return {**explicit, "source": "explicit"} + + location_policy = LOCATION_DEFAULT_POLICY.get(location) + if location_policy: + return {**location_policy, "source": "location-default"} + + return { + "team": "general-maintenance-team", + "strict_area": False, + "fallback_chain": ("category_only", "any_worker"), + "source": "global-default", + } diff --git a/FusionIIIT/applications/complaint_system/escalation.py b/FusionIIIT/applications/complaint_system/escalation.py new file mode 100644 index 000000000..2e2037bd2 --- /dev/null +++ b/FusionIIIT/applications/complaint_system/escalation.py @@ -0,0 +1,173 @@ +import logging + +from django.db import transaction +from django.db.models import Q +from django.utils import timezone +from django.contrib.auth.models import User + +from notification.views import complaint_system_notif + +from applications.complaint_system.models import ComplaintEvent, ComplaintStatus, Supervisor +from applications.globals.models import HoldsDesignation + + +logger = logging.getLogger(__name__) + +AUTO_ESCALATION_NOTE = 'Automatically escalated after SLA breach' + + +def _get_supervisor_recipients(complaint): + recipients = [] + seen_ids = set() + + supervisors = Supervisor.objects.select_related('sup_id', 'sup_id__user').filter( + type=complaint.complaint_type, + ).filter( + Q(area='') | Q(area=complaint.location) + ) + + for supervisor in supervisors: + recipient = getattr(supervisor.sup_id, 'user', None) + if recipient is None or recipient.id in seen_ids: + continue + seen_ids.add(recipient.id) + recipients.append(recipient) + + return recipients + + +def _get_authority_and_admin_recipients(excluded_ids=None): + excluded_ids = set(excluded_ids or []) + recipients = [] + seen_ids = set(excluded_ids) + + # Always include Django superusers as admin-level fallback recipients. + for admin_user in User.objects.filter(is_superuser=True): + if admin_user.id in seen_ids: + continue + recipients.append(admin_user) + seen_ids.add(admin_user.id) + + # Include service authority style roles based on held designations. + authority_designations = HoldsDesignation.objects.select_related('working', 'designation').filter( + Q(designation__name__icontains='serviceauthority') + | Q(designation__name__icontains='service authority') + | Q(designation__name__icontains='convener') + | Q(designation__name__icontains='admin') + ) + for held in authority_designations: + recipient = held.working + if recipient is None or recipient.id in seen_ids: + continue + recipients.append(recipient) + seen_ids.add(recipient.id) + + return recipients + + +def notify_supervisors_about_escalation(complaint, reason, actor=None, automatic=False): + sender = getattr(actor, 'user', None) or getattr(getattr(complaint, 'complainer', None), 'user', None) + recipients = _get_supervisor_recipients(complaint) + + if not recipients: + logger.warning( + 'No supervisors found for complaint escalation', + extra={'complaint_id': complaint.id, 'complaint_type': complaint.complaint_type}, + ) + return 0 + + message_prefix = 'Automatically escalated' if automatic else 'Escalated' + message = f'{message_prefix} complaint {complaint.complaint_ref or complaint.id}: {reason}' + + for recipient in recipients: + complaint_system_notif( + sender, + recipient, + 'complaint_escalation', + complaint.id, + 0, + message, + ) + + authority_recipients = _get_authority_and_admin_recipients( + excluded_ids=[recipient.id for recipient in recipients], + ) + for recipient in authority_recipients: + complaint_system_notif( + sender, + recipient, + 'complaint_escalation', + complaint.id, + 0, + message, + ) + + complainer_user = getattr(getattr(complaint, 'complainer', None), 'user', None) + if complainer_user is not None: + complaint_system_notif( + sender, + complainer_user, + 'complaint_escalation', + complaint.id, + 1, + message, + ) + + assigned = getattr(complaint, 'assigned_to', None) + assigned_caretaker_user = None + if assigned is not None and assigned.secincharge_id is not None and assigned.secincharge_id.staff_id is not None: + assigned_caretaker_user = getattr(assigned.secincharge_id.staff_id, 'user', None) + + if assigned_caretaker_user is not None and assigned_caretaker_user.id != getattr(complainer_user, 'id', None): + complaint_system_notif( + sender, + assigned_caretaker_user, + 'complaint_escalation', + complaint.id, + 0, + message, + ) + + return len(recipients) + len(authority_recipients) + + +@transaction.atomic +def escalate_complaint_record(complaint, reason, actor=None, automatic=False): + escalation_reason = str(reason or '').strip() + if not escalation_reason: + raise ValueError('escalation_reason is required') + + if complaint.status == ComplaintStatus.ESCALATED: + raise ValueError('Complaint is already escalated') + + before_status = complaint.status + complaint.is_escalated = 1 + complaint.escalation_reason = escalation_reason + complaint.escalated_date = timezone.now() + complaint.status = ComplaintStatus.ESCALATED + complaint.save( + update_fields=['is_escalated', 'escalation_reason', 'escalated_date', 'status', 'updated_at'], + ) + + ComplaintEvent.objects.create( + complaint=complaint, + actor=actor, + action='auto_escalated' if automatic else 'escalated', + from_status=before_status, + to_status=ComplaintStatus.ESCALATED, + note=escalation_reason, + metadata={ + 'source': 'automatic' if automatic else 'manual', + }, + ) + + transaction.on_commit( + lambda: notify_supervisors_about_escalation( + complaint, + escalation_reason, + actor=actor, + automatic=automatic, + ) + ) + + return complaint diff --git a/FusionIIIT/applications/complaint_system/migrations/0002_studentcomplain_history.py b/FusionIIIT/applications/complaint_system/migrations/0002_studentcomplain_history.py new file mode 100644 index 000000000..ae268330b --- /dev/null +++ b/FusionIIIT/applications/complaint_system/migrations/0002_studentcomplain_history.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-24 02:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('complaint_system', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='studentcomplain', + name='history', + field=models.JSONField(blank=True, default=list, help_text='Track status changes and modifications'), + ), + ] diff --git a/FusionIIIT/applications/complaint_system/migrations/0003_auto_20260324_0300.py b/FusionIIIT/applications/complaint_system/migrations/0003_auto_20260324_0300.py new file mode 100644 index 000000000..f73c0c69e --- /dev/null +++ b/FusionIIIT/applications/complaint_system/migrations/0003_auto_20260324_0300.py @@ -0,0 +1,32 @@ +# Generated by Django 3.1.5 on 2026-03-24 03:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('complaint_system', '0002_studentcomplain_history'), + ] + + operations = [ + migrations.RemoveField( + model_name='studentcomplain', + name='history', + ), + migrations.AddField( + model_name='studentcomplain', + name='escalated_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='studentcomplain', + name='escalation_reason', + field=models.CharField(blank=True, default='', max_length=300), + ), + migrations.AddField( + model_name='studentcomplain', + name='is_escalated', + field=models.IntegerField(default=0), + ), + ] diff --git a/FusionIIIT/applications/complaint_system/migrations/0004_complaint_lifecycle.py b/FusionIIIT/applications/complaint_system/migrations/0004_complaint_lifecycle.py new file mode 100644 index 000000000..255f2472c --- /dev/null +++ b/FusionIIIT/applications/complaint_system/migrations/0004_complaint_lifecycle.py @@ -0,0 +1,95 @@ +# Generated by Copilot on 2026-04-07 + +from django.db import migrations, models +import django.utils.timezone + + +def backfill_complaint_ref(apps, schema_editor): + StudentComplain = apps.get_model('complaint_system', 'StudentComplain') + for complaint in StudentComplain.objects.all().order_by('id'): + if not complaint.complaint_ref: + complaint.complaint_ref = f"CMP-LEGACY-{complaint.id:06d}" + complaint.save(update_fields=['complaint_ref']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('complaint_system', '0003_auto_20260324_0300'), + ] + + operations = [ + migrations.AddField( + model_name='studentcomplain', + name='complaint_ref', + field=models.CharField(blank=True, max_length=32, null=True), + ), + migrations.AddField( + model_name='studentcomplain', + name='priority', + field=models.CharField(choices=[('Urgent', 'Urgent'), ('Standard', 'Standard'), ('Low', 'Low')], default='Standard', max_length=20), + ), + migrations.AddField( + model_name='studentcomplain', + name='sla_deadline', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='studentcomplain', + name='assigned_to', + field=models.ForeignKey(blank=True, null=True, on_delete=models.deletion.SET_NULL, related_name='assigned_complaints', to='complaint_system.workers'), + ), + migrations.AddField( + model_name='studentcomplain', + name='verification_source', + field=models.CharField(blank=True, default='', max_length=20), + ), + migrations.AddField( + model_name='studentcomplain', + name='reopen_requested', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='studentcomplain', + name='reopen_reason', + field=models.CharField(blank=True, default='', max_length=300), + ), + migrations.AddField( + model_name='studentcomplain', + name='reopen_requested_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='studentcomplain', + name='reopened_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='studentcomplain', + name='status', + field=models.IntegerField(choices=[(0, 'Pending'), (1, 'In Progress'), (2, 'Resolved'), (3, 'Closed'), (4, 'Escalated'), (5, 'Reopened')], default=0), + ), + migrations.RunPython(backfill_complaint_ref, migrations.RunPython.noop), + migrations.AlterField( + model_name='studentcomplain', + name='complaint_ref', + field=models.CharField(blank=True, max_length=32, unique=True), + ), + migrations.CreateModel( + name='ComplaintEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(max_length=50)), + ('from_status', models.IntegerField(blank=True, null=True)), + ('to_status', models.IntegerField(blank=True, null=True)), + ('note', models.TextField(blank=True, default='')), + ('metadata', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=False, default=django.utils.timezone.now)), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=models.deletion.SET_NULL, to='globals.extrainfo')), + ('complaint', models.ForeignKey(on_delete=models.deletion.CASCADE, related_name='events', to='complaint_system.studentcomplain')), + ], + options={ + 'ordering': ('created_at', 'id'), + }, + ), + ] diff --git a/FusionIIIT/applications/complaint_system/migrations/0005_complaint_team_and_timestamps.py b/FusionIIIT/applications/complaint_system/migrations/0005_complaint_team_and_timestamps.py new file mode 100644 index 000000000..e474e8219 --- /dev/null +++ b/FusionIIIT/applications/complaint_system/migrations/0005_complaint_team_and_timestamps.py @@ -0,0 +1,31 @@ +# Generated by Copilot on 2026-04-07 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('complaint_system', '0004_complaint_lifecycle'), + ] + + operations = [ + migrations.AddField( + model_name='studentcomplain', + name='assigned_team', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='studentcomplain', + name='updated_at', + field=models.DateTimeField(auto_now=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='complaintevent', + name='updated_at', + field=models.DateTimeField(auto_now=True, default=django.utils.timezone.now), + preserve_default=False, + ), + ] diff --git a/FusionIIIT/applications/complaint_system/migrations/0006_caretaker_progress_fields.py b/FusionIIIT/applications/complaint_system/migrations/0006_caretaker_progress_fields.py new file mode 100644 index 000000000..7fd2d4c4c --- /dev/null +++ b/FusionIIIT/applications/complaint_system/migrations/0006_caretaker_progress_fields.py @@ -0,0 +1,28 @@ +# Generated by Copilot on 2026-04-07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('complaint_system', '0005_complaint_team_and_timestamps'), + ] + + operations = [ + migrations.AddField( + model_name='studentcomplain', + name='estimated_resolution_time', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='studentcomplain', + name='progress_attachment', + field=models.FileField(blank=True, upload_to='complaint/progress/'), + ), + migrations.AddField( + model_name='studentcomplain', + name='progress_notes', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/FusionIIIT/applications/complaint_system/migrations/0007_resolution_verification.py b/FusionIIIT/applications/complaint_system/migrations/0007_resolution_verification.py new file mode 100644 index 000000000..1b2123697 --- /dev/null +++ b/FusionIIIT/applications/complaint_system/migrations/0007_resolution_verification.py @@ -0,0 +1,33 @@ +# Generated by Copilot on 2026-04-07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('complaint_system', '0006_caretaker_progress_fields'), + ] + + operations = [ + migrations.AddField( + model_name='studentcomplain', + name='resolved_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='studentcomplain', + name='closed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='studentcomplain', + name='verification_status', + field=models.CharField(choices=[('Pending', 'Pending'), ('Approved', 'Approved'), ('Rejected', 'Rejected')], default='Pending', max_length=20), + ), + migrations.AddField( + model_name='studentcomplain', + name='verification_notes', + field=models.TextField(blank=True, default=''), + ), + ] \ No newline at end of file diff --git a/FusionIIIT/applications/complaint_system/migrations/0008_complaint_drafts.py b/FusionIIIT/applications/complaint_system/migrations/0008_complaint_drafts.py new file mode 100644 index 000000000..17edfe7e7 --- /dev/null +++ b/FusionIIIT/applications/complaint_system/migrations/0008_complaint_drafts.py @@ -0,0 +1,23 @@ +# Generated by Copilot on 2026-04-13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('complaint_system', '0007_resolution_verification'), + ] + + operations = [ + migrations.AddField( + model_name='studentcomplain', + name='is_draft', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='studentcomplain', + name='submitted_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/FusionIIIT/applications/complaint_system/migrations/0009_supervisor_area_scope.py b/FusionIIIT/applications/complaint_system/migrations/0009_supervisor_area_scope.py new file mode 100644 index 000000000..7a9b0f5b7 --- /dev/null +++ b/FusionIIIT/applications/complaint_system/migrations/0009_supervisor_area_scope.py @@ -0,0 +1,18 @@ +# Generated by Copilot on 2026-04-14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('complaint_system', '0008_complaint_drafts'), + ] + + operations = [ + migrations.AddField( + model_name='supervisor', + name='area', + field=models.CharField(blank=True, default='', max_length=30), + ), + ] diff --git a/FusionIIIT/applications/complaint_system/migrations/0010_optional_workflow_extensions.py b/FusionIIIT/applications/complaint_system/migrations/0010_optional_workflow_extensions.py new file mode 100644 index 000000000..e7415030d --- /dev/null +++ b/FusionIIIT/applications/complaint_system/migrations/0010_optional_workflow_extensions.py @@ -0,0 +1,68 @@ +# Generated by Copilot on 2026-04-21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('complaint_system', '0009_supervisor_area_scope'), + ] + + operations = [ + migrations.AddField( + model_name='studentcomplain', + name='escalation_hierarchy_path', + field=models.CharField(blank=True, default='', max_length=200), + ), + migrations.AddField( + model_name='studentcomplain', + name='escalation_level', + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.AddField( + model_name='studentcomplain', + name='fast_track', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='studentcomplain', + name='last_sla_breach_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='studentcomplain', + name='preferred_language', + field=models.CharField(default='en', max_length=10), + ), + migrations.AddField( + model_name='studentcomplain', + name='sla_penalty_points', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='studentcomplain', + name='sla_violation_count', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='studentcomplain', + name='vendor_assigned_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='studentcomplain', + name='vendor_contact', + field=models.CharField(blank=True, default='', max_length=80), + ), + migrations.AddField( + model_name='studentcomplain', + name='vendor_name', + field=models.CharField(blank=True, default='', max_length=120), + ), + migrations.AddField( + model_name='studentcomplain', + name='vendor_reference', + field=models.CharField(blank=True, default='', max_length=120), + ), + ] diff --git a/FusionIIIT/applications/complaint_system/models.py b/FusionIIIT/applications/complaint_system/models.py index 47145fad3..bedaf63c3 100644 --- a/FusionIIIT/applications/complaint_system/models.py +++ b/FusionIIIT/applications/complaint_system/models.py @@ -1,6 +1,7 @@ # imports from django.db import models from django.utils import timezone +import uuid from applications.globals.models import ExtraInfo @@ -36,6 +37,48 @@ class Constants: ) +class ComplaintPriority: + URGENT = 'Urgent' + STANDARD = 'Standard' + LOW = 'Low' + + CHOICES = ( + (URGENT, URGENT), + (STANDARD, STANDARD), + (LOW, LOW), + ) + + +class VerificationStatus: + PENDING = 'Pending' + APPROVED = 'Approved' + REJECTED = 'Rejected' + + CHOICES = ( + (PENDING, PENDING), + (APPROVED, APPROVED), + (REJECTED, REJECTED), + ) + + +class ComplaintStatus: + PENDING = 0 + IN_PROGRESS = 1 + RESOLVED = 2 + CLOSED = 3 + ESCALATED = 4 + REOPENED = 5 + + CHOICES = ( + (PENDING, 'Pending'), + (IN_PROGRESS, 'In Progress'), + (RESOLVED, 'Resolved'), + (CLOSED, 'Closed'), + (ESCALATED, 'Escalated'), + (REOPENED, 'Reopened'), + ) + + class Caretaker(models.Model): staff_id = models.ForeignKey(ExtraInfo, on_delete=models.CASCADE) area = models.CharField(choices=Constants.AREA, max_length=20, default='hall-3') @@ -67,31 +110,84 @@ def __str__(self): class StudentComplain(models.Model): + complaint_ref = models.CharField(max_length=32, unique=True, blank=True) complainer = models.ForeignKey(ExtraInfo, on_delete=models.CASCADE) complaint_date = models.DateTimeField(default=timezone.now) + submitted_at = models.DateTimeField(blank=True, null=True) + is_draft = models.BooleanField(default=False) complaint_finish = models.DateField(blank=True, null=True) + priority = models.CharField(max_length=20, choices=ComplaintPriority.CHOICES, default=ComplaintPriority.STANDARD) + sla_deadline = models.DateTimeField(blank=True, null=True) + resolved_at = models.DateTimeField(blank=True, null=True) + closed_at = models.DateTimeField(blank=True, null=True) + verification_status = models.CharField(max_length=20, choices=VerificationStatus.CHOICES, default=VerificationStatus.PENDING) complaint_type = models.CharField(choices=Constants.COMPLAINT_TYPE, max_length=20, default='internet') location = models.CharField(max_length=20, choices=Constants.AREA) specific_location = models.CharField(max_length=50, blank=True) details = models.CharField(max_length=100) - status = models.IntegerField(default='0') + status = models.IntegerField(choices=ComplaintStatus.CHOICES, default=ComplaintStatus.PENDING) remarks = models.CharField(max_length=300, default="Pending") flag = models.IntegerField(default='0') reason = models.CharField(max_length=100, blank=True, default="None") feedback = models.CharField(max_length=500, blank=True) worker_id = models.ForeignKey(Workers, blank=True, null=True,on_delete=models.CASCADE) + assigned_to = models.ForeignKey(Workers, blank=True, null=True, related_name='assigned_complaints', on_delete=models.SET_NULL) + assigned_team = models.CharField(max_length=100, blank=True, default='') upload_complaint = models.FileField(blank=True) + progress_attachment = models.FileField(blank=True, upload_to='complaint/progress/') comment = models.CharField(max_length=100, default="None") + progress_notes = models.TextField(blank=True, default='') + estimated_resolution_time = models.DateTimeField(blank=True, null=True) + is_escalated = models.IntegerField(default=0) # 0=Not escalated, 1=Escalated + escalation_reason = models.CharField(max_length=300, blank=True, default="") + escalated_date = models.DateTimeField(blank=True, null=True) + verification_source = models.CharField(max_length=20, blank=True, default='') + verification_notes = models.TextField(blank=True, default='') + reopen_requested = models.BooleanField(default=False) + reopen_reason = models.CharField(max_length=300, blank=True, default='') + reopen_requested_at = models.DateTimeField(blank=True, null=True) + reopened_at = models.DateTimeField(blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) #upload_resolved = models.FileField(blank=True,null=True) def __str__(self): return str(self.complainer.user.username) + def save(self, *args, **kwargs): + if not self.complaint_ref: + prefix = 'DRF' if self.is_draft else 'CMP' + self.complaint_ref = f"{prefix}-{timezone.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8].upper()}" + + if self.sla_deadline and not self.complaint_finish: + self.complaint_finish = self.sla_deadline.date() + + super().save(*args, **kwargs) + + +class ComplaintEvent(models.Model): + complaint = models.ForeignKey(StudentComplain, related_name='events', on_delete=models.CASCADE) + actor = models.ForeignKey(ExtraInfo, blank=True, null=True, on_delete=models.SET_NULL) + action = models.CharField(max_length=50) + from_status = models.IntegerField(blank=True, null=True) + to_status = models.IntegerField(blank=True, null=True) + note = models.TextField(blank=True, default='') + metadata = models.JSONField(blank=True, default=dict) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ('created_at', 'id') + + def __str__(self): + return f"{self.complaint_id}:{self.action}:{self.created_at.isoformat()}" + class Supervisor(models.Model): sup_id = models.ForeignKey(ExtraInfo, on_delete=models.CASCADE) type = models.CharField(choices=Constants.COMPLAINT_TYPE, max_length=30,default='Electricity') + area = models.CharField(max_length=30, blank=True, default='') def __str__(self): - return str(self.sup_id) + '-' + str(self.type) + scope = self.area or 'all-areas' + return str(self.sup_id) + '-' + str(self.type) + '-' + scope diff --git a/FusionIIIT/applications/complaint_system/notifications.py b/FusionIIIT/applications/complaint_system/notifications.py new file mode 100644 index 000000000..0e98d1736 --- /dev/null +++ b/FusionIIIT/applications/complaint_system/notifications.py @@ -0,0 +1,269 @@ +from django.db.models import Q +from notification.views import complaint_system_notif + +from applications.complaint_system.models import ComplaintEvent, ComplaintStatus, Supervisor, Caretaker + + +STATUS_LABELS = { + ComplaintStatus.PENDING: 'Pending', + ComplaintStatus.IN_PROGRESS: 'In Progress', + ComplaintStatus.RESOLVED: 'Resolved', + ComplaintStatus.CLOSED: 'Closed', + ComplaintStatus.ESCALATED: 'Escalated', + ComplaintStatus.REOPENED: 'Reopened', +} + + +def _notify(sender_user, recipient_user, complaint, message, student_view=True): + if recipient_user is None: + return + + complaint_system_notif( + sender_user, + recipient_user, + 'complaint_update', + complaint.id, + 1 if student_view else 0, + message, + ) + + actor = getattr(complaint, 'complainer', None) + if sender_user is not None and actor is not None and getattr(actor, 'user_id', None) != sender_user.id: + actor = None + + ComplaintEvent.objects.create( + complaint=complaint, + actor=actor, + action='notification_sent', + from_status=complaint.status, + to_status=complaint.status, + note=message[:200], + metadata={ + 'recipient_user_id': recipient_user.id, + 'recipient_username': recipient_user.username, + 'channel': 'in_app', + 'student_view': bool(student_view), + }, + ) + + +def _assigned_caretaker_user(complaint): + worker = complaint.assigned_to + if worker is None or worker.secincharge_id is None or worker.secincharge_id.staff_id is None: + return None + return getattr(worker.secincharge_id.staff_id, 'user', None) + + +def _worker_user(worker): + if worker is None or worker.secincharge_id is None or worker.secincharge_id.staff_id is None: + return None + return getattr(worker.secincharge_id.staff_id, 'user', None) + + +def _location_caretaker_users(complaint): + recipients = [] + seen = set() + queryset = Caretaker.objects.select_related('staff_id', 'staff_id__user').filter(area=complaint.location) + for caretaker in queryset: + user = getattr(caretaker.staff_id, 'user', None) + if user is None or user.id in seen: + continue + recipients.append(user) + seen.add(user.id) + return recipients + + +def _supervisor_users(complaint): + recipients = [] + seen = set() + queryset = Supervisor.objects.select_related('sup_id', 'sup_id__user').filter( + Q(type=complaint.complaint_type) & (Q(area='') | Q(area=complaint.location)) + ) + for supervisor in queryset: + user = getattr(supervisor.sup_id, 'user', None) + if user is None or user.id in seen: + continue + recipients.append(user) + seen.add(user.id) + return recipients + + +def notify_complaint_created(complaint, actor=None): + sender_user = getattr(actor, 'user', None) or getattr(complaint.complainer, 'user', None) + complainer_user = getattr(complaint.complainer, 'user', None) + + _notify( + sender_user, + complainer_user, + complaint, + f'Complaint {complaint.complaint_ref or complaint.id} created successfully. Current status: Pending.', + student_view=True, + ) + + notified_ids = {getattr(complainer_user, 'id', None)} + + for c_user in _location_caretaker_users(complaint): + if c_user.id not in notified_ids: + _notify( + sender_user, + c_user, + complaint, + f'New complaint registered in your area: {complaint.complaint_ref or complaint.id} ({complaint.complaint_type} at {complaint.location}).', + student_view=False, + ) + notified_ids.add(c_user.id) + + for s_user in _supervisor_users(complaint): + if s_user.id not in notified_ids: + _notify( + sender_user, + s_user, + complaint, + f'New complaint registered in your department: {complaint.complaint_ref or complaint.id} ({complaint.complaint_type} at {complaint.location}).', + student_view=False, + ) + notified_ids.add(s_user.id) + + +def notify_status_change(complaint, from_status, to_status, actor=None, remarks=''): + sender_user = getattr(actor, 'user', None) or getattr(complaint.complainer, 'user', None) + complainer_user = getattr(complaint.complainer, 'user', None) + + from_label = STATUS_LABELS.get(from_status, str(from_status)) + to_label = STATUS_LABELS.get(to_status, str(to_status)) + suffix = f' Note: {remarks}' if str(remarks or '').strip() else '' + message = f'Complaint {complaint.complaint_ref or complaint.id} moved from {from_label} to {to_label}.{suffix}' + + _notify(sender_user, complainer_user, complaint, message, student_view=True) + + notified_ids = {getattr(complainer_user, 'id', None)} + + for c_user in _location_caretaker_users(complaint): + if c_user.id not in notified_ids: + _notify(sender_user, c_user, complaint, message, student_view=False) + notified_ids.add(c_user.id) + + for s_user in _supervisor_users(complaint): + if s_user.id not in notified_ids: + _notify(sender_user, s_user, complaint, message, student_view=False) + notified_ids.add(s_user.id) + + +def notify_reopen_requested(complaint, actor=None, reason=''): + sender_user = getattr(actor, 'user', None) or getattr(complaint.complainer, 'user', None) + message = ( + f'Reopen requested for complaint {complaint.complaint_ref or complaint.id}. ' + f'Reason: {reason or "No reason provided."}' + ) + + for recipient in _supervisor_users(complaint): + _notify(sender_user, recipient, complaint, message, student_view=False) + + +def notify_reopen_approved(complaint, actor=None, reason=''): + sender_user = getattr(actor, 'user', None) or getattr(complaint.complainer, 'user', None) + complainer_user = getattr(complaint.complainer, 'user', None) + caretaker_user = _assigned_caretaker_user(complaint) + message = ( + f'Complaint {complaint.complaint_ref or complaint.id} has been reopened. ' + f'Reason: {reason or "No reason provided."}' + ) + + _notify(sender_user, complainer_user, complaint, message, student_view=True) + if caretaker_user: + _notify(sender_user, caretaker_user, complaint, message, student_view=False) + + +def notify_verification_result(complaint, actor=None, decision='approve', notes=''): + sender_user = getattr(actor, 'user', None) or getattr(complaint.complainer, 'user', None) + complainer_user = getattr(complaint.complainer, 'user', None) + caretaker_user = _assigned_caretaker_user(complaint) + + if decision == 'approve': + message = ( + f'Complaint {complaint.complaint_ref or complaint.id} was verified and closed.' + f'{" Note: " + notes if str(notes or "").strip() else ""}' + ) + else: + message = ( + f'Complaint {complaint.complaint_ref or complaint.id} verification was rejected and reopened.' + f'{" Note: " + notes if str(notes or "").strip() else ""}' + ) + + _notify(sender_user, complainer_user, complaint, message, student_view=True) + + if caretaker_user and caretaker_user.id != getattr(complainer_user, 'id', None): + _notify(sender_user, caretaker_user, complaint, message, student_view=False) + + for supervisor_user in _supervisor_users(complaint): + if supervisor_user.id in { + getattr(complainer_user, 'id', None), + getattr(caretaker_user, 'id', None), + }: + continue + _notify(sender_user, supervisor_user, complaint, message, student_view=False) + + +def notify_assignment_change(complaint, actor=None, previous_worker=None, note=''): + sender_user = getattr(actor, 'user', None) or getattr(complaint.complainer, 'user', None) + complainer_user = getattr(complaint.complainer, 'user', None) + previous_worker_user = _worker_user(previous_worker) + current_worker_user = _assigned_caretaker_user(complaint) + + previous_label = ( + getattr(previous_worker, 'name', None) + or getattr(previous_worker_user, 'username', None) + or 'unassigned' + ) + current_label = ( + getattr(complaint.assigned_to, 'name', None) + or getattr(current_worker_user, 'username', None) + or 'unassigned' + ) + suffix = f' Note: {note}' if str(note or '').strip() else '' + message = ( + f'Complaint {complaint.complaint_ref or complaint.id} reassigned from {previous_label} ' + f'to {current_label}.{suffix}' + ) + + _notify(sender_user, complainer_user, complaint, message, student_view=True) + + if current_worker_user and current_worker_user.id != getattr(complainer_user, 'id', None): + _notify(sender_user, current_worker_user, complaint, message, student_view=False) + + if previous_worker_user and previous_worker_user.id not in { + getattr(complainer_user, 'id', None), + getattr(current_worker_user, 'id', None), + }: + _notify(sender_user, previous_worker_user, complaint, message, student_view=False) + + for supervisor_user in _supervisor_users(complaint): + if supervisor_user.id in { + getattr(complainer_user, 'id', None), + getattr(current_worker_user, 'id', None), + getattr(previous_worker_user, 'id', None), + }: + continue + _notify(sender_user, supervisor_user, complaint, message, student_view=False) + + +def notify_sla_deadline_reminder(complaint, hours_remaining): + sender_user = getattr(complaint.complainer, 'user', None) + complainer_user = getattr(complaint.complainer, 'user', None) + caretaker_user = _assigned_caretaker_user(complaint) + + message = ( + f'Complaint {complaint.complaint_ref or complaint.id} is nearing SLA deadline ' + f'({hours_remaining} hours remaining). Please prioritize resolution.' + ) + + if caretaker_user: + _notify(sender_user, caretaker_user, complaint, message, student_view=False) + + for supervisor_user in _supervisor_users(complaint): + if supervisor_user.id in { + getattr(complainer_user, 'id', None), + getattr(caretaker_user, 'id', None), + }: + continue + _notify(sender_user, supervisor_user, complaint, message, student_view=False) diff --git a/FusionIIIT/applications/complaint_system/tasks.py b/FusionIIIT/applications/complaint_system/tasks.py new file mode 100644 index 000000000..23ec2b112 --- /dev/null +++ b/FusionIIIT/applications/complaint_system/tasks.py @@ -0,0 +1,113 @@ +import logging +from math import ceil + +from celery import shared_task +from django.utils import timezone +from datetime import timedelta + +from applications.complaint_system.escalation import AUTO_ESCALATION_NOTE, escalate_complaint_record +from applications.complaint_system.models import ComplaintEvent, ComplaintStatus, StudentComplain +from applications.complaint_system.notifications import notify_sla_deadline_reminder + + +logger = logging.getLogger(__name__) + +SLA_REMINDER_WINDOW_HOURS = 4 + + +@shared_task +def send_sla_deadline_reminders(): + now = timezone.now() + reminder_deadline = now + timedelta(hours=SLA_REMINDER_WINDOW_HOURS) + + candidate_complaints = StudentComplain.objects.select_related( + 'complainer', + 'complainer__user', + 'assigned_to', + 'assigned_to__secincharge_id', + 'assigned_to__secincharge_id__staff_id', + 'assigned_to__secincharge_id__staff_id__user', + ).filter( + sla_deadline__isnull=False, + sla_deadline__gt=now, + sla_deadline__lte=reminder_deadline, + is_escalated=0, + status__in=( + ComplaintStatus.PENDING, + ComplaintStatus.IN_PROGRESS, + ComplaintStatus.REOPENED, + ), + ) + + reminded_ids = [] + for complaint in candidate_complaints: + sla_deadline_key = complaint.sla_deadline.isoformat() + already_reminded = ComplaintEvent.objects.filter( + complaint=complaint, + action='sla_reminder_sent', + metadata__sla_deadline=sla_deadline_key, + ).exists() + + if already_reminded: + continue + + hours_remaining = max( + 1, + int(ceil((complaint.sla_deadline - now).total_seconds() / 3600)), + ) + + notify_sla_deadline_reminder(complaint, hours_remaining) + ComplaintEvent.objects.create( + complaint=complaint, + actor=None, + action='sla_reminder_sent', + from_status=complaint.status, + to_status=complaint.status, + note='SLA deadline reminder sent', + metadata={ + 'sla_deadline': sla_deadline_key, + 'hours_remaining': hours_remaining, + 'source': 'automatic', + }, + ) + reminded_ids.append(complaint.id) + + return { + 'reminder_count': len(reminded_ids), + 'reminder_ids': reminded_ids, + } + + +@shared_task +def escalate_overdue_complaints(): + now = timezone.now() + overdue_complaints = StudentComplain.objects.select_related('complainer', 'complainer__user').filter( + sla_deadline__lt=now, + is_escalated=0, + status__in=( + ComplaintStatus.PENDING, + ComplaintStatus.IN_PROGRESS, + ComplaintStatus.REOPENED, + ), + ) + + escalated_ids = [] + for complaint in overdue_complaints: + try: + escalate_complaint_record( + complaint, + AUTO_ESCALATION_NOTE, + actor=None, + automatic=True, + ) + escalated_ids.append(complaint.id) + except ValueError as exc: + logger.info( + 'Skipping automatic complaint escalation', + extra={'complaint_id': complaint.id, 'reason': str(exc)}, + ) + + return { + 'escalated_count': len(escalated_ids), + 'escalated_ids': escalated_ids, + } \ No newline at end of file diff --git a/FusionIIIT/applications/complaint_system/tests.py b/FusionIIIT/applications/complaint_system/tests.py index e9137c85e..73447c471 100644 --- a/FusionIIIT/applications/complaint_system/tests.py +++ b/FusionIIIT/applications/complaint_system/tests.py @@ -1,3 +1,852 @@ -# from django.test import TestCase +from django.contrib.auth.models import User +from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone +from datetime import timedelta +from unittest.mock import patch +from rest_framework import status +from rest_framework.test import APITestCase -# Create your tests here. +from applications.complaint_system.models import ( + Caretaker, + ComplaintEvent, + ComplaintStatus, + SectionIncharge, + StudentComplain, + VerificationStatus, + Supervisor, + Workers, +) +from applications.globals.models import DepartmentInfo, ExtraInfo + + +class ComplaintApiTests(APITestCase): + def setUp(self): + self.department = DepartmentInfo.objects.create(name='CSE-Test') + + self.student_user = User.objects.create_user(username='student1', password='pass123') + self.student_extra = ExtraInfo.objects.create( + id='stu001', + user=self.student_user, + user_type='student', + department=self.department, + ) + + self.other_student_user = User.objects.create_user(username='student2', password='pass123') + self.other_student_extra = ExtraInfo.objects.create( + id='stu002', + user=self.other_student_user, + user_type='student', + department=self.department, + ) + + self.staff_user = User.objects.create_user(username='staff1', password='pass123') + self.staff_extra = ExtraInfo.objects.create( + id='stf001', + user=self.staff_user, + user_type='staff', + department=self.department, + ) + Caretaker.objects.create(staff_id=self.staff_extra, area='hall-3') + self.secincharge = SectionIncharge.objects.create( + staff_id=self.staff_extra, + work_type='internet', + ) + self.internet_worker = Workers.objects.create( + secincharge_id=self.secincharge, + name='Internet Worker', + age='32', + phone=9999999999, + worker_type='internet', + ) + + self.faculty_user = User.objects.create_user(username='faculty1', password='pass123') + self.faculty_extra = ExtraInfo.objects.create( + id='fac001', + user=self.faculty_user, + user_type='faculty', + department=self.department, + ) + Supervisor.objects.create(sup_id=self.faculty_extra, type='internet') + + self.superuser = User.objects.create_superuser( + username='admin1', email='admin@test.com', password='pass123' + ) + self.super_extra = ExtraInfo.objects.create( + id='adm001', + user=self.superuser, + user_type='staff', + department=self.department, + ) + + self.complaint_1 = StudentComplain.objects.create( + complainer=self.student_extra, + complaint_type='internet', + location='hall-3', + details='Wifi not working', + specific_location='Room 101', + ) + self.complaint_2 = StudentComplain.objects.create( + complainer=self.other_student_extra, + complaint_type='plumber', + location='hall-1', + details='Leakage in washroom', + specific_location='Ground floor', + ) + + def _auth(self, user): + self.client.force_authenticate(user=user) + + def test_list_requires_authentication(self): + response = self.client.get('/complaint/api/studentcomplain') + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_student_list_returns_only_own_complaints(self): + self._auth(self.student_user) + response = self.client.get('/complaint/api/studentcomplain') + self.assertEqual(response.status_code, status.HTTP_200_OK) + complaints = response.data['student_complain'] + self.assertEqual(len(complaints), 1) + self.assertEqual(complaints[0]['id'], self.complaint_1.id) + + def test_caretaker_list_filters_by_area(self): + self._auth(self.staff_user) + response = self.client.get('/complaint/api/studentcomplain') + self.assertEqual(response.status_code, status.HTTP_200_OK) + complaints = response.data['student_complain'] + self.assertEqual(len(complaints), 1) + self.assertEqual(complaints[0]['location'], 'hall-3') + + def test_supervisor_list_filters_by_type(self): + self._auth(self.faculty_user) + response = self.client.get('/complaint/api/studentcomplain') + self.assertEqual(response.status_code, status.HTTP_200_OK) + complaints = response.data['student_complain'] + self.assertEqual(len(complaints), 1) + self.assertEqual(complaints[0]['complaint_type'], 'internet') + + def test_supervisor_list_includes_all_mapped_supervisor_types(self): + Supervisor.objects.create(sup_id=self.faculty_extra, type='plumber') + self._auth(self.faculty_user) + response = self.client.get('/complaint/api/studentcomplain') + self.assertEqual(response.status_code, status.HTTP_200_OK) + complaints = response.data['student_complain'] + self.assertEqual(len(complaints), 2) + types = sorted([item['complaint_type'] for item in complaints]) + self.assertEqual(types, ['internet', 'plumber']) + + def test_supervisor_area_scope_filters_complaints(self): + Supervisor.objects.filter(sup_id=self.faculty_extra, type='internet').update(area='hall-3') + self.complaint_2.complaint_type = 'internet' + self.complaint_2.location = 'hall-1' + self.complaint_2.save(update_fields=['complaint_type', 'location']) + + self._auth(self.faculty_user) + response = self.client.get('/complaint/api/studentcomplain') + self.assertEqual(response.status_code, status.HTTP_200_OK) + complaints = response.data['student_complain'] + self.assertEqual(len(complaints), 1) + self.assertEqual(complaints[0]['location'], 'hall-3') + + def test_create_sets_logged_in_user_as_complainer(self): + self._auth(self.student_user) + payload = { + 'complaint_type': 'garbage', + 'location': 'hall-3', + 'specific_location': 'Near lift', + 'details': 'Garbage not cleared', + 'priority': 'Standard', + # Even if payload tries to spoof complainer, backend should ignore it. + 'complainer': self.other_student_extra.id, + } + response = self.client.post('/complaint/api/newcomplain', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + created = StudentComplain.objects.get(id=response.data['id']) + self.assertEqual(created.complainer_id, self.student_extra.id) + self.assertTrue(created.complaint_ref) + self.assertGreaterEqual(len(created.complaint_ref), 10) + self.assertTrue(created.sla_deadline) + self.assertTrue(ComplaintEvent.objects.filter(complaint=created, action='created').exists()) + self.assertEqual(created.assigned_to_id, self.internet_worker.id) + + def test_sla_deadline_respects_priority(self): + self._auth(self.student_user) + payload = { + 'complaint_type': 'internet', + 'location': 'hall-3', + 'specific_location': 'Room 102', + 'details': 'No internet', + 'priority': 'Urgent', + } + response = self.client.post('/complaint/api/newcomplain', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + created = StudentComplain.objects.get(id=response.data['id']) + delta_hours = (created.sla_deadline - created.complaint_date).total_seconds() / 3600 + self.assertLessEqual(delta_hours, 25) + self.assertGreaterEqual(delta_hours, 23) + + def test_assignment_falls_back_to_any_worker_when_no_category_match(self): + self._auth(self.student_user) + payload = { + 'complaint_type': 'garbage', + 'location': 'NR3', + 'specific_location': 'Main gate', + 'details': 'Garbage pileup', + 'priority': 'Low', + } + response = self.client.post('/complaint/api/newcomplain', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + created = StudentComplain.objects.get(id=response.data['id']) + self.assertEqual(created.assigned_to_id, self.internet_worker.id) + event = ComplaintEvent.objects.filter(complaint=created, action='created').first() + self.assertIsNotNone(event) + self.assertEqual(event.metadata.get('assignment_strategy'), 'any_worker') + + def test_create_requires_category_location_and_description(self): + self._auth(self.student_user) + payload = { + 'complaint_type': '', + 'location': '', + 'details': '', + } + response = self.client.post('/complaint/api/newcomplain', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('complaint_type', response.data) + + def test_create_rejects_invalid_attachment_type(self): + self._auth(self.student_user) + payload = { + 'complaint_type': 'internet', + 'location': 'hall-3', + 'specific_location': 'Room 110', + 'details': 'File type validation', + 'priority': 'Standard', + 'upload_complaint': SimpleUploadedFile( + 'bad.exe', + b'bad-content', + content_type='application/x-msdownload', + ), + } + response = self.client.post('/complaint/api/newcomplain', payload, format='multipart') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('upload_complaint', response.data) + + def test_create_rejects_oversized_attachment(self): + self._auth(self.student_user) + payload = { + 'complaint_type': 'internet', + 'location': 'hall-3', + 'specific_location': 'Room 110', + 'details': 'File size validation', + 'priority': 'Standard', + 'upload_complaint': SimpleUploadedFile( + 'large.pdf', + b'a' * (5 * 1024 * 1024 + 1), + content_type='application/pdf', + ), + } + response = self.client.post('/complaint/api/newcomplain', payload, format='multipart') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('upload_complaint', response.data) + + def test_create_draft_allows_partial_payload_and_skips_sla(self): + self._auth(self.student_user) + payload = { + 'complaint_type': 'internet', + 'location': 'hall-3', + 'details': '', + 'is_draft': True, + } + response = self.client.post('/complaint/api/newcomplain', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + draft = StudentComplain.objects.get(id=response.data['id']) + self.assertTrue(draft.is_draft) + self.assertIsNone(draft.sla_deadline) + self.assertTrue(str(draft.complaint_ref).startswith('DRF-')) + self.assertTrue(ComplaintEvent.objects.filter(complaint=draft, action='draft_saved').exists()) + + def test_drafts_are_hidden_from_caretaker_queue(self): + StudentComplain.objects.create( + complainer=self.student_extra, + complaint_type='internet', + location='hall-3', + details='Draft complaint', + is_draft=True, + ) + self._auth(self.staff_user) + response = self.client.get('/complaint/api/studentcomplain') + self.assertEqual(response.status_code, status.HTTP_200_OK) + for item in response.data['student_complain']: + self.assertFalse(item.get('is_draft')) + + def test_submit_draft_starts_sla_and_assignment(self): + draft = StudentComplain.objects.create( + complainer=self.student_extra, + complaint_type='internet', + location='hall-3', + details='Saved draft details', + is_draft=True, + ) + self._auth(self.student_user) + response = self.client.post(f'/complaint/api/submitdraft/{draft.id}', {}, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + draft.refresh_from_db() + self.assertFalse(draft.is_draft) + self.assertIsNotNone(draft.submitted_at) + self.assertIsNotNone(draft.sla_deadline) + self.assertTrue(str(draft.complaint_ref).startswith('CMP-')) + self.assertEqual(draft.assigned_to_id, self.internet_worker.id) + self.assertTrue(ComplaintEvent.objects.filter(complaint=draft, action='draft_submitted').exists()) + + def test_report_analytics_returns_kpis_and_status_logs(self): + self.complaint_1.complaint_date = timezone.now() - timedelta(hours=10) + self.complaint_1.sla_deadline = timezone.now() - timedelta(hours=1) + self.complaint_1.resolved_at = timezone.now() - timedelta(hours=2) + self.complaint_1.status = 2 + self.complaint_1.feedback = 'Resolved quickly' + self.complaint_1.save(update_fields=['complaint_date', 'sla_deadline', 'resolved_at', 'status', 'feedback']) + + self.complaint_2.complaint_date = timezone.now() - timedelta(hours=100) + self.complaint_2.sla_deadline = timezone.now() - timedelta(hours=90) + self.complaint_2.closed_at = timezone.now() - timedelta(hours=10) + self.complaint_2.status = 3 + self.complaint_2.reopen_requested = True + self.complaint_2.save(update_fields=['complaint_date', 'sla_deadline', 'closed_at', 'status', 'reopen_requested']) + + ComplaintEvent.objects.create(complaint=self.complaint_1, actor=self.staff_extra, action='status_updated') + ComplaintEvent.objects.create(complaint=self.complaint_1, actor=self.staff_extra, action='status_updated') + ComplaintEvent.objects.create(complaint=self.complaint_2, actor=self.staff_extra, action='verified_and_closed') + + self._auth(self.superuser) + response = self.client.get('/complaint/api/report-analytics') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['totals']['complaint_count'], 2) + self.assertEqual(response.data['totals']['resolved_count'], 2) + self.assertEqual(response.data['totals']['feedback_count'], 1) + self.assertAlmostEqual(response.data['kpis']['avg_resolution_time_hours'], 49.0, delta=0.2) + self.assertEqual(response.data['kpis']['sla_compliance_rate'], 50.0) + self.assertEqual(response.data['kpis']['reopen_rate'], 50.0) + self.assertEqual(response.data['kpis']['feedback_response_rate'], 50.0) + actions = {entry['action']: entry['count'] for entry in response.data['status_logs']} + self.assertEqual(actions.get('status_updated'), 2) + self.assertIn('analytics', response.data) + self.assertIn('category_hotspots', response.data['analytics']) + self.assertIn('location_hotspots', response.data['analytics']) + self.assertIn('recurring_issue_clusters', response.data['analytics']) + self.assertIn('time_series', response.data['analytics']) + + def test_report_analytics_rejects_invalid_date_range(self): + self._auth(self.superuser) + response = self.client.get('/complaint/api/report-analytics?date_from=2026-04-20&date_to=2026-04-01') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_report_analytics_denies_non_supervisor_user(self): + self._auth(self.student_user) + response = self.client.get('/complaint/api/report-analytics') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_report_analytics_for_supervisor_is_scoped_to_supervisor_types(self): + self._auth(self.faculty_user) + response = self.client.get('/complaint/api/report-analytics') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['totals']['complaint_count'], 1) + self.assertEqual(response.data['complaints'][0]['complaint_type'], 'internet') + + def test_escalation_rejects_empty_reason(self): + self._auth(self.staff_user) + response = self.client.post( + f'/complaint/api/escalate/{self.complaint_1.id}', + {'escalation_reason': ''}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.complaint_1.refresh_from_db() + self.assertEqual(self.complaint_1.status, 0) + + @patch('applications.complaint_system.escalation.complaint_system_notif') + def test_manual_escalation_logs_history_and_notifies_supervisor(self, mocked_notif): + self._auth(self.staff_user) + response = self.client.post( + f'/complaint/api/escalate/{self.complaint_1.id}', + {'escalation_reason': 'Needs supervisor review'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.complaint_1.refresh_from_db() + self.assertEqual(self.complaint_1.status, 4) + event = ComplaintEvent.objects.filter(complaint=self.complaint_1, action='escalated').first() + self.assertIsNotNone(event) + self.assertEqual(event.note, 'Needs supervisor review') + self.assertTrue(mocked_notif.called) + + def test_supervisor_can_update_escalated_complaint(self): + self._auth(self.staff_user) + response = self.client.post( + f'/complaint/api/escalate/{self.complaint_1.id}', + {'escalation_reason': 'Needs supervisor resolution'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self._auth(self.faculty_user) + response = self.client.post( + f'/complaint/api/caretaker-action/{self.complaint_1.id}', + { + 'status': ComplaintStatus.RESOLVED, + 'remarks': 'Supervisor resolved the escalated issue', + 'progress_notes': 'Issue resolved after escalation review', + }, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.complaint_1.refresh_from_db() + self.assertEqual(self.complaint_1.status, ComplaintStatus.RESOLVED) + self.assertEqual(self.complaint_1.remarks, 'Supervisor resolved the escalated issue') + event = ComplaintEvent.objects.filter( + complaint=self.complaint_1, + action='caretaker_progress_update', + ).first() + self.assertIsNotNone(event) + + @patch('applications.complaint_system.escalation.complaint_system_notif') + def test_auto_escalation_job_escalates_overdue_complaints(self, mocked_notif): + self.complaint_1.sla_deadline = timezone.now() - timedelta(hours=1) + self.complaint_1.save(update_fields=['sla_deadline']) + + from applications.complaint_system.tasks import escalate_overdue_complaints + + result = escalate_overdue_complaints() + self.assertEqual(result['escalated_count'], 1) + self.complaint_1.refresh_from_db() + self.assertEqual(self.complaint_1.status, 4) + event = ComplaintEvent.objects.filter(complaint=self.complaint_1, action='auto_escalated').first() + self.assertIsNotNone(event) + self.assertEqual(event.metadata.get('source'), 'automatic') + self.assertTrue(mocked_notif.called) + + @patch('applications.complaint_system.notifications.complaint_system_notif') + def test_sla_reminder_job_notifies_before_breach_and_logs_event(self, mocked_notif): + self.complaint_1.sla_deadline = timezone.now() + timedelta(hours=2) + self.complaint_1.assigned_to = self.internet_worker + self.complaint_1.save(update_fields=['sla_deadline', 'assigned_to']) + + from applications.complaint_system.tasks import send_sla_deadline_reminders + + result = send_sla_deadline_reminders() + self.assertEqual(result['reminder_count'], 1) + self.assertIn(self.complaint_1.id, result['reminder_ids']) + event = ComplaintEvent.objects.filter(complaint=self.complaint_1, action='sla_reminder_sent').first() + self.assertIsNotNone(event) + self.assertEqual(event.metadata.get('source'), 'automatic') + self.assertTrue(mocked_notif.called) + + @patch('applications.complaint_system.notifications.complaint_system_notif') + def test_sla_reminder_job_does_not_duplicate_for_same_deadline(self, mocked_notif): + self.complaint_1.sla_deadline = timezone.now() + timedelta(hours=3) + self.complaint_1.assigned_to = self.internet_worker + self.complaint_1.save(update_fields=['sla_deadline', 'assigned_to']) + + from applications.complaint_system.tasks import send_sla_deadline_reminders + + first = send_sla_deadline_reminders() + second = send_sla_deadline_reminders() + + self.assertEqual(first['reminder_count'], 1) + self.assertEqual(second['reminder_count'], 0) + self.assertEqual( + ComplaintEvent.objects.filter(complaint=self.complaint_1, action='sla_reminder_sent').count(), + 1, + ) + + def test_detail_denies_unrelated_student(self): + self._auth(self.other_student_user) + response = self.client.get(f'/complaint/api/user/detail/{self.complaint_1.id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_owner_can_update_description_but_cannot_change_status(self): + self._auth(self.student_user) + + update_response = self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'details': 'Wifi down since morning'}, + format='json', + ) + self.assertEqual(update_response.status_code, status.HTTP_200_OK) + self.complaint_1.refresh_from_db() + self.assertEqual(self.complaint_1.details, 'Wifi down since morning') + + status_update_response = self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 1}, + format='json', + ) + self.assertEqual(status_update_response.status_code, status.HTTP_403_FORBIDDEN) + self.complaint_1.refresh_from_db() + self.assertEqual(self.complaint_1.status, 0) + + def test_caretaker_can_change_status(self): + self._auth(self.staff_user) + + in_progress_response = self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 1, 'remarks': 'started'}, + format='json', + ) + self.assertEqual(in_progress_response.status_code, status.HTTP_200_OK) + + update_response = self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 2}, + format='json', + ) + self.assertEqual(update_response.status_code, status.HTTP_200_OK) + self.complaint_1.refresh_from_db() + self.assertEqual(self.complaint_1.status, 2) + + def test_invalid_status_transition_is_rejected(self): + self._auth(self.staff_user) + response = self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 2}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_resolution_requires_remarks(self): + self._auth(self.staff_user) + response = self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 1, 'remarks': ' '}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_closure_requires_verification_endpoint(self): + self._auth(self.staff_user) + response = self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 3, 'verification_source': 'complainant'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_verify_closes_only_resolved_complaints(self): + self._auth(self.staff_user) + + not_resolved = self.client.post( + f'/complaint/api/verify/{self.complaint_1.id}', + {'verification_source': 'complainant'}, + format='json', + ) + self.assertEqual(not_resolved.status_code, status.HTTP_400_BAD_REQUEST) + + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 1, 'remarks': 'in progress'}, + format='json', + ) + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 2, 'remarks': 'resolved'}, + format='json', + ) + + self._auth(self.student_user) + verified = self.client.post( + f'/complaint/api/verify/{self.complaint_1.id}', + {'verification_source': 'complainant', 'verification_decision': 'approve'}, + format='json', + ) + self.assertEqual(verified.status_code, status.HTTP_200_OK) + self.complaint_1.refresh_from_db() + self.assertEqual(self.complaint_1.status, 3) + self.assertEqual(self.complaint_1.verification_status, VerificationStatus.APPROVED) + + def test_feedback_submission_requires_closed_status_and_complainant(self): + self._auth(self.student_user) + not_closed = self.client.post( + f'/complaint/api/feedback/{self.complaint_1.id}', + {'feedback': 'good', 'rating': 4}, + format='json', + ) + self.assertEqual(not_closed.status_code, status.HTTP_400_BAD_REQUEST) + + self._auth(self.staff_user) + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 1, 'remarks': 'in progress'}, + format='json', + ) + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 2, 'remarks': 'resolved'}, + format='json', + ) + + self._auth(self.student_user) + self.client.post( + f'/complaint/api/verify/{self.complaint_1.id}', + {'verification_source': 'complainant', 'verification_decision': 'approve'}, + format='json', + ) + + self._auth(self.other_student_user) + forbidden = self.client.post( + f'/complaint/api/feedback/{self.complaint_1.id}', + {'feedback': 'not owner', 'rating': 3}, + format='json', + ) + self.assertEqual(forbidden.status_code, status.HTTP_403_FORBIDDEN) + + self._auth(self.student_user) + ok = self.client.post( + f'/complaint/api/feedback/{self.complaint_1.id}', + {'feedback': 'Issue fixed properly', 'rating': 5}, + format='json', + ) + self.assertEqual(ok.status_code, status.HTTP_200_OK) + self.complaint_1.refresh_from_db() + self.assertEqual(self.complaint_1.feedback, 'Issue fixed properly') + self.assertEqual(self.complaint_1.flag, 5) + self.assertTrue( + ComplaintEvent.objects.filter(complaint=self.complaint_1, action='feedback_submitted').exists() + ) + + def test_supervisor_can_reject_a_resolved_complaint(self): + self._auth(self.staff_user) + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 1, 'remarks': 'in progress'}, + format='json', + ) + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 2, 'remarks': 'resolved'}, + format='json', + ) + + self._auth(self.faculty_user) + response = self.client.post( + f'/complaint/api/verify/{self.complaint_1.id}', + { + 'verification_source': 'supervisor', + 'verification_decision': 'reject', + 'verification_notes': 'Issue still persists', + }, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.complaint_1.refresh_from_db() + self.assertEqual(self.complaint_1.status, 5) + self.assertEqual(self.complaint_1.verification_status, VerificationStatus.REJECTED) + self.assertTrue(self.complaint_1.reopen_requested) + + def test_reopen_requires_non_empty_reason(self): + self._auth(self.staff_user) + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 1, 'remarks': 'in progress'}, + format='json', + ) + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 2, 'remarks': 'resolved'}, + format='json', + ) + + # Complainant must request reopen, not the caretaker + self._auth(self.student_user) + response = self.client.post( + f'/complaint/api/reopen/{self.complaint_1.id}', + {'reopen_reason': ' '}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_non_matching_supervisor_cannot_reopen(self): + self._auth(self.staff_user) + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_2.id}', + {'status': 1, 'remarks': 'in progress'}, + format='json', + ) + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_2.id}', + {'status': 2, 'remarks': 'resolved'}, + format='json', + ) + + self._auth(self.faculty_user) + response = self.client.post( + f'/complaint/api/reopen/{self.complaint_2.id}', + {'reopen_reason': 'Try reopen plumber complaint'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_reopen_rejects_expired_window(self): + self._auth(self.student_user) + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 1, 'remarks': 'in progress'}, + format='json', + ) + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 2, 'remarks': 'resolved'}, + format='json', + ) + self.complaint_1.refresh_from_db() + self.complaint_1.resolved_at = timezone.now() - timedelta(days=8) + self.complaint_1.save(update_fields=['resolved_at']) + + response = self.client.post( + f'/complaint/api/reopen/{self.complaint_1.id}', + {'reopen_reason': 'Still broken'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_assigned_caretaker_can_submit_progress_update(self): + self._auth(self.staff_user) + self.complaint_1.assigned_to = self.internet_worker + self.complaint_1.save(update_fields=['assigned_to']) + + response = self.client.post( + f'/complaint/api/caretaker-action/{self.complaint_1.id}', + { + 'status': 1, + 'remarks': 'Started troubleshooting', + 'progress_notes': 'Checked router and local switch', + }, + format='multipart', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.complaint_1.refresh_from_db() + self.assertEqual(self.complaint_1.status, 1) + self.assertEqual(self.complaint_1.remarks, 'Started troubleshooting') + + def test_unassigned_caretaker_cannot_submit_progress_update(self): + other_staff = User.objects.create_user(username='staff2', password='pass123') + other_extra = ExtraInfo.objects.create( + id='stf002', + user=other_staff, + user_type='staff', + department=self.department, + ) + Caretaker.objects.create(staff_id=other_extra, area='hall-1') + + self._auth(other_staff) + self.complaint_1.assigned_to = self.internet_worker + self.complaint_1.save(update_fields=['assigned_to']) + + response = self.client.post( + f'/complaint/api/caretaker-action/{self.complaint_1.id}', + {'status': 1, 'remarks': 'Attempted update'}, + format='multipart', + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_caretaker_progress_update_rejects_invalid_transition(self): + self._auth(self.staff_user) + self.complaint_1.assigned_to = self.internet_worker + self.complaint_1.save(update_fields=['assigned_to']) + + response = self.client.post( + f'/complaint/api/caretaker-action/{self.complaint_1.id}', + {'status': 2, 'remarks': 'Resolved directly'}, + format='multipart', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_owner_can_delete(self): + self._auth(self.student_user) + + delete_response = self.client.delete(f'/complaint/api/removecomplain/{self.complaint_1.id}') + self.assertEqual(delete_response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(StudentComplain.objects.filter(id=self.complaint_1.id).exists()) + + @patch('applications.complaint_system.notifications.complaint_system_notif') + def test_create_complaint_sends_notifications(self, mocked_notif): + self._auth(self.student_user) + payload = { + 'complaint_type': 'internet', + 'location': 'hall-3', + 'specific_location': 'Room 110', + 'details': 'Frequent disconnects', + 'priority': 'Standard', + } + response = self.client.post('/complaint/api/newcomplain', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(mocked_notif.called) + + @patch('applications.complaint_system.notifications.complaint_system_notif') + def test_status_update_sends_notifications(self, mocked_notif): + self._auth(self.staff_user) + response = self.client.post( + f'/complaint/api/caretaker-action/{self.complaint_1.id}', + {'status': 1, 'remarks': 'Work started'}, + format='multipart', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(mocked_notif.called) + + @patch('applications.complaint_system.notifications.complaint_system_notif') + def test_verification_and_reopen_notifications_are_sent(self, mocked_notif): + self._auth(self.staff_user) + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 1, 'remarks': 'in progress'}, + format='json', + ) + self.client.put( + f'/complaint/api/updatecomplain/{self.complaint_1.id}', + {'status': 2, 'remarks': 'resolved'}, + format='json', + ) + + self._auth(self.student_user) + verify_resp = self.client.post( + f'/complaint/api/verify/{self.complaint_1.id}', + {'verification_source': 'complainant', 'verification_decision': 'approve'}, + format='json', + ) + self.assertEqual(verify_resp.status_code, status.HTTP_200_OK) + self.assertTrue(mocked_notif.called) + + mocked_notif.reset_mock() + reopen_resp = self.client.post( + f'/complaint/api/reopen/{self.complaint_1.id}', + {'reopen_reason': 'Issue still present'}, + format='json', + ) + self.assertEqual(reopen_resp.status_code, status.HTTP_200_OK) + self.assertTrue(mocked_notif.called) + + @patch('applications.complaint_system.notifications.complaint_system_notif') + def test_bulk_reassign_updates_assignment_and_notifies(self, mocked_notif): + new_worker = Workers.objects.create( + secincharge_id=self.secincharge, + name='Replacement Worker', + age='29', + phone=8888888888, + worker_type='internet', + ) + + self._auth(self.faculty_user) + response = self.client.post( + '/complaint/api/bulk-action', + { + 'action': 'reassign', + 'complaint_ids': [self.complaint_1.id], + 'assigned_to': new_worker.id, + 'assigned_team': 'Night shift', + 'remarks': 'Reassigned for follow up', + }, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.complaint_1.refresh_from_db() + self.assertEqual(self.complaint_1.assigned_to_id, new_worker.id) + self.assertEqual(self.complaint_1.assigned_team, 'Night shift') + self.assertTrue(ComplaintEvent.objects.filter(complaint=self.complaint_1, action='bulk_reassigned').exists()) + self.assertTrue(mocked_notif.called) diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0026_add_database_indexes.py b/FusionIIIT/applications/programme_curriculum/migrations/0026_add_database_indexes.py index 2afda5843..b734ebdf9 100644 --- a/FusionIIIT/applications/programme_curriculum/migrations/0026_add_database_indexes.py +++ b/FusionIIIT/applications/programme_curriculum/migrations/0026_add_database_indexes.py @@ -13,28 +13,27 @@ class Migration(migrations.Migration): operations = [ # Add database indexes for optimized query performance + # Safe migration that checks if table exists before creating indexes migrations.RunSQL( sql=[ - # Main composite index for course registration queries + # Check if course_registration table exists and create indexes only if it does """ - CREATE INDEX IF NOT EXISTS idx_course_reg_main_query - ON course_registration(session, semester_type, course_id_id, registration_type, student_id_id); - """, - - # Individual indexes for course registration - """ - CREATE INDEX IF NOT EXISTS idx_course_reg_session_semester_course - ON course_registration(session, semester_type, course_id_id); - """, - - """ - CREATE INDEX IF NOT EXISTS idx_course_reg_student - ON course_registration(student_id_id); - """, - - """ - CREATE INDEX IF NOT EXISTS idx_course_reg_type - ON course_registration(registration_type); + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'course_registration' + ) THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS idx_course_reg_main_query + ON course_registration(session, semester_type, course_id_id, registration_type, student_id_id)'; + EXECUTE 'CREATE INDEX IF NOT EXISTS idx_course_reg_session_semester_course + ON course_registration(session, semester_type, course_id_id)'; + EXECUTE 'CREATE INDEX IF NOT EXISTS idx_course_reg_student + ON course_registration(student_id_id)'; + EXECUTE 'CREATE INDEX IF NOT EXISTS idx_course_reg_type + ON course_registration(registration_type)'; + END IF; + END $$; """ ], diff --git a/FusionIIIT/create_admin.py b/FusionIIIT/create_admin.py new file mode 100644 index 000000000..5efbf708e --- /dev/null +++ b/FusionIIIT/create_admin.py @@ -0,0 +1,31 @@ +from django.contrib.auth.models import User +from applications.globals.models import Designation, HoldsDesignation, ModuleAccess + +try: + # Get the user + user = User.objects.get(username='23bcs201') + + # Create or get the designation + designation, _ = Designation.objects.get_or_create( + name='complaint_admin', + defaults={'full_name': 'Complaint System Admin', 'type': 'administrative'} + ) + + # Create module access so it appears in UI if necessary + ModuleAccess.objects.get_or_create( + designation='complaint_admin', + defaults={'complaint_management': True} + ) + + # Assign the designation to the user + HoldsDesignation.objects.get_or_create( + user=user, + working=user, + designation=designation + ) + + print("Successfully assigned complaint_admin to 23bcs201") +except User.DoesNotExist: + print("User 23bcs201 does not exist.") +except Exception as e: + print(f"Error occurred: {e}") diff --git a/FusionIIIT/fix_caretakers.py b/FusionIIIT/fix_caretakers.py new file mode 100644 index 000000000..f7a9f3df5 --- /dev/null +++ b/FusionIIIT/fix_caretakers.py @@ -0,0 +1,67 @@ +""" +Script to assign HoldsDesignation entries to all Caretakers in the complaint system +so that the role-switcher dropdown recognizes them. +""" +from django.contrib.auth.models import User +from applications.globals.models import ExtraInfo, Designation, HoldsDesignation +from applications.complaint_system.models import Caretaker + +# Area -> Designation name mapping +AREA_DESIGNATION_MAP = { + 'hall-1': 'hall1caretaker', + 'hall-3': 'hall3caretaker', + 'hall-4': 'hall4caretaker', + 'core_lab': 'corelabcaretaker', + 'LHTC': 'lhtccaretaker', + 'NR2': 'nr2caretaker', + 'Rewa_Residency': 'rewacaretaker', + 'Maa Saraswati Hostel': 'mshcaretaker', + 'Nagarjun Hostel': 'nhcaretaker', + 'Panini Hostel': 'phcaretaker', +} + +caretakers = Caretaker.objects.all() +created_count = 0 +skipped_count = 0 +error_count = 0 + +for ct in caretakers: + staff_id = ct.staff_id_id + area = ct.area + + # Find the ExtraInfo and its User + try: + extra = ExtraInfo.objects.get(id=staff_id) + user = extra.user + except ExtraInfo.DoesNotExist: + print(f" SKIP: ExtraInfo '{staff_id}' not found") + error_count += 1 + continue + + # Find the matching designation + desig_name = AREA_DESIGNATION_MAP.get(area) + if not desig_name: + # Fallback: create a generic caretaker designation for this area + safe_area = area.lower().replace(' ', '').replace('_', '') + desig_name = f"{safe_area}caretaker" + + desig, _ = Designation.objects.get_or_create( + name=desig_name, + defaults={'full_name': f'Caretaker ({area})', 'type': 'staff'} + ) + + # Create HoldsDesignation if not already present + obj, created = HoldsDesignation.objects.get_or_create( + user=user, + working=user, + designation=desig, + ) + + if created: + print(f" CREATED: {user.username} -> {desig_name} ({area})") + created_count += 1 + else: + print(f" EXISTS: {user.username} -> {desig_name} ({area})") + skipped_count += 1 + +print(f"\nDone! Created: {created_count}, Already existed: {skipped_count}, Errors: {error_count}") diff --git a/FusionIIIT/fix_supervisors.py b/FusionIIIT/fix_supervisors.py new file mode 100644 index 000000000..99f4d8ee9 --- /dev/null +++ b/FusionIIIT/fix_supervisors.py @@ -0,0 +1,52 @@ +""" +Script to assign HoldsDesignation entries to all Supervisors in the complaint system +so that the role-switcher dropdown recognizes them. +""" +from django.contrib.auth.models import User +from applications.globals.models import ExtraInfo, Designation, HoldsDesignation +from applications.complaint_system.models import Supervisor + +supervisors = Supervisor.objects.all() +created_count = 0 +skipped_count = 0 +error_count = 0 + +for sup in supervisors: + staff_id = sup.sup_id_id + comp_type = sup.type + + # Find the ExtraInfo and its User + try: + extra = ExtraInfo.objects.get(id=staff_id) + user = extra.user + except ExtraInfo.DoesNotExist: + print(f" SKIP: ExtraInfo '{staff_id}' not found") + error_count += 1 + continue + + # Format designation name based on type + if comp_type.lower() == 'electricity': + desig_name = 'Electricitysupervisor' + else: + desig_name = f"{comp_type.lower()}supervisor" + + desig, _ = Designation.objects.get_or_create( + name=desig_name, + defaults={'full_name': f'{comp_type.capitalize()} Supervisor', 'type': 'staff'} + ) + + # Create HoldsDesignation if not already present + obj, created = HoldsDesignation.objects.get_or_create( + user=user, + working=user, + designation=desig, + ) + + if created: + print(f" CREATED: {user.username} -> {desig_name}") + created_count += 1 + else: + print(f" EXISTS: {user.username} -> {desig_name}") + skipped_count += 1 + +print(f"\nDone! Created: {created_count}, Already existed: {skipped_count}, Errors: {error_count}") diff --git a/drop_testdb.py b/drop_testdb.py new file mode 100644 index 000000000..34205a7a8 --- /dev/null +++ b/drop_testdb.py @@ -0,0 +1,30 @@ +import psycopg2 + +try: + conn = psycopg2.connect( + dbname='fusionlab', + user='fusion_admin', + password='hello123', + host='localhost' + ) + conn.autocommit = True + cur = conn.cursor() + + # Drop problematic table + try: + cur.execute('DROP TABLE IF EXISTS central_mess_payments CASCADE') + print('Dropped central_mess_payments') + except: + pass + + # Drop test database + try: + cur.execute('DROP DATABASE IF EXISTS test_fusionlab') + print('Test database dropped') + except: + pass + + conn.close() + print('Cleanup complete') +except Exception as e: + print(f'Error: {e}')