From 768bb25c9b3bc180dafee83fa5622acf09f800ca Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Thu, 30 Jul 2026 10:07:17 +0530 Subject: [PATCH] Examination: add API to add courses to slots and enhance allocation logic to handle skipped courses --- .../academic_information/api/urls.py | 1 + .../academic_information/api/views.py | 58 +++++++++- .../academic_information/utils.py | 105 ++++++++++++++++-- 3 files changed, 154 insertions(+), 10 deletions(-) diff --git a/FusionIIIT/applications/academic_information/api/urls.py b/FusionIIIT/applications/academic_information/api/urls.py index 44246dc03..4f17d9017 100644 --- a/FusionIIIT/applications/academic_information/api/urls.py +++ b/FusionIIIT/applications/academic_information/api/urls.py @@ -20,6 +20,7 @@ # url(r'^delete-calendar',views.delete_calendar,name='calendar-delete-api'), url(r'^check-allocation$', views.check_allocation_api, name='check-allocation-api'), url(r'^start-allocation$', views.start_allocation_api, name='start-allocation-api'), + url(r'^add-course-to-slots$', views.add_course_to_slots_api, name='add-course-to-slots-api'), url(r'^allocation-results$', views.get_allocation_results, name='allocation-results-api'), url(r'^allocation-results/export$', views.export_allocation_course, name='allocation-results-export-api'), url(r'^allocation-results/export-all$', views.export_all_allocation_courses, name='allocation-results-export-all-api'), diff --git a/FusionIIIT/applications/academic_information/api/views.py b/FusionIIIT/applications/academic_information/api/views.py index 6cfd4ec95..d18acaf72 100644 --- a/FusionIIIT/applications/academic_information/api/views.py +++ b/FusionIIIT/applications/academic_information/api/views.py @@ -32,7 +32,7 @@ from django.db.models import Q from applications.academic_procedures.api.views import role_required from django.core.cache import cache -from django.db import connection +from django.db import connection, transaction logger = logging.getLogger(__name__) @@ -318,8 +318,18 @@ def start_allocation_api(request): batch = int(batch) semester = int(semester) + skip_course_ids = data.get("skip_course_ids") or [] + if not isinstance(skip_course_ids, list): + return JsonResponse({"error": "skip_course_ids must be a list"}, status=400) + try: + skip_course_ids = [int(c) for c in skip_course_ids] + except (TypeError, ValueError): + return JsonResponse({"error": "skip_course_ids must be course ids"}, status=400) + mock_request = type('MockRequest', (), {})() - mock_request.POST = {'batch': batch, 'sem': semester, 'year': year, 'programme_type': programme_type} + mock_request.POST = {'batch': batch, 'sem': semester, 'year': year, + 'programme_type': programme_type, + 'skip_course_ids': skip_course_ids} return allocate(mock_request) @@ -330,6 +340,50 @@ def start_allocation_api(request): return JsonResponse({"error": str(e)}, status=500) +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +@role_required(['acadadmin']) +def add_course_to_slots_api(request): + """Adds one course to the CourseSlot rows it was flagged as missing from.""" + course_id = request.data.get('course_id') + slot_ids = request.data.get('slot_ids') or [] + + if not course_id or not slot_ids: + return Response({'status': -1, 'message': 'course_id and slot_ids are required'}, + status=status.HTTP_400_BAD_REQUEST) + if not isinstance(slot_ids, list): + return Response({'status': -1, 'message': 'slot_ids must be a list'}, + status=status.HTTP_400_BAD_REQUEST) + + try: + course = Courses.objects.get(id=int(course_id)) + except (Courses.DoesNotExist, TypeError, ValueError): + return Response({'status': -1, 'message': 'No such course'}, + status=status.HTTP_404_NOT_FOUND) + + slots = CourseSlot.objects.filter(id__in=[int(s) for s in slot_ids]) + found = {s.id for s in slots} + missing = [s for s in slot_ids if int(s) not in found] + if missing: + return Response({'status': -1, 'message': f'Unknown course slot(s): {missing}'}, + status=status.HTTP_404_NOT_FOUND) + + added = [] + with transaction.atomic(): + for slot in slots: + if not slot.courses.filter(id=course.id).exists(): + slot.courses.add(course) + added.append({'slot_id': slot.id, 'slot_name': slot.name}) + + return Response({ + 'status': 1, + 'message': f'{course.code} added to {len(added)} slot(s)', + 'course_code': course.code, + 'added': added, + }, status=status.HTTP_200_OK) + + def _semester_type(sem): return "Even Semester" if int(sem) % 2 == 0 else "Odd Semester" diff --git a/FusionIIIT/applications/academic_information/utils.py b/FusionIIIT/applications/academic_information/utils.py index 456344e36..ea28420eb 100644 --- a/FusionIIIT/applications/academic_information/utils.py +++ b/FusionIIIT/applications/academic_information/utils.py @@ -10,11 +10,72 @@ from django.http import HttpResponse, JsonResponse from django.utils import timezone from django.core import serializers -from django.db.models import Q +from django.db.models import Q, Count import datetime import random from django.db import transaction time = timezone.now() + + +def validate_course_slots(batch, sem, programme_type, skip_course_ids=None): + """ + Finds courses registered under a CourseSlot that does not list them. + + random_algo pools students by slot *name* across curricula, so a course in + one curriculum's slot but absent from another's gets allotted into a slot + that does not contain it. Empty list means allocation is safe to proceed. + """ + skip_course_ids = {int(c) for c in (skip_course_ids or [])} + + pairs = (InitialRegistration.objects + .filter(Q(semester_id__semester_no=sem) + & Q(student_id__batch=batch) + & Q(student_id__batch_id__curriculum__programme__category=programme_type)) + .exclude(course_id__isnull=True) + .exclude(course_slot_id__isnull=True) + .values('course_id', 'course_slot_id') + .annotate(students=Count('id'))) + pairs = [p for p in pairs if p['course_id'] not in skip_course_ids] + if not pairs: + return [] + + slot_ids = {p['course_slot_id'] for p in pairs} + valid = set(CourseSlot.courses.through.objects + .filter(courseslot_id__in=slot_ids) + .values_list('courseslot_id', 'course_id')) + + offending = [p for p in pairs if (p['course_slot_id'], p['course_id']) not in valid] + if not offending: + return [] + + courses = {c.id: c for c in Course.objects.filter( + id__in={p['course_id'] for p in offending})} + slots = {s.id: s for s in CourseSlot.objects + .filter(id__in={p['course_slot_id'] for p in offending}) + .select_related('semester', 'semester__curriculum')} + + problems = {} + for p in offending: + course, slot = courses[p['course_id']], slots[p['course_slot_id']] + entry = problems.setdefault((course.id, slot.name), { + 'course_id': course.id, + 'course_code': course.code, + 'course_name': course.name, + 'slot_name': slot.name, + 'slot_type': slot.type, + 'missing_from': [], + 'students': 0, + }) + entry['missing_from'].append({ + 'slot_id': slot.id, + 'semester_no': slot.semester.semester_no, + 'curriculum_id': slot.semester.curriculum_id, + 'curriculum_name': str(slot.semester.curriculum.name), + 'students': p['students'], + }) + entry['students'] += p['students'] + + return sorted(problems.values(), key=lambda e: -e['students']) def check_for_registration_complete(batch, sem, year, programme_type): date = datetime.date.today() try: @@ -40,7 +101,10 @@ def check_for_registration_complete(batch, sem, year, programme_type): return {"status": -3, "message": f"Internal Server Error: {str(e)}"} @transaction.atomic -def random_algo(batch,sem,year,course_slot, programme_type) : +def random_algo(batch,sem,year,course_slot, programme_type, skip_course_ids=None) : + # zero seats makes the "slot full" branch below push these students to + # their next priority, so skipping reuses the existing fall-through + skip_course_ids = {int(c) for c in (skip_course_ids or [])} unique_course = InitialRegistration.objects.filter(Q(semester_id__semester_no = sem) & Q( course_slot_id__name = course_slot ) & Q(student_id__batch = batch) & Q(student_id__batch_id__curriculum__programme__category=programme_type)).values_list('course_id',flat=True).distinct() max_seats={} seats_alloted = {} @@ -48,8 +112,11 @@ def random_algo(batch,sem,year,course_slot, programme_type) : next_priority = {} total_seats = 0 for course in unique_course : - max_seats[course] = Course.objects.get(id=course).max_seats - total_seats+=max_seats[course] + if course in skip_course_ids : + max_seats[course] = 0 + else : + max_seats[course] = Course.objects.get(id=course).max_seats + total_seats+=max_seats[course] seats_alloted[course] = FinalRegistration.objects.filter( course_id_id=course, semester_id__semester_no=sem, @@ -108,12 +175,23 @@ def allocate(request): sem = request.POST.get('sem') year = request.POST.get('year') programme_type = request.POST.get('programme_type') + skip_course_ids = {int(c) for c in (request.POST.get('skip_course_ids') or [])} + + # write nothing until every mis-slotted course is added or skipped + problems = validate_course_slots(batch, sem, programme_type, skip_course_ids) + if problems: + return JsonResponse({ + 'status': 0, + 'message': "Some registered courses are missing from their course slot.", + 'needs_action': problems, + }, status=409) unique_course_slot = InitialRegistration.objects.filter( Q(semester_id__semester_no=sem) & Q(student_id__batch=batch) & Q(student_id__batch_id__curriculum__programme__category=programme_type) ).values('course_slot_id').distinct() unique_course_name = [] + skipped_students = 0 try: with transaction.atomic(): @@ -143,6 +221,11 @@ def allocate(request): student_id_id=student_id ).values_list('course_id', flat=True).first() + # no alternative in a single-choice slot + if course_id in skip_course_ids: + skipped_students += 1 + continue + course = Course.objects.get(id=course_id) prev_registration_id = InitialRegistration.objects.filter( @@ -167,15 +250,21 @@ def allocate(request): elif course_slot_object.type == "Open Elective": if course_slot_object.name not in unique_course_name: - stat = random_algo(batch, sem, year, course_slot_object.name, programme_type) + stat = random_algo(batch, sem, year, course_slot_object.name, + programme_type, skip_course_ids) unique_course_name.append(course_slot_object.name) if stat == -1: raise Exception(f"Seats not enough for course_slot {course_slot_object.name}") - return JsonResponse({'status': 1, 'message': "Course allocation successful"}) + message = "Course allocation successful" + if skip_course_ids: + message += (f" ({len(skip_course_ids)} course(s) skipped" + f"; {skipped_students} student(s) left without a course in a single-choice slot)") + return JsonResponse({'status': 1, 'message': message, + 'skipped_course_ids': sorted(skip_course_ids)}) - except: - return JsonResponse({'status': -1, 'message': "Seats not enough for some course_slot"}) + except Exception as e: + return JsonResponse({'status': -1, 'message': str(e) or "Allocation failed"}) def view_alloted_course(request) : batch = request.POST.get('batch')