diff --git a/FusionIIIT/applications/academic_information/api/views.py b/FusionIIIT/applications/academic_information/api/views.py index 15bb5fce7..81f6df721 100644 --- a/FusionIIIT/applications/academic_information/api/views.py +++ b/FusionIIIT/applications/academic_information/api/views.py @@ -727,7 +727,7 @@ def generate_xlsheet_api(request): c.code as course_code, c.name as course_name, s.programme, - s.section as section + COALESCE(ci.section_label, s.section) as section FROM course_registration cr INNER JOIN globals_extrainfo ei ON cr.student_id_id = ei.id INNER JOIN auth_user u ON ei.user_id = u.id @@ -735,6 +735,7 @@ def generate_xlsheet_api(request): LEFT JOIN programme_curriculum_batch b ON s.batch_id_id = b.id LEFT JOIN programme_curriculum_discipline d ON b.discipline_id = d.id INNER JOIN programme_curriculum_course c ON cr.course_id_id = c.id + LEFT JOIN programme_curriculum_courseinstructor ci ON cr.course_instructor_id = ci.id WHERE cr.session = %s AND cr.semester_type = %s AND cr.course_id_id = %s @@ -760,9 +761,9 @@ def generate_xlsheet_api(request): sql += " AND s.programme = %s" params.append(programme_type) - # Section filter (A-F): scope the roll list to one section's students. + # Section = the course offering's section (course_instructor), else home section. if section: - sql += " AND s.section = %s" + sql += " AND COALESCE(ci.section_label, s.section) = %s" params.append(section) sql += " ORDER BY u.username" diff --git a/FusionIIIT/applications/academic_procedures/api/views.py b/FusionIIIT/applications/academic_procedures/api/views.py index b59fcb581..4278ae9ac 100644 --- a/FusionIIIT/applications/academic_procedures/api/views.py +++ b/FusionIIIT/applications/academic_procedures/api/views.py @@ -288,6 +288,30 @@ def add_course(request): current_year = datetime.datetime.now().year session, semester_type = generate_current_session(current_year, student.curr_semester_no) + working_year = parse_academic_year(academic_year=session, semester_type=semester_type)[0] + + # Resolve the offering (section) to register into. Prefer the student's own + # section; if that section doesn't run the course, they must pick one of the + # running sections (course_instructor id) — a cross-section backlog/improvement. + from applications.academic_information.models import resolve_offering + offering = resolve_offering(student, course, working_year, semester_type) + ci_id = request.data.get('course_instructor_id') + if ci_id: + offering = CourseInstructor.objects.filter( + id=ci_id, course_id=course, year=working_year, semester_type=semester_type, + ).first() + if not offering: + return Response({ + 'error': 'Selected section is not running this course this semester.' + }, status=status.HTTP_400_BAD_REQUEST) + elif offering is None: + running = CourseInstructor.objects.filter( + course_id=course, year=working_year, semester_type=semester_type, + ).exists() + if running: + return Response({ + 'error': 'This course is not running in your section. Please select a section to register in.' + }, status=status.HTTP_400_BAD_REQUEST) old_course_reg = course_registration.objects.filter( student_id=student, @@ -316,6 +340,7 @@ def add_course(request): academic_year=session, semester_type=semester_type, old_course_registration=old_course_reg, + course_instructor=offering, status='Pending' ) except Exception as create_error: @@ -500,17 +525,40 @@ def get_student_add_courses(request): name__startswith='BL' ) + # Current term, to find where each course is actually running this sem. + cur_year = datetime.datetime.now().year + session, sem_type = generate_current_session(cur_year, current_sem_no) + working_year = parse_academic_year(academic_year=session, semester_type=sem_type)[0] + student_section = student.section or '' + courses_list = [] - + for slot in bl_slots: courses = slot.courses.all() - + for course in courses: already_registered = course_registration.objects.filter( course_id=course, student_id=student ).exists() - + + # Sections where this course is running this term. If the student's + # own section isn't among them, they pick one of these (backlog/improvement). + offerings = CourseInstructor.objects.filter( + course_id=course, year=working_year, semester_type=sem_type, + ).select_related('instructor_id__id__user') + sections = [] + own_section_running = False + for o in offerings: + u = o.instructor_id.id.user + sections.append({ + 'course_instructor_id': o.id, + 'section': o.section_label or '', + 'instructor': f"{u.first_name} {u.last_name}".strip(), + }) + if student_section and o.section_label == student_section: + own_section_running = True + courses_list.append({ 'id': course.id, 'code': course.code, @@ -518,9 +566,12 @@ def get_student_add_courses(request): 'credit': course.credit, 'slot': slot.name, 'slot_id': slot.id, - 'already_registered': already_registered + 'already_registered': already_registered, + 'sections': sections, + 'own_section_running': own_section_running, + 'student_section': student_section, }) - + return Response(courses_list, status=status.HTTP_200_OK) except Exception as e: @@ -1904,6 +1955,43 @@ def acad_add_course(request): reg_type = data["registration_type"] old_id = data.get("old_course") sem_type = data["semester_type"] + working_year = parse_academic_year(academic_year=session, semester_type=sem_type)[0] + + # Resolve the offering (section). Prefer the chosen section, else the student's + # own. Registering into a section other than the student's own is only allowed + # for Backlog/Improvement. + from applications.academic_information.models import resolve_offering + ci_id = data.get("course_instructor_id") + if ci_id: + offering = CourseInstructor.objects.filter( + id=ci_id, course_id=course, year=working_year, semester_type=sem_type, + ).first() + if not offering: + return Response({"error": "Selected section is not running this course this semester."}, + status=status.HTTP_400_BAD_REQUEST) + else: + offering = resolve_offering(student, course, working_year, sem_type) + + # A sectioned course can't be registered without a chosen section. + if offering is None: + sectioned = CourseInstructor.objects.filter( + course_id=course, year=working_year, semester_type=sem_type, + ).exclude(section_label__isnull=True).exclude(section_label="").exists() + if sectioned: + return Response( + {"error": "This course runs in sections — select a section to register in."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + student_section = getattr(student, "section", None) + if (offering is not None and offering.section_label + and offering.section_label != student_section + and reg_type not in ("Backlog", "Improvement")): + return Response( + {"error": "Registering into another section is only allowed as Backlog/Improvement."}, + status=status.HTTP_400_BAD_REQUEST, + ) + with transaction.atomic(): cr = course_registration.objects.create( student_id = student, @@ -1913,7 +2001,8 @@ def acad_add_course(request): session = session, registration_type= reg_type, semester_type = sem_type, - working_year = parse_academic_year(academic_year=session, semester_type=sem_type)[0] + working_year = working_year, + course_instructor = offering, ) if old_id: old = course_registration.objects.filter(id=old_id).first() @@ -3662,9 +3751,31 @@ def get_add_course_courses(request): # ensure the slot exists (404 if not) slot = get_object_or_404(CourseSlot, id=slot_id) - # via the M2M relationship .courses - courses = slot.courses.all().values("id", "code", "name", "credit") - return Response(list(courses), status=status.HTTP_200_OK) + # When the term is supplied, include the sections each course is running in + # (so the admin can pick a section for a cross-section backlog/improvement). + academic_year = request.query_params.get("academic_year") + semester_type = request.query_params.get("semester_type") + working_year = None + if academic_year and semester_type: + try: + working_year = parse_academic_year(academic_year=academic_year, semester_type=semester_type)[0] + except Exception: + working_year = None + + result = [] + for c in slot.courses.all(): + entry = {"id": c.id, "code": c.code, "name": c.name, "credit": c.credit} + if working_year is not None: + offerings = CourseInstructor.objects.filter( + course_id=c, year=working_year, semester_type=semester_type, + ).select_related("instructor_id__id__user") + entry["sections"] = [{ + "course_instructor_id": o.id, + "section": o.section_label or "", + "instructor": f"{o.instructor_id.id.user.first_name} {o.instructor_id.id.user.last_name}".strip(), + } for o in offerings] + result.append(entry) + return Response(result, status=status.HTTP_200_OK) def roman_to_int(s): @@ -5061,7 +5172,9 @@ def approve_add_requests(request): session=add_request.academic_year, semester_type=add_request.semester_type, working_year=datetime.datetime.now().year, - registration_type=registration_type + registration_type=registration_type, + # Section chosen at request time (cross-section backlog/improvement). + course_instructor=add_request.course_instructor, ) reg.save() diff --git a/FusionIIIT/applications/academic_procedures/management/commands/assign_backlog_sections.py b/FusionIIIT/applications/academic_procedures/management/commands/assign_backlog_sections.py new file mode 100644 index 000000000..c58a2eefc --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/management/commands/assign_backlog_sections.py @@ -0,0 +1,93 @@ +"""Bulk-assign a section to unsectioned backlog/improvement registrations (once per term).""" + +from collections import defaultdict + +from django.core.management.base import BaseCommand +from django.db import transaction + +from applications.academic_procedures.models import course_registration +from applications.programme_curriculum.models import CourseInstructor + + +def _working_year(session, semester_type): + # "2026-27" -> 2026; Even semester falls in the next calendar year. + start = int(str(session).split("-")[0]) + return start + 1 if semester_type == "Even Semester" else start + + +class Command(BaseCommand): + help = "Assign a section (course_instructor) to unsectioned backlog/improvement registrations." + + def add_arguments(self, parser): + parser.add_argument("--year", required=True, help="Session, e.g. 2026-27") + parser.add_argument("--sem", required=True, help='Semester type, e.g. "Odd Semester"') + parser.add_argument("--course", help="Optional course code to limit to") + parser.add_argument("--dry-run", action="store_true", help="Preview only, no writes") + + def handle(self, *args, **opts): + session, sem, dry = opts["year"], opts["sem"], opts["dry_run"] + working_year = _working_year(session, sem) + + regs = course_registration.objects.filter( + session=session, + semester_type=sem, + registration_type__in=["Backlog", "Improvement"], + course_instructor__isnull=True, + ).select_related("student_id", "course_id") + if opts.get("course"): + regs = regs.filter(course_id__code=opts["course"]) + + # Seed per-offering counts so "least-full" balances against those already placed. + counts = defaultdict(int) + for ci_id in course_registration.objects.filter( + session=session, semester_type=sem, course_instructor__isnull=False, + ).values_list("course_instructor_id", flat=True): + counts[ci_id] += 1 + + offerings_by_course = {} + + def offerings_for(course): + if course.id not in offerings_by_course: + offerings_by_course[course.id] = list(CourseInstructor.objects.filter( + course_id=course, year=working_year, semester_type=sem, + )) + return offerings_by_course[course.id] + + plan, skipped = [], 0 + for reg in regs: + offs = offerings_for(reg.course_id) + if not offs: + skipped += 1 + continue + if len(offs) == 1: + chosen = offs[0] + else: + sec = getattr(reg.student_id, "section", None) + chosen = next( + (o for o in offs if o.section_label and o.section_label == sec), + None, + ) or min(offs, key=lambda o: counts[o.id]) + counts[chosen.id] += 1 + plan.append((reg, chosen)) + + self.stdout.write( + f"Unsectioned backlog/improvement regs: {regs.count()} | " + f"to assign: {len(plan)} | skipped (course has no offering): {skipped}" + ) + for reg, chosen in plan[:50]: + self.stdout.write( + f" {reg.student_id_id} {reg.course_id.code} -> " + f"section {chosen.section_label or '-'} (offering {chosen.id})" + ) + if len(plan) > 50: + self.stdout.write(f" ... and {len(plan) - 50} more") + + if dry: + self.stdout.write(self.style.WARNING("DRY RUN — no changes written.")) + return + + with transaction.atomic(): + for reg, chosen in plan: + reg.course_instructor = chosen + reg.save(update_fields=["course_instructor"]) + self.stdout.write(self.style.SUCCESS(f"Assigned {len(plan)} registration(s).")) diff --git a/FusionIIIT/applications/academic_procedures/migrations/0023_courseaddrequest_course_instructor.py b/FusionIIIT/applications/academic_procedures/migrations/0023_courseaddrequest_course_instructor.py new file mode 100644 index 000000000..4a4fea00e --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0023_courseaddrequest_course_instructor.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.5 on 2026-08-03 02:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0034_studentbatchupload_section'), + ('academic_procedures', '0022_feedbackresponse_course_instructor'), + ] + + operations = [ + migrations.AddField( + model_name='courseaddrequest', + name='course_instructor', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='programme_curriculum.courseinstructor'), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/models.py b/FusionIIIT/applications/academic_procedures/models.py index 70c54615b..1b0d70319 100644 --- a/FusionIIIT/applications/academic_procedures/models.py +++ b/FusionIIIT/applications/academic_procedures/models.py @@ -1037,6 +1037,13 @@ class CourseAddRequest(models.Model): on_delete=models.SET_NULL, help_text="Reference to the previous course registration being replaced (for backlog/improvement)" ) + # The running section (offering) the student chose when the course isn't run + # in their own section; applied as course_instructor on approval. + course_instructor = models.ForeignKey( + 'programme_curriculum.CourseInstructor', + null=True, blank=True, + on_delete=models.SET_NULL, + ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, diff --git a/FusionIIIT/applications/examination/api/views.py b/FusionIIIT/applications/examination/api/views.py index 08b429a8b..d7e48ec80 100644 --- a/FusionIIIT/applications/examination/api/views.py +++ b/FusionIIIT/applications/examination/api/views.py @@ -444,10 +444,14 @@ def download_template(request): student_id__in=student_ids_with_programme ) - # Sections apply only to UG (PG/PhD have no sections); electives ignore it. + # Scope by the offering each registration is bound to (course_instructor), + # falling back to the student's home section only for pre-sectioning rows. section = (request.data.get('section') or '').strip() or None if section and (programme_type or '').strip().upper() == 'UG': - course_info_query = course_info_query.filter(student_id__section=section) + course_info_query = course_info_query.filter( + Q(course_instructor__section_label=section) + | (Q(course_instructor__section_label__isnull=True) & Q(student_id__section=section)) + ) course_info = course_info_query.order_by("student_id_id") @@ -1745,9 +1749,13 @@ def post(self, request): my_offering_ids = {o.id for o in my_offerings} my_sections = {o.section_label for o in my_offerings} # A no-section (elective) offering owns all registrants; named-section - # offerings scope the roster to students in those sections (UG only). + # offerings scope the roster to the bound offering, falling back to the + # student's home section only for pre-sectioning rows (UG only). if (programme_type or "").strip().upper() == "UG" and None not in my_sections: - regs = regs.filter(student_id__section__in=my_sections) + regs = regs.filter( + Q(course_instructor_id__in=my_offering_ids) + | (Q(course_instructor__isnull=True) & Q(student_id__section__in=my_sections)) + ) if not regs.exists(): return Response( {"error": "No students are registered in the selected section for this course."}, @@ -3006,10 +3014,14 @@ def post(self, request): registrations = registrations.filter(student_id__in=student_ids_with_programme) - # Sections apply only to UG (PG/PhD have no sections); electives ignore it. + # Scope by the offering each registration is bound to (course_instructor), + # falling back to the student's home section only for pre-sectioning rows. section = (request.data.get("section") or "").strip() or None if section and (programme_type or "").strip().upper() == "UG": - registrations = registrations.filter(student_id__section=section) + registrations = registrations.filter( + Q(course_instructor__section_label=section) + | (Q(course_instructor__section_label__isnull=True) & Q(student_id__section=section)) + ) # Build a set of registered roll numbers for fast lookup. registered_rollnos = set()