From 3c4c136093f13628a2c44f76e8c85f05f0a3316e Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Mon, 6 Jul 2026 00:20:36 +0530 Subject: [PATCH 1/9] Add B.Tech sectioning and per-instructor grade submission - Add Student.section (A-F), derived from discipline + roll-number parity via compute_section(); assigned at student onboarding and recomputed on branch change. backfill_sections management command for existing students. - Make CourseInstructor a per-section offering: add section_label and change uniqueness to (course, year, semester_type, section_label) so each section has exactly one owning faculty; single-offering enforcement for electives. - Bind registrations and grades to their offering: add course_instructor FK to course_registration, FinalRegistration and Student_grades; resolve_offering() + binding at verification; bind_course_offerings management command. - Scope grade submission per instructor/section in UploadGradesProfAPI (a faculty may only grade their own section); remove the acadadmin proxy submission path (SubmitGradesView / UploadGradesAPI). - Harden ModerateStudentGradesAPI (term-aware, no MultipleObjectsReturned) and make GradeStatusAPI report status per section. - Make the student roll list (generate_xlsheet_api) section-aware: optional section filter, Section column, section-aware instructor. - Migrations: academic_information 0003, programme_curriculum 0033, academic_procedures 0021, online_cms 0005. --- .../academic_information/api/views.py | 32 +- .../management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/backfill_sections.py | 73 +++ .../migrations/0003_student_section.py | 18 + .../academic_information/models.py | 86 ++++ .../academic_procedures/api/views.py | 7 +- .../management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/bind_course_offerings.py | 90 ++++ .../migrations/0021_auto_20260704_1127.py | 25 + .../academic_procedures/models.py | 14 + .../applications/academic_procedures/views.py | 10 +- .../applications/examination/api/urls.py | 5 +- .../applications/examination/api/views.py | 437 ++++++------------ .../0005_student_grades_course_instructor.py | 20 + FusionIIIT/applications/online_cms/models.py | 4 + .../programme_curriculum/api/views.py | 38 +- .../programme_curriculum/forms.py | 16 +- .../migrations/0033_auto_20260704_1109.py | 22 + .../programme_curriculum/models.py | 12 +- .../models_student_management.py | 7 +- 22 files changed, 593 insertions(+), 323 deletions(-) create mode 100644 FusionIIIT/applications/academic_information/management/__init__.py create mode 100644 FusionIIIT/applications/academic_information/management/commands/__init__.py create mode 100644 FusionIIIT/applications/academic_information/management/commands/backfill_sections.py create mode 100644 FusionIIIT/applications/academic_information/migrations/0003_student_section.py create mode 100644 FusionIIIT/applications/academic_procedures/management/__init__.py create mode 100644 FusionIIIT/applications/academic_procedures/management/commands/__init__.py create mode 100644 FusionIIIT/applications/academic_procedures/management/commands/bind_course_offerings.py create mode 100644 FusionIIIT/applications/academic_procedures/migrations/0021_auto_20260704_1127.py create mode 100644 FusionIIIT/applications/online_cms/migrations/0005_student_grades_course_instructor.py create mode 100644 FusionIIIT/applications/programme_curriculum/migrations/0033_auto_20260704_1109.py diff --git a/FusionIIIT/applications/academic_information/api/views.py b/FusionIIIT/applications/academic_information/api/views.py index 6cfd4ec95..fdef6d160 100644 --- a/FusionIIIT/applications/academic_information/api/views.py +++ b/FusionIIIT/applications/academic_information/api/views.py @@ -642,6 +642,7 @@ def generate_xlsheet_api(request): semester_type = request.data.get('semester_type') list_type = request.data.get('list_type', '').strip() programme_type = request.data.get('programme_type', '').strip() + section = (request.data.get('section') or '').strip().upper() preview_only = request.data.get('preview_only', False) if not list_type: @@ -653,7 +654,7 @@ def generate_xlsheet_api(request): return Response({ 'error': 'Missing required parameters: course, academic_year, semester_type' }, status=status.HTTP_400_BAD_REQUEST) - cache_key = f"student_list_{course_id}_{academic_year.replace('-', '_')}_{semester_type.replace(' ', '_')}_{(list_type or 'all').replace(' ', '_')}_{(programme_type or 'all').replace(' ', '_')}" + cache_key = f"student_list_{course_id}_{academic_year.replace('-', '_')}_{semester_type.replace(' ', '_')}_{(list_type or 'all').replace(' ', '_')}_{(programme_type or 'all').replace(' ', '_')}_{section or 'allsec'}" cached_data = cache.get(cache_key) if cached_data and not preview_only: @@ -670,7 +671,8 @@ def generate_xlsheet_api(request): cr.registration_type, c.code as course_code, c.name as course_name, - s.programme + s.programme, + 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 @@ -702,7 +704,12 @@ def generate_xlsheet_api(request): else: sql += " AND s.programme = %s" params.append(programme_type) - + + # Section filter (A-F): scope the roll list to one section's students. + if section: + sql += " AND s.section = %s" + params.append(section) + sql += " ORDER BY u.username" try: with connection.cursor() as cursor: @@ -720,6 +727,7 @@ def generate_xlsheet_api(request): 'last_name': data['last_name'], 'full_name': data['full_name'], 'discipline': data['discipline'], + 'section': data.get('section') or '', 'email': data['email'], 'registration_type': data['registration_type'], 'programme': data.get('programme', '') @@ -763,7 +771,10 @@ def generate_xlsheet_api(request): list_type_display += " (PG Only)" else: list_type_display += f" ({programme_type} Only)" - + + if section: + list_type_display += f" — Section {section}" + processing_time = time.time() - start_time if preview_only: preview_students = students[:350] if len(students) > 350 else students @@ -790,7 +801,7 @@ def generate_xlsheet_api(request): ws = wb.active ws.title = "Student List" - column_widths = [8, 15, 30, 15, 35, 18, 15] + column_widths = [8, 15, 30, 15, 10, 35, 18, 15] for i, width in enumerate(column_widths, 1): ws.column_dimensions[chr(64 + i)].width = width @@ -823,7 +834,9 @@ def generate_xlsheet_api(request): year_int = int(year_parts[0]) instructor_name = "TBA" - course_instructor = CourseInstructor.objects.filter(course_id=course_id, year=year_int, semester_type=semester_type).first() + _ci_qs = CourseInstructor.objects.filter(course_id=course_id, year=year_int, semester_type=semester_type) + # When a section is selected, show that section's faculty; else the first offering. + course_instructor = (_ci_qs.filter(section_label=section).first() if section else None) or _ci_qs.first() if course_instructor: instructor_name = f"{course_instructor.instructor_id.id.user.first_name} {course_instructor.instructor_id.id.user.last_name}".strip() @@ -840,7 +853,7 @@ def generate_xlsheet_api(request): for row in range(3, 7): ws[f'A{row}'].alignment = Alignment(horizontal="left", vertical="center") - headers = ['Sl. No', 'Roll No', 'Name', 'Discipline', 'Email', 'Reg. Type', 'Signature'] + headers = ['Sl. No', 'Roll No', 'Name', 'Discipline', 'Section', 'Email', 'Reg. Type', 'Signature'] for col, header in enumerate(headers, 1): cell = ws.cell(row=8, column=col, value=header) cell.font = header_font @@ -853,6 +866,7 @@ def generate_xlsheet_api(request): student['roll_no'], student['full_name'], student['discipline'], + student.get('section', '') or '—', student['email'], student['registration_type'], '' # Signature @@ -862,7 +876,7 @@ def generate_xlsheet_api(request): # Add borders only to the student list table section (header + data rows). last_table_row = ws.max_row for row in range(8, last_table_row + 1): - for col in range(1, 8): + for col in range(1, 9): ws.cell(row=row, column=col).border = thin_border from io import BytesIO @@ -890,6 +904,8 @@ def generate_xlsheet_api(request): else: filename_suffix += f"_{programme_type.replace(' ', '_')}" + if section: + filename_suffix += f"_Section_{section}" filename = f"{course_info['code']}_{filename_suffix}_CourseList.xlsx" response['Content-Disposition'] = f'attachment; filename="{filename}"' diff --git a/FusionIIIT/applications/academic_information/management/__init__.py b/FusionIIIT/applications/academic_information/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/academic_information/management/commands/__init__.py b/FusionIIIT/applications/academic_information/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/academic_information/management/commands/backfill_sections.py b/FusionIIIT/applications/academic_information/management/commands/backfill_sections.py new file mode 100644 index 000000000..3fcf71a4c --- /dev/null +++ b/FusionIIIT/applications/academic_information/management/commands/backfill_sections.py @@ -0,0 +1,73 @@ +"""Backfill Student.section for existing undergraduate students. + +Section is derived from (discipline, roll-number parity) via +``academic_information.models.compute_section``. This is a one-time, +idempotent backfill for students who predate the section feature; new +students get their section at onboarding and it is recomputed on branch +change, so this command can safely be re-run at any time. + + python manage.py backfill_sections # apply + python manage.py backfill_sections --dry-run # report only +""" + +from collections import Counter + +from django.core.management.base import BaseCommand + +from applications.academic_information.models import Student, compute_section + + +class Command(BaseCommand): + help = "Backfill Student.section from discipline + roll parity (UG only)." + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', action='store_true', + help='Report what would change without writing to the database.', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + students = Student.objects.select_related('batch_id__discipline').all() + + to_update = [] + dist = Counter() + no_discipline = 0 + non_ug = 0 + + for student in students: + discipline = None + if student.batch_id and student.batch_id.discipline: + discipline = student.batch_id.discipline.name + + section = compute_section(discipline, student.id_id, student.programme) + + if section is None: + if str(student.programme or '').strip().upper() not in ('B.TECH', 'B.DES'): + non_ug += 1 + elif not discipline: + no_discipline += 1 + # leave section as-is (None) when unresolved + if student.section is not None: + # UG student whose discipline no longer maps — clear stale value + student.section = None + to_update.append(student) + continue + + dist[section] += 1 + if student.section != section: + student.section = section + to_update.append(student) + + self.stdout.write(f"Scanned {students.count()} students.") + self.stdout.write(f"Section distribution (UG resolved): {dict(sorted(dist.items()))}") + self.stdout.write(f"Non-UG (skipped): {non_ug} UG without discipline (skipped): {no_discipline}") + self.stdout.write(f"Rows needing update: {len(to_update)}") + + if dry_run: + self.stdout.write(self.style.WARNING("Dry run — no changes written.")) + return + + if to_update: + Student.objects.bulk_update(to_update, ['section'], batch_size=500) + self.stdout.write(self.style.SUCCESS(f"Updated {len(to_update)} students.")) diff --git a/FusionIIIT/applications/academic_information/migrations/0003_student_section.py b/FusionIIIT/applications/academic_information/migrations/0003_student_section.py new file mode 100644 index 000000000..7fbe3db2b --- /dev/null +++ b/FusionIIIT/applications/academic_information/migrations/0003_student_section.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-07-04 10:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0002_auto_20260210_1820'), + ] + + operations = [ + migrations.AddField( + model_name='student', + name='section', + field=models.CharField(blank=True, choices=[('A', 'A'), ('B', 'B'), ('C', 'C'), ('D', 'D'), ('E', 'E'), ('F', 'F')], max_length=2, null=True), + ), + ] diff --git a/FusionIIIT/applications/academic_information/models.py b/FusionIIIT/applications/academic_information/models.py index fe51b81b0..82eae6137 100755 --- a/FusionIIIT/applications/academic_information/models.py +++ b/FusionIIIT/applications/academic_information/models.py @@ -71,6 +71,88 @@ class Constants: ('Management Science', 'Management Science'), ) + # B.Tech section labels. CSE is split into A/B by roll-number parity; every + # other discipline maps to a single section. See compute_section() below. + SECTION_CHOICES = ( + ('A', 'A'), + ('B', 'B'), + ('C', 'C'), + ('D', 'D'), + ('E', 'E'), + ('F', 'F'), + ) + + +def compute_section(discipline_name, roll_no, programme=None): + """Return the section (A-F) for an undergraduate student, else None. + + Section is a pure function of (discipline, roll-number parity): + - CSE -> 'A' if the roll number is odd, else 'B' + - ECE -> 'C', Mechanical -> 'D', Design -> 'E', Smart Manufacturing -> 'F' + + Sections apply only to undergraduate programmes (B.Tech / B.Des); when + ``programme`` is given and is not one of those, returns None (an M.Tech + "CSE" student must not get A/B). When ``programme`` is None the check is + skipped (caller is assumed to have already scoped to UG students). + + Because it is fully derived, it must be (re)computed wherever a student's + discipline is set or changed (onboarding, branch change). Accepts both the + canonical Discipline name ("Computer Science and Engineering") and short + acronyms ("CSE"). Returns None for unknown disciplines. + """ + if not discipline_name: + return None + if programme is not None and str(programme).strip().upper() not in ('B.TECH', 'B.DES'): + return None + import re + name = str(discipline_name).strip().lower() + match = re.search(r'(\d+)\s*$', str(roll_no or '')) + + if 'computer science' in name or name == 'cse': + if not match: + return None + return 'A' if int(match.group(1)) % 2 == 1 else 'B' + if 'electronic' in name or name == 'ece': + return 'C' + if 'mechanical' in name or name in ('me', 'mech'): + return 'D' + if 'design' in name: + return 'E' + if 'smart manufacturing' in name or name in ('sm', 'smart'): + return 'F' + return None + + +def resolve_offering(student, course, year, semester_type): + """Return the CourseInstructor (offering) a student belongs to for a course + in a given term, or None if it cannot be determined unambiguously. + + Resolution order: + 1. Core: the offering whose section_label matches the student's section. + 2. Elective / single-offering: the offering with no section (NULL). + 3. If exactly one offering exists for the term, use it. + ``year`` must be the CourseInstructor working year (== course_registration + working_year for both Odd and Even semesters). + """ + from applications.programme_curriculum.models import CourseInstructor + qs = CourseInstructor.objects.filter( + course_id=course, year=year, semester_type=semester_type, + ) + section = getattr(student, 'section', None) + if section: + match = qs.filter(section_label=section).first() + if match: + return match + # Elective / single-offering: exactly one no-section offering. If more than + # one exists (legacy team-taught rows), it is ambiguous -> return None rather + # than guess an instructor, so it can be relabelled deliberately. + nulls = list(qs.filter(section_label__isnull=True)[:2]) + if len(nulls) == 1: + return nulls[0] + if not nulls and qs.count() == 1: + return qs.first() + return None + class Student(models.Model): ''' @@ -106,6 +188,10 @@ class Student(models.Model): room_no = models.CharField(max_length=10, blank=True, null=True) specialization = models.CharField(max_length=40,choices=Constants.MTechSpecialization, null=True, default='') curr_semester_no = models.IntegerField(default=1) + # B.Tech section (A-F). Derived from discipline + roll parity via + # compute_section(); (re)set at onboarding and on branch change. Null for + # non-B.Tech students and disciplines without a section mapping. + section = models.CharField(max_length=2, choices=Constants.SECTION_CHOICES, null=True, blank=True) def __str__(self): username = str(self.id.user.username) diff --git a/FusionIIIT/applications/academic_procedures/api/views.py b/FusionIIIT/applications/academic_procedures/api/views.py index 8b094698b..df1531cba 100644 --- a/FusionIIIT/applications/academic_procedures/api/views.py +++ b/FusionIIIT/applications/academic_procedures/api/views.py @@ -1122,6 +1122,7 @@ def verify_registration(request): # final_register_list = FinalRegistration.objects.all().filter(student_id = student, verified = False) with transaction.atomic(): + from applications.academic_information.models import resolve_offering ver_reg = [] for obj in final_register_list: _work_year = datetime.datetime.now().year @@ -1131,6 +1132,9 @@ def verify_registration(request): _session = f"{_work_year}-{str(_work_year + 1)[-2:]}" else: _session = f"{_work_year - 1}-{str(_work_year)[-2:]}" + # Bind this registration to the offering (course+section+faculty) + # the student belongs to, so grades are attributable per instructor. + offering = resolve_offering(student, obj.course_id, _work_year, _sem_type) p = course_registration( course_id=obj.course_id, student_id=student, @@ -1140,12 +1144,13 @@ def verify_registration(request): registration_type=obj.registration_type, session=_session, semester_type=_sem_type, + course_instructor=offering, ) # ver_reg.append(p) p.save() if (obj.old_course_registration): course_replacement.objects.create(new_course_registration=p, old_course_registration=obj.old_course_registration) - o = FinalRegistration.objects.filter(id= obj.id).update(verified = True) + o = FinalRegistration.objects.filter(id= obj.id).update(verified = True, course_instructor = offering) # course_registration.objects.bulk_create(ver_reg) academics_module_notif(request.user, student.id.user, 'registration_approved') diff --git a/FusionIIIT/applications/academic_procedures/management/__init__.py b/FusionIIIT/applications/academic_procedures/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/academic_procedures/management/commands/__init__.py b/FusionIIIT/applications/academic_procedures/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/academic_procedures/management/commands/bind_course_offerings.py b/FusionIIIT/applications/academic_procedures/management/commands/bind_course_offerings.py new file mode 100644 index 000000000..c4a6a6489 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/management/commands/bind_course_offerings.py @@ -0,0 +1,90 @@ +"""Bind course_registration rows to their CourseInstructor offering. + +For each registration that isn't yet bound, resolve the offering from +(course, working_year, semester_type, student.section) and set course_instructor. +Resolution mirrors academic_information.models.resolve_offering: + 1. core: offering whose section_label matches the student's section + 2. elective / single-offering: the offering with no section (NULL) + 3. if exactly one offering exists for the term, use it + +All offerings are preloaded into memory so this scales to 100k+ registrations +without a per-row query. Registrations whose offering can't be resolved (e.g. a +sectioned course whose offerings aren't labelled yet, or an ambiguous +multi-offering term) are left unbound and reported. Idempotent / re-runnable. + + python manage.py bind_course_offerings # apply + python manage.py bind_course_offerings --dry-run # report only +""" + +from collections import defaultdict + +from django.core.management.base import BaseCommand + +from applications.academic_procedures.models import course_registration +from applications.programme_curriculum.models import CourseInstructor + + +class Command(BaseCommand): + help = "Bind course_registration rows to their CourseInstructor offering." + + def add_arguments(self, parser): + parser.add_argument('--dry-run', action='store_true', + help='Report what would change without writing.') + + def handle(self, *args, **options): + dry_run = options['dry_run'] + + # Preload offerings into memory: {(course_id, year, semester_type): [CI, ...]} + offerings = defaultdict(list) + for ci in CourseInstructor.objects.all().only( + 'id', 'course_id', 'year', 'semester_type', 'section_label'): + offerings[(ci.course_id_id, ci.year, ci.semester_type)].append(ci) + + def resolve(course_id, year, semester_type, section): + group = offerings.get((course_id, year, semester_type)) + if not group: + return None + if section: + for o in group: + if o.section_label == section: + return o + nulls = [o for o in group if o.section_label is None] + if len(nulls) == 1: + return nulls[0] + if not nulls and len(group) == 1: + return group[0] + return None + + qs = (course_registration.objects + .filter(course_instructor__isnull=True, working_year__isnull=False) + .exclude(semester_type__isnull=True) + .exclude(semester_type='') + .select_related('student_id') + .only('id', 'course_id', 'working_year', 'semester_type', 'student_id__section', + 'course_instructor')) + + total = bound = unresolved = 0 + batch = [] + for reg in qs.iterator(chunk_size=2000): + total += 1 + offering = resolve(reg.course_id_id, reg.working_year, reg.semester_type, + reg.student_id.section) + if offering is None: + unresolved += 1 + continue + reg.course_instructor = offering + batch.append(reg) + bound += 1 + if not dry_run and len(batch) >= 1000: + course_registration.objects.bulk_update(batch, ['course_instructor']) + batch = [] + + if not dry_run and batch: + course_registration.objects.bulk_update(batch, ['course_instructor']) + + self.stdout.write(f"Unbound registrations scanned: {total}") + self.stdout.write(f"Resolved & bound: {bound} Unresolved (no matching offering): {unresolved}") + if dry_run: + self.stdout.write(self.style.WARNING("Dry run - no changes written.")) + else: + self.stdout.write(self.style.SUCCESS(f"Bound {bound} registrations.")) diff --git a/FusionIIIT/applications/academic_procedures/migrations/0021_auto_20260704_1127.py b/FusionIIIT/applications/academic_procedures/migrations/0021_auto_20260704_1127.py new file mode 100644 index 000000000..5192252a8 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0021_auto_20260704_1127.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.5 on 2026-07-04 11:27 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0033_auto_20260704_1109'), + ('academic_procedures', '0020_backfill_course_registration_session'), + ] + + operations = [ + migrations.AddField( + model_name='course_registration', + name='course_instructor', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='programme_curriculum.courseinstructor'), + ), + migrations.AddField( + model_name='finalregistration', + 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 ff095c117..c1531c885 100644 --- a/FusionIIIT/applications/academic_procedures/models.py +++ b/FusionIIIT/applications/academic_procedures/models.py @@ -600,6 +600,14 @@ class course_registration(models.Model): semester_id = models.ForeignKey(Semester, on_delete=models.CASCADE) course_id = models.ForeignKey(Courses, on_delete=models.CASCADE) course_slot_id = models.ForeignKey(CourseSlot, null=True, blank=True, on_delete=models.SET_NULL) + # The offering (programme_curriculum.CourseInstructor = course+section+faculty) + # this registration is bound to. Set at allotment from the student's section; + # this is what per-instructor grade submission scopes on. Nullable for + # rows created before the sectioning feature. + course_instructor = models.ForeignKey( + 'programme_curriculum.CourseInstructor', null=True, blank=True, + on_delete=models.SET_NULL, + ) REGISTRATION_TYPE_CHOICES = [ ('Audit', 'Audit'), ('Improvement', 'Improvement'), @@ -693,6 +701,12 @@ class FinalRegistration(models.Model): student_id = models.ForeignKey(Student, on_delete=models.CASCADE) verified = models.BooleanField(default=False) course_slot_id = models.ForeignKey(CourseSlot, null=True, blank=True,on_delete=models.SET_NULL) + # Offering this student is bound to (course+section+faculty), set at allotment. + # Carried onto course_registration at verification. Nullable for pre-feature rows. + course_instructor = models.ForeignKey( + 'programme_curriculum.CourseInstructor', null=True, blank=True, + on_delete=models.SET_NULL, + ) old_course_registration=models.ForeignKey(course_registration, null=True, on_delete=models.CASCADE) REGISTRATION_TYPE_CHOICES = [ ('Audit', 'Audit'), diff --git a/FusionIIIT/applications/academic_procedures/views.py b/FusionIIIT/applications/academic_procedures/views.py index 9d763beff..d56dc4914 100644 --- a/FusionIIIT/applications/academic_procedures/views.py +++ b/FusionIIIT/applications/academic_procedures/views.py @@ -900,7 +900,8 @@ def approve_branch_change(request): continue from applications.programme_curriculum.models import Discipline, Batch - + from applications.academic_information.models import compute_section + changed_branch = [] changed_students = [] @@ -929,6 +930,11 @@ def approve_branch_change(request): ).first() if new_batch: student.batch_id = new_batch + # Discipline changed -> section follows automatically. + # Roll number is the immutable PK, so parity is + # unchanged; only the discipline mapping flips + # (e.g. ECE 'C' -> CSE 'A'/'B'). + student.section = compute_section(new_discipline.name, student.id_id, student.programme) changed_students.append(student) except Exception as e: pass @@ -942,7 +948,7 @@ def approve_branch_change(request): if changed_branch: ExtraInfo.objects.bulk_update(changed_branch, ['department']) if changed_students: - Student.objects.bulk_update(changed_students, ['batch_id']) + Student.objects.bulk_update(changed_students, ['batch_id', 'section']) messages.info(request, 'Apply for branch change successfull') except Exception as e: messages.info(request, 'Unable to proceed, we will get back to you very soon') diff --git a/FusionIIIT/applications/examination/api/urls.py b/FusionIIIT/applications/examination/api/urls.py index f0499996a..bf0fb5c27 100644 --- a/FusionIIIT/applications/examination/api/urls.py +++ b/FusionIIIT/applications/examination/api/urls.py @@ -8,8 +8,9 @@ url(r'^exam_view/', views.exam_view, name='exam_view'), url(r'^download_template/', views.download_template, name='download_template'), url(r'^check_course_students/', views.check_course_students, name='check_course_students'), - url(r'^submitGrades/', views.SubmitGradesView.as_view(), name='submitGrades'), - url(r'^upload_grades/', views.UploadGradesAPI.as_view(), name='upload_grades'), + # Removed: acadadmin proxy grade-submission endpoints (submitGrades / upload_grades). + # Grades are submitted only by the assigned faculty per section via + # upload_grades_prof (UploadGradesProfAPI); acadadmin's role is verify/moderate. url(r'^update_grades/', views.UpdateGradesAPI.as_view(), name='update_grades'), url(r'^update_enter_grades/', views.UpdateEnterGradesAPI.as_view(), name='update_enter_grades'), url(r'^moderate_student_grades/', views.ModerateStudentGradesAPI.as_view(), name='moderate_student_grades'), diff --git a/FusionIIIT/applications/examination/api/views.py b/FusionIIIT/applications/examination/api/views.py index 4e6f0a5ed..b9510f748 100644 --- a/FusionIIIT/applications/examination/api/views.py +++ b/FusionIIIT/applications/examination/api/views.py @@ -558,244 +558,10 @@ def check_course_students(request): return Response({'error': f'An unexpected error occurred: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) -class SubmitGradesView(APIView): - """ - API to retrieve course information for a given academic year session and semester type. - - If both academic_year (formatted as "YYYY-YY") and semester_type are provided in the request, - the API filters courses from course_registration records based on: - - session: must equal the provided academic_year, and - - semester_type: must match the provided semester type. - - Otherwise, if academic_year is not provided, it returns available sessions. - """ - permission_classes = [IsAuthenticated] - - def post(self, request): - designation = request.data.get("Role") - academic_year = request.data.get("academic_year") - semester_type = request.data.get("semester_type") - - # Only allow access to 'acadadmin' - if not user_holds_role(request.user, "acadadmin"): - return Response( - {"success": False, "error": "Access denied."}, - status=status.HTTP_403_FORBIDDEN - ) - - # If both academic_year and semester_type are provided, filter courses. - if academic_year and semester_type: - # Use academic_year to match the session field. - unique_course_ids = course_registration.objects.filter( - session=academic_year, - semester_type=semester_type - ).values("course_id").distinct() - - courses_info = Courses.objects.filter( - id__in=unique_course_ids.values_list("course_id", flat=True) - ).order_by("code") - - return Response( - {"courses": list(courses_info.values())}, - status=status.HTTP_200_OK - ) - - # If academic_year is not provided, return available sessions. - sessions = course_registration.objects.values("session").distinct() - return Response( - {"sessions": list(sessions)}, - status=status.HTTP_200_OK - ) - - -class UploadGradesAPI(APIView): - permission_classes = [IsAuthenticated] - parser_classes = [MultiPartParser, FormParser] - - def post(self, request): - # Validate the role (only allow "acadadmin" in this example). - des = request.data.get("Role") - if not user_holds_role(request.user, "acadadmin"): - return Response( - {"success": False, "error": "Access denied."}, - status=status.HTTP_403_FORBIDDEN, - ) - - csv_file = request.FILES.get("csv_file") - if not csv_file: - return Response( - {"error": "No file provided. Please upload a CSV file."}, - status=status.HTTP_400_BAD_REQUEST, - ) - if not csv_file.name.endswith(".csv"): - return Response( - {"error": "Invalid file format. Please upload a CSV file."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Extract course_id, academic_year, and semester_type from the request. - course_id = request.data.get("course_id") - academic_year = request.data.get("academic_year") - semester_type = request.data.get("semester_type") - if not course_id or not academic_year or not semester_type: - return Response( - {"error": "Course ID, Academic Year, and Semester Type are required."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - try: - # Parse academic_year to determine working_year and session. - working_year, session = parse_academic_year(academic_year, semester_type) - - # Fetch the course. - courses_info = Courses.objects.get(id=course_id) - - # Check if any student is registered for this course, working_year, and semester_type. - registrations = course_registration.objects.filter( - course_id=courses_info, - session = academic_year, - semester_type=semester_type - ) - if not registrations.exists(): - message = "NO STUDENTS REGISTERED IN THIS COURSE FOR THE SELECTED SEMESTER." - return Response( - {"error": message,}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check if grades already exist and cannot be resubmitted. - existing_grades = Student_grades.objects.filter( - course_id=courses_info.id, academic_year = academic_year, semester_type = semester_type - ) - if existing_grades.exists() and not existing_grades.first().reSubmit: - message = "THIS COURSE HAS ALREADY BEEN SUBMITTED." - return Response( - {"error": message}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Parse the CSV file."20" - decoded_file = csv_file.read().decode("utf-8").splitlines() - reader = csv.DictReader(decoded_file) - required_columns = ["roll_no", "grade", "remarks"] - if not all(column in reader.fieldnames for column in required_columns): - return Response( - { - "error": "CSV file must contain the following columns: roll_no, grade, remarks." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - errors = [] # To track errors for each CSV row. - allowed_list = ", ".join(sorted(ALLOWED_GRADES)) - # Wrap the upload process in an atomic transaction. - with transaction.atomic(): - for index, row in enumerate(reader, start=1): - roll_no = row.get("roll_no") - grade = (row.get("grade") or "").strip() - remarks = row.get("remarks", "") - semester = row.get("semester", None) - - # Validate student existence. - try: - stud = Student.objects.get(id_id=roll_no) - except Student.DoesNotExist: - errors.append(f"Row {index}: Student with roll_no {roll_no} does not exist.") - continue - - # Check that the student is registered for the course. - registration_exists = course_registration.objects.filter( - student_id=stud, - course_id=courses_info, - semester_type=semester_type, - session = academic_year - ).exists() - if not registration_exists: - errors.append( - f"Row {index}: Student with roll_no {roll_no} is not registered for this course in the selected semester." - ) - continue - - - if not is_valid_grade(grade, courses_info.code): - errors.append( - f"Row {index}: Invalid grade '{grade}' for roll_no {roll_no}. " - f"Allowed grades are: {allowed_list}." - ) - continue - - # Determine the semester for the grade (use the provided value or fall back to student's current semester). - semester = semester or stud.curr_semester_no - batch = stud.batch - reSubmit = False - - # Create or update the grade record. - try: - Student_grades.objects.update_or_create( - roll_no=roll_no, - course_id_id=course_id, - year=working_year, # stored as academic year string - semester=semester, - batch=batch, - academic_year = academic_year, - semester_type = semester_type, - defaults={ - 'grade': grade, - 'remarks': remarks, - 'reSubmit': reSubmit, - 'academic_year': session, - 'semester_type': semester_type, - } - ) - except Exception as create_err: - errors.append( - f"Row {index}: Error creating/updating grade for student with roll_no {roll_no} - {str(create_err)}" - ) - continue - - # If errors were encountered in any row, rollback and return error summary. - if errors: - error_summary = "\n".join(f"- {msg}" for msg in errors) - raise Exception(error_summary) - - return Response( - {"message": "Grades uploaded successfully."}, - status=status.HTTP_200_OK, - ) - - except Courses.DoesNotExist: - return Response( - {"error": "Invalid course ID."}, status=status.HTTP_400_BAD_REQUEST - ) - except Exception as e: - return Response( - {"error": f"An error occurred: {str(e)}"}, - status=status.HTTP_400_BAD_REQUEST, - ) - -""" -API to fetch courses with unverified grades along with unique academic years. - -- Only users with the role of 'acadadmin' can access this endpoint. -- Retrieves courses where at least one student's grades are unverified. -- Fetches the academic years associated with unverified grades. - -Expected Request: -Headers: - Authorization: Token - -Body (JSON): - { - "Role": "acadadmin" - } - -Response: - 200 OK - { - "courses_info": [{"id": 1, "course_name": "Data Structures", ...}], - "unique_year_ids": [{"year": "2024"}, {"year": "2025"}] - } - 403 Forbidden - {"success": false, "error": "Access denied."} -""" +# NOTE: SubmitGradesView + UploadGradesAPI (acadadmin proxy grade submission, +# no per-instructor ownership check) were removed in the sectioning refactor. +# Grades are submitted only by the assigned faculty per section via +# UploadGradesProfAPI; acadadmin/Dean verify & moderate. class UpdateGradesAPI(APIView): permission_classes = [IsAuthenticated] @@ -988,6 +754,10 @@ def post(self, request): grades = request.data.get("grades", []) remarks=request.data.get("remarks",[]) allow_resubmission = request.data.get("allow_resubmission", "NO") + # Term context (optional but recommended): disambiguates a student's grade + # row when the same course+semester recurs across academic years. + academic_year = request.data.get("academic_year") + semester_type = request.data.get("semester_type") if ( @@ -1024,17 +794,27 @@ def post(self, request): student_ids, semester_ids, course_ids, grades, remarks ): grade = (grade or "").strip() - try: - grade_of_student = Student_grades.objects.get( - course_id=course_id, roll_no=student_id, semester=semester_id - ) + lookup = {"course_id": course_id, "roll_no": student_id, "semester": semester_id} + if academic_year: + lookup["academic_year"] = academic_year + if semester_type: + lookup["semester_type"] = semester_type + # Use filter().first() rather than get(): a student can have grade + # rows for the same course+semester across academic years, which + # would make get() raise MultipleObjectsReturned. academic_year + + # semester_type disambiguate; ordering keeps the fallback deterministic. + grade_of_student = (Student_grades.objects + .filter(**lookup) + .order_by("-year", "-id") + .first()) + if grade_of_student: grade_of_student.remarks = remark grade_of_student.grade = grade grade_of_student.verified = True if allow_resubmission.upper() == "YES": grade_of_student.reSubmit = True grade_of_student.save() - except Student_grades.DoesNotExist: + else: hidden_grades.objects.create( course_id=course_id, student_id=student_id, @@ -1880,7 +1660,33 @@ def post(self, request): status=status.HTTP_400_BAD_REQUEST ) - from applications.academic_information.models import Student + # INSTRUCTOR OWNERSHIP: resolve the offering(s) this faculty owns for + # this course + term. Grading is scoped to these — a faculty may only + # grade the students in the section(s) they are assigned to teach. + my_offerings = list(CourseInstructor.objects.filter( + course_id_id=course_id, + instructor_id_id=request.user.username, + year=working_year, + semester_type=semester_type, + )) + if not my_offerings: + return Response( + {"error": "Access denied: you are not assigned to teach this course this term."}, + status=status.HTTP_403_FORBIDDEN + ) + 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. + if None not in my_sections: + regs = regs.filter(student_id__section__in=my_sections) + if not regs.exists(): + return Response( + {"error": "No students are registered in your section for this course."}, + status=status.HTTP_400_BAD_REQUEST + ) + + from applications.academic_information.models import Student, resolve_offering ug_programmes = ['B.Tech', 'B.Des'] pg_programmes = ['M.Tech', 'M.Des', 'PhD'] @@ -1970,16 +1776,7 @@ def post(self, request): status=status.HTTP_400_BAD_REQUEST ) - # 8) INSTRUCTOR‐OWNERSHIP CHECK - if not CourseInstructor.objects.filter( - course_id_id=course_id, - instructor_id_id=request.user.username, - year=working_year - ).exists(): - return Response( - {"error": "Access denied: you are not assigned as instructor for this course."}, - status=status.HTTP_403_FORBIDDEN - ) + # (Instructor ownership + section scoping already resolved above.) # 9) PARSE CSV HEADER decoded = csv_file.read().decode("utf-8").splitlines() @@ -2037,7 +1834,16 @@ def post(self, request): f"Row {idx}: Student {roll_no} not registered for this course/semester." ) continue - + + # SECTION OWNERSHIP: the student must belong to a section this + # faculty teaches, otherwise this user cannot grade them. + stud_offering = resolve_offering(stud, course, working_year, semester_type) + if stud_offering is None or stud_offering.id not in my_offering_ids: + errors.append( + f"Row {idx}: Student {roll_no} is not in a section you are assigned to teach." + ) + continue + # Check if student belongs to the specified programme type if programme_type: student_programme = stud.programme @@ -2079,6 +1885,7 @@ def post(self, request): "grade": grade, "remarks": remarks, "reSubmit": reSubmit, + "course_instructor": stud_offering, } ) except Exception as exc: @@ -3540,17 +3347,20 @@ def post(self, request): # Fetch courses with select_related for better performance courses = Courses.objects.filter(id__in=course_ids).order_by('code') - # Bulk fetch all instructors to avoid N+1 queries - instructors_map = {} - instructors = CourseInstructor.objects.filter( + # All offerings for the term. A course can have several — one + # CourseInstructor row per section, each with its own faculty — so we + # group them per course instead of keeping a single instructor. + from collections import defaultdict + instructors = list(CourseInstructor.objects.filter( course_id__in=course_ids, year=working_year, semester_type=semester_type - ).select_related() - - for instructor in instructors: - instructors_map[instructor.course_id_id] = instructor - + )) + offerings_by_course = defaultdict(list) + for inst in instructors: + offerings_by_course[inst.course_id_id].append(inst) + offering_ids = [inst.id for inst in instructors] + # Bulk fetch professor names to avoid individual User queries instructor_ids = [inst.instructor_id_id for inst in instructors] users_map = {} @@ -3578,6 +3388,24 @@ def post(self, request): verified=True ).values_list('course_id', flat=True).distinct() ) + + # Per-offering (per-section) status via the grade's course_instructor FK, + # so each section's submission/verification is reported separately. + submitted_offerings = set( + Student_grades.objects.filter( + course_instructor_id__in=offering_ids, + academic_year=academic_year, + semester_type=semester_type, + ).values_list('course_instructor_id', flat=True).distinct() + ) + verified_offerings = set( + Student_grades.objects.filter( + course_instructor_id__in=offering_ids, + academic_year=academic_year, + semester_type=semester_type, + verified=True, + ).values_list('course_instructor_id', flat=True).distinct() + ) # Bulk fetch authentication records auth_records_map = {} @@ -3592,40 +3420,57 @@ def post(self, request): # Build response data efficiently grade_status_list = [] - for course in courses: - # Get instructor information from pre-fetched data - instructor = instructors_map.get(course.id) - professor_name = "Not Assigned" - - if instructor: - professor_name = users_map.get( - instructor.instructor_id_id, - instructor.instructor_id_id - ) - - # Determine status from pre-fetched sets - submitted = "Submitted" if course.id in submitted_courses else "Not Submitted" - verified = "Verified" if course.id in verified_courses else "Not Verified" - - # Check validation status - validated = "Not Validated" - if course.id in verified_courses: # Only check if verified - auth_record = auth_records_map.get(course.id) - if (auth_record and auth_record.authenticator_1 and + def _validated(course_id, is_verified): + if not is_verified: + return "Not Validated" + auth_record = auth_records_map.get(course_id) + if (auth_record and auth_record.authenticator_1 and auth_record.authenticator_2 and auth_record.authenticator_3): - validated = "Validated" - - grade_status_list.append({ - "course_code": course.code, - "course_name": course.name, - "course_id": course.id, - "professor_name": professor_name, - "submitted": submitted, - "verified": verified, - "validated": validated, - "credits": course.credit, - "version": course.version - }) + return "Validated" + return "Not Validated" + + for course in courses: + offs = offerings_by_course.get(course.id, []) + if offs: + # One row per section/offering, each with its own faculty + status. + for off in offs: + professor_name = users_map.get(off.instructor_id_id, off.instructor_id_id) + if off.id in submitted_offerings or off.id in verified_offerings: + is_sub = off.id in submitted_offerings + is_ver = off.id in verified_offerings + else: + # No grades bound to this offering yet (e.g. legacy rows + # with no offering) — fall back to course-level status. + is_sub = course.id in submitted_courses + is_ver = course.id in verified_courses + grade_status_list.append({ + "course_code": course.code, + "course_name": course.name, + "course_id": course.id, + "section_label": off.section_label or "—", + "professor_name": professor_name, + "submitted": "Submitted" if is_sub else "Not Submitted", + "verified": "Verified" if is_ver else "Not Verified", + "validated": _validated(course.id, is_ver), + "credits": course.credit, + "version": course.version, + }) + else: + # No offering assigned — single course-level row (existing behaviour). + is_sub = course.id in submitted_courses + is_ver = course.id in verified_courses + grade_status_list.append({ + "course_code": course.code, + "course_name": course.name, + "course_id": course.id, + "section_label": None, + "professor_name": "Not Assigned", + "submitted": "Submitted" if is_sub else "Not Submitted", + "verified": "Verified" if is_ver else "Not Verified", + "validated": _validated(course.id, is_ver), + "credits": course.credit, + "version": course.version, + }) return Response({ "success": True, diff --git a/FusionIIIT/applications/online_cms/migrations/0005_student_grades_course_instructor.py b/FusionIIIT/applications/online_cms/migrations/0005_student_grades_course_instructor.py new file mode 100644 index 000000000..0b793c20f --- /dev/null +++ b/FusionIIIT/applications/online_cms/migrations/0005_student_grades_course_instructor.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.5 on 2026-07-04 11:27 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0033_auto_20260704_1109'), + ('online_cms', '0004_populate_academic_year_and_type'), + ] + + operations = [ + migrations.AddField( + model_name='student_grades', + 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/online_cms/models.py b/FusionIIIT/applications/online_cms/models.py index aa3d5c6ec..20fad9b80 100644 --- a/FusionIIIT/applications/online_cms/models.py +++ b/FusionIIIT/applications/online_cms/models.py @@ -284,6 +284,10 @@ class Student_grades(models.Model): choices=SEMESTER_TYPE_CHOICES, null=True ) + # The offering (course+section+faculty) that produced this grade. Grade + # submission is scoped to the offering's instructor, and this disambiguates + # section-wise rows for the same course. Nullable for pre-feature rows. + course_instructor = models.ForeignKey(CourseInstructor, null=True, blank=True, on_delete=models.SET_NULL) def __str__(self): return '{} - {}'.format(self.pk, self.course_id) diff --git a/FusionIIIT/applications/programme_curriculum/api/views.py b/FusionIIIT/applications/programme_curriculum/api/views.py index a1e78b939..a59a29c39 100644 --- a/FusionIIIT/applications/programme_curriculum/api/views.py +++ b/FusionIIIT/applications/programme_curriculum/api/views.py @@ -3426,9 +3426,9 @@ def admin_view_all_course_instructor(request): faculty_first_name=F('instructor_id__id__user__first_name'), faculty_last_name=F('instructor_id__id__user__last_name') ).values( - 'course_id', 'course_name', 'course_code', 'course_version', - 'instructor_id','semester_type', 'faculty_first_name', 'faculty_last_name', - 'year', 'id' + 'course_id', 'course_name', 'course_code', 'course_version', + 'instructor_id','semester_type', 'faculty_first_name', 'faculty_last_name', + 'year', 'id', 'section_label' ) for instructor in course_instructors: obj = CourseInstructor( @@ -3507,7 +3507,19 @@ def add_course_instructor(request): data.pop("academic_year", None) form = CourseInstructorForm(data) if form.is_valid(): - form.save() + instance = form.save(commit=False) + # Named sections (A-F) are kept unique by the DB constraint, but a + # NULL (elective) section is not (Postgres treats NULLs as distinct), + # so enforce a single elective offering per course/term here. + if instance.section_label is None and CourseInstructor.objects.filter( + course_id=instance.course_id, year=instance.year, + semester_type=instance.semester_type, section_label__isnull=True, + ).exists(): + return JsonResponse( + {"error": "An elective (no-section) offering already exists for this course and term."}, + status=400, + ) + instance.save() return JsonResponse( {"success": "Instructor added successfully"}, status=201 ) @@ -3540,6 +3552,12 @@ def add_course_instructor(request): instr_id = str(sheet.cell(i, 2).value).strip() acad_year = str(sheet.cell(i, 3).value).strip() sem_type = str(sheet.cell(i, 4).value).strip() + # Section (6th column, index 5) is optional: blank = elective / single offering. + sec = None + if sheet.ncols > 5: + sec = str(sheet.cell(i, 5).value).strip().upper() or None + if sec is not None and sec not in ('A', 'B', 'C', 'D', 'E', 'F'): + raise ValueError(f"Bad section '{sec}' (expected A-F or blank)") # convert year year = parse_academic_year(acad_year, sem_type) @@ -3556,12 +3574,13 @@ def add_course_instructor(request): if not fac: raise ValueError(f"Instructor {instr_id} not found") + # One owning faculty per (course, term, section). if CourseInstructor.objects.filter( - course_id=course, instructor_id=fac, year=year + course_id=course, year=year, semester_type=sem_type, section_label=sec ).exists(): - raise ValueError("Duplicate entry") + raise ValueError("Duplicate entry for this course/term/section") - rows.append((course, fac, year, sem_type)) + rows.append((course, fac, year, sem_type, sec)) except Exception as e: errors.append({"row": i + 1, "error": str(e)}) @@ -3569,12 +3588,13 @@ def add_course_instructor(request): return JsonResponse({"error": "Validation failed", "details": errors}, status=400) # bulk insert - for course, fac, year, sem_type in rows: + for course, fac, year, sem_type, sec in rows: CourseInstructor.objects.create( course_id=course, instructor_id=fac, year=year, - semester_type=sem_type + semester_type=sem_type, + section_label=sec, ) return JsonResponse({"success": f"{len(rows)} instructors added"}, status=201) diff --git a/FusionIIIT/applications/programme_curriculum/forms.py b/FusionIIIT/applications/programme_curriculum/forms.py index 49189b282..4037e6e17 100644 --- a/FusionIIIT/applications/programme_curriculum/forms.py +++ b/FusionIIIT/applications/programme_curriculum/forms.py @@ -417,9 +417,23 @@ class CourseInstructorForm(forms.ModelForm): label="Select Semester Type", widget=forms.Select(attrs={'class': 'ui fluid search selection dropdown'}) ) + section_label = forms.ChoiceField( + choices=[('', 'No section (single-offering / elective)')] + [(s, s) for s in ['A', 'B', 'C', 'D', 'E', 'F']], + required=False, + label="Section", + widget=forms.Select(attrs={'class': 'ui fluid search selection dropdown'}) + ) + + def clean_section_label(self): + # Normalise blank -> None so a single-offering elective stays NULL + # (one elective offering per course/term is enforced in the view); a + # letter A-F identifies a core section owned by exactly one faculty. + value = (self.cleaned_data.get('section_label') or '').strip().upper() + return value or None + class Meta: model = CourseInstructor - fields = ['course_id', 'instructor_id', 'year', 'semester_type'] + fields = ['course_id', 'instructor_id', 'year', 'semester_type', 'section_label'] # def __init__(self, *args, **kwargs): # super().__init__(*args, **kwargs) diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0033_auto_20260704_1109.py b/FusionIIIT/applications/programme_curriculum/migrations/0033_auto_20260704_1109.py new file mode 100644 index 000000000..6ae2bcb0a --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0033_auto_20260704_1109.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1.5 on 2026-07-04 11:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0032_auto_20260210_1820'), + ] + + operations = [ + migrations.AddField( + model_name='courseinstructor', + name='section_label', + field=models.CharField(blank=True, help_text='Section (A-F) this offering teaches; blank for a single-offering elective.', max_length=4, null=True), + ), + migrations.AlterUniqueTogether( + name='courseinstructor', + unique_together={('course_id', 'year', 'semester_type', 'section_label')}, + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/models.py b/FusionIIIT/applications/programme_curriculum/models.py index 54276ba6e..438d5b9ed 100644 --- a/FusionIIIT/applications/programme_curriculum/models.py +++ b/FusionIIIT/applications/programme_curriculum/models.py @@ -289,9 +289,19 @@ class CourseInstructor(models.Model): choices=SEMESTER_TYPE_CHOICES, null=True ) + # Which B.Tech section (A-F) this offering teaches. A course taught to + # multiple sections has one CourseInstructor row per section, each with its + # own instructor. Left blank for a single-offering course (e.g. an + # interdisciplinary elective taught as one class by one dedicated faculty). + section_label = models.CharField( + max_length=4, null=True, blank=True, + help_text="Section (A-F) this offering teaches; blank for a single-offering elective.", + ) class Meta: - unique_together = ('course_id', 'instructor_id', 'year', 'semester_type') + # One owning faculty per (course, term, section). instructor_id is + # deliberately NOT in the key: a section must have exactly one owner. + unique_together = ('course_id', 'year', 'semester_type', 'section_label') @property def academic_year(self): diff --git a/FusionIIIT/applications/programme_curriculum/models_student_management.py b/FusionIIIT/applications/programme_curriculum/models_student_management.py index ba95d853c..406677cf7 100644 --- a/FusionIIIT/applications/programme_curriculum/models_student_management.py +++ b/FusionIIIT/applications/programme_curriculum/models_student_management.py @@ -631,8 +631,8 @@ def create_student_profiles_automatically(students_list): ) if student.user and student.roll_number: - from applications.academic_information.models import Student as AcademicStudent - + from applications.academic_information.models import Student as AcademicStudent, compute_section + try: extra_info = ExtraInfo.objects.get(id=student.roll_number) @@ -647,7 +647,8 @@ def create_student_profiles_automatically(students_list): 'mother_name': student.mother_name or '', 'hall_no': 0, 'room_no': '', - 'specialization': '' + 'specialization': '', + 'section': compute_section(getattr(student, 'branch', None), student.roll_number, student.get_programme_name()), } ) From 32a4b5387241bc988f0ea761abbeeac5457f5f50 Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Mon, 6 Jul 2026 00:34:34 +0530 Subject: [PATCH 2/9] Assign B.Tech section at admission (upcoming-batch feed) - Add StudentBatchUpload.section, auto-derived in save() from discipline + roll-number parity, so a student's section is decided when data is fed via Admin > Upcoming Batches (recomputed on every save, e.g. when a roll number is later assigned). Auto-exposed by StudentBatchUploadSerializer (fields '__all__'). - Migration: programme_curriculum 0034. --- .../0034_studentbatchupload_section.py | 18 ++++++++++++++++++ .../models_student_management.py | 10 +++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 FusionIIIT/applications/programme_curriculum/migrations/0034_studentbatchupload_section.py diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0034_studentbatchupload_section.py b/FusionIIIT/applications/programme_curriculum/migrations/0034_studentbatchupload_section.py new file mode 100644 index 000000000..8be9b9a6e --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0034_studentbatchupload_section.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-07-06 00:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0033_auto_20260704_1109'), + ] + + operations = [ + migrations.AddField( + model_name='studentbatchupload', + name='section', + field=models.CharField(blank=True, help_text='B.Tech section (A-F), auto-derived at save from discipline + roll-number parity', max_length=2, null=True), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/models_student_management.py b/FusionIIIT/applications/programme_curriculum/models_student_management.py index 406677cf7..e626d2a5c 100644 --- a/FusionIIIT/applications/programme_curriculum/models_student_management.py +++ b/FusionIIIT/applications/programme_curriculum/models_student_management.py @@ -185,6 +185,7 @@ class StudentBatchUpload(models.Model): # Academic information branch = models.CharField(max_length=200, help_text="Discipline/Branch") specialization = models.CharField(max_length=200, blank=True, null=True, help_text="Specialization") + section = models.CharField(max_length=2, blank=True, null=True, help_text="B.Tech section (A-F), auto-derived at save from discipline + roll-number parity") date_of_birth = models.DateField(blank=True, null=True) ai_rank = models.IntegerField(blank=True, null=True, help_text="JEE AI Rank", db_column='jee_rank') category_rank = models.IntegerField(blank=True, null=True, help_text="Category Rank") @@ -349,7 +350,14 @@ def save(self, *args, **kwargs): # Clean branch name if self.branch: self.branch = self.clean_branch_name(self.branch) - + + # Decide the B.Tech section (A-F) at admission time, from discipline + + # roll-number parity, so it's recorded on the upcoming-batch feed itself. + # Recomputed on every save (e.g. when a roll number is later assigned). + # Lazy import avoids a circular import with academic_information. + from applications.academic_information.models import compute_section + self.section = compute_section(self.branch, self.roll_number, self.get_programme_name()) + super().save(*args, **kwargs) def create_user_account(self, password=None): From 34b021d088f7e4158464ffe026d168cd6e9f8462 Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Mon, 6 Jul 2026 01:01:05 +0530 Subject: [PATCH 3/9] Return section in batch-students API (upcoming batches) - get_batch_students now includes each student's section in its response so the Upcoming Batches admin view can display it (StudentBatchUpload.section, derived at admission from discipline + roll-number parity). --- .../programme_curriculum/api/views_student_management.py | 1 + 1 file changed, 1 insertion(+) diff --git a/FusionIIIT/applications/programme_curriculum/api/views_student_management.py b/FusionIIIT/applications/programme_curriculum/api/views_student_management.py index 25c54ccf3..3a6ff1b41 100644 --- a/FusionIIIT/applications/programme_curriculum/api/views_student_management.py +++ b/FusionIIIT/applications/programme_curriculum/api/views_student_management.py @@ -3984,6 +3984,7 @@ def get_batch_students(request, batch_id): 'state': getattr(student, 'state', ''), 'branch': student.branch, + 'section': getattr(student, 'section', '') or '', 'specialization': getattr(student, 'specialization', ''), 'ai_rank': getattr(student, 'ai_rank', None), 'category_rank': getattr(student, 'category_rank', None), From 5ad4d921246556cf890cb4ba5bb9dd09cce3b156 Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Wed, 8 Jul 2026 18:36:27 +0530 Subject: [PATCH 4/9] Add manual section assignment; make section filters conditional - New acadadmin Section Assignment API: section/batches, section/students, section/assign - Stop auto-deriving Student.section (onboarding + StudentBatchUpload.save); branch change clears it - get_batch_students shows the assigned section read-only (by roll number) - available_courses exposes per-course sections from enrolled students' Student.section - Faculty assigned-courses API returns each offering's section --- .../academic_information/api/urls.py | 5 + .../academic_information/api/views.py | 96 ++++++++++++++++++- .../academic_procedures/api/views.py | 2 + .../applications/academic_procedures/views.py | 8 +- .../api/views_student_management.py | 13 ++- .../models_student_management.py | 14 +-- 6 files changed, 117 insertions(+), 21 deletions(-) diff --git a/FusionIIIT/applications/academic_information/api/urls.py b/FusionIIIT/applications/academic_information/api/urls.py index 44246dc03..1dd8edbd1 100644 --- a/FusionIIIT/applications/academic_information/api/urls.py +++ b/FusionIIIT/applications/academic_information/api/urls.py @@ -35,6 +35,11 @@ url(r'^calendar/export/$', views.export_calendar, name='export_calendar'), url(r'^calendar/import/$', views.import_calendar, name='import_calendar'), + # Section assignment (Academics > Section Assignment) + url(r'^section/batches/$', views.section_batches, name='section_batches'), + url(r'^section/students/$', views.section_students, name='section_students'), + url(r'^section/assign/$', views.assign_section, name='assign_section'), + # url(r'^holiday',views.holiday_api,name='holiday-get-api'), diff --git a/FusionIIIT/applications/academic_information/api/views.py b/FusionIIIT/applications/academic_information/api/views.py index fdef6d160..eee02c074 100644 --- a/FusionIIIT/applications/academic_information/api/views.py +++ b/FusionIIIT/applications/academic_information/api/views.py @@ -1314,7 +1314,14 @@ def available_courses(request): course_ids = regs.values_list('course_id', flat=True).distinct() courses = Courses.objects.filter(id__in=course_ids) - + + # Per-course sections = distinct Student.section of its enrolled students (empty => UI hides filter). + from collections import defaultdict + section_map = defaultdict(set) + for cid, sec in regs.values_list('course_id', 'student_id__section'): + if sec: + section_map[cid].add(sec) + data = [] # Calculate correct year based on semester type year_parts = year.split('-') @@ -1328,12 +1335,13 @@ def available_courses(request): course_instructor = CourseInstructor.objects.filter(course_id=c, year=year_int, semester_type=sem).first() if course_instructor: instructor_name = f"{course_instructor.instructor_id.id.user.first_name} {course_instructor.instructor_id.id.user.last_name}".strip() - + data.append({ "id": c.id, "code": c.code, "name": c.name, - "instructor": instructor_name + "instructor": instructor_name, + "sections": sorted(section_map.get(c.id, set())), }) return Response(data) @@ -1503,4 +1511,84 @@ def export_all_courses_zip(request): response['Content-Disposition'] = f'attachment; filename="{zip_filename}"' return response - return Response(data) \ No newline at end of file + return Response(data) + + +# Section assignment (Academics > Section Assignment): acadadmin sets Student.section manually. + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +@role_required(['acadadmin']) +def section_batches(request): + """Running batches, for the Batch and Discipline dropdowns.""" + batches = (Batch.objects.filter(running_batch=True) + .select_related('discipline') + .order_by('-year', 'name')) + result = [] + for b in batches: + disc = b.discipline + result.append({ + 'id': b.id, + 'year': b.year, + 'name': b.name, + 'discipline_name': disc.name if disc else '', + 'discipline_acronym': disc.acronym if disc else '', + 'label': f"{b.name} {disc.acronym if disc else ''} {b.year}".strip(), + }) + return Response(result) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +@role_required(['acadadmin']) +def section_students(request): + """Students of one batch (?batch_id=) with their assigned section, by roll no.""" + batch_id = request.query_params.get('batch_id') + if not batch_id: + return Response({'detail': 'batch_id required.'}, + status=status.HTTP_400_BAD_REQUEST) + + students = (Student.objects + .filter(batch_id__id=batch_id) + .select_related('id', 'id__user') + .order_by('id_id')) + + result = [] + for idx, st in enumerate(students, start=1): + user = getattr(st.id, 'user', None) + full_name = '' + if user: + full_name = (f"{user.first_name} {user.last_name}").strip() or user.username + result.append({ + 'sno': idx, + 'roll_no': st.id_id, + 'name': full_name, + 'section': st.section or '', + }) + return Response(result) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +@role_required(['acadadmin']) +def assign_section(request): + """Bulk-set section for {"roll_numbers": [...], "section": "A"}; empty section clears it.""" + roll_numbers = request.data.get('roll_numbers') or [] + section = (request.data.get('section') or '').strip().upper() + + if not roll_numbers: + return Response({'detail': 'roll_numbers required.'}, + status=status.HTTP_400_BAD_REQUEST) + if section and (len(section) > 2 or not section.isalpha()): + return Response({'detail': 'section must be one or two letters.'}, + status=status.HTTP_400_BAD_REQUEST) + + updated = (Student.objects + .filter(id_id__in=roll_numbers) + .update(section=section or None)) + return Response({'detail': f'Section updated for {updated} student(s).', + 'updated': updated, + 'section': section or None}) \ No newline at end of file diff --git a/FusionIIIT/applications/academic_procedures/api/views.py b/FusionIIIT/applications/academic_procedures/api/views.py index df1531cba..7fda692d4 100644 --- a/FusionIIIT/applications/academic_procedures/api/views.py +++ b/FusionIIIT/applications/academic_procedures/api/views.py @@ -1951,6 +1951,8 @@ def academic_procedures_faculty_api(request): "version": course.course_id.version, "semester_type": course.semester_type, "academic_year": course.academic_year, + # This offering's section (A-F), or "" for old/single-offering courses. + "section": course.section_label or "", }) return Response({"assigned_courses": response_data}) diff --git a/FusionIIIT/applications/academic_procedures/views.py b/FusionIIIT/applications/academic_procedures/views.py index d56dc4914..79ddb2f5b 100644 --- a/FusionIIIT/applications/academic_procedures/views.py +++ b/FusionIIIT/applications/academic_procedures/views.py @@ -900,7 +900,6 @@ def approve_branch_change(request): continue from applications.programme_curriculum.models import Discipline, Batch - from applications.academic_information.models import compute_section changed_branch = [] changed_students = [] @@ -930,11 +929,8 @@ def approve_branch_change(request): ).first() if new_batch: student.batch_id = new_batch - # Discipline changed -> section follows automatically. - # Roll number is the immutable PK, so parity is - # unchanged; only the discipline mapping flips - # (e.g. ECE 'C' -> CSE 'A'/'B'). - student.section = compute_section(new_discipline.name, student.id_id, student.programme) + # Discipline changed -> clear stale section for re-assignment. + student.section = None changed_students.append(student) except Exception as e: pass diff --git a/FusionIIIT/applications/programme_curriculum/api/views_student_management.py b/FusionIIIT/applications/programme_curriculum/api/views_student_management.py index 3a6ff1b41..8fd3398d7 100644 --- a/FusionIIIT/applications/programme_curriculum/api/views_student_management.py +++ b/FusionIIIT/applications/programme_curriculum/api/views_student_management.py @@ -3962,6 +3962,17 @@ def get_batch_students(request, batch_id): students = students.order_by('roll_number') + # Read-only mirror of the assigned Student.section, keyed by roll number. + roll_numbers = [s.roll_number for s in students if s.roll_number] + assigned_sections = {} + if roll_numbers: + from applications.academic_information.models import Student as AcademicStudent + assigned_sections = dict( + AcademicStudent.objects + .filter(id_id__in=roll_numbers) + .values_list('id_id', 'section') + ) + upload_students = [] for student in students: upload_students.append({ @@ -3984,7 +3995,7 @@ def get_batch_students(request, batch_id): 'state': getattr(student, 'state', ''), 'branch': student.branch, - 'section': getattr(student, 'section', '') or '', + 'section': assigned_sections.get(student.roll_number) or '', 'specialization': getattr(student, 'specialization', ''), 'ai_rank': getattr(student, 'ai_rank', None), 'category_rank': getattr(student, 'category_rank', None), diff --git a/FusionIIIT/applications/programme_curriculum/models_student_management.py b/FusionIIIT/applications/programme_curriculum/models_student_management.py index e626d2a5c..a444f7a7b 100644 --- a/FusionIIIT/applications/programme_curriculum/models_student_management.py +++ b/FusionIIIT/applications/programme_curriculum/models_student_management.py @@ -351,13 +351,7 @@ def save(self, *args, **kwargs): if self.branch: self.branch = self.clean_branch_name(self.branch) - # Decide the B.Tech section (A-F) at admission time, from discipline + - # roll-number parity, so it's recorded on the upcoming-batch feed itself. - # Recomputed on every save (e.g. when a roll number is later assigned). - # Lazy import avoids a circular import with academic_information. - from applications.academic_information.models import compute_section - self.section = compute_section(self.branch, self.roll_number, self.get_programme_name()) - + # Section is assigned manually in Academics > Section Assignment, not auto-derived here. super().save(*args, **kwargs) def create_user_account(self, password=None): @@ -639,11 +633,12 @@ def create_student_profiles_automatically(students_list): ) if student.user and student.roll_number: - from applications.academic_information.models import Student as AcademicStudent, compute_section + from applications.academic_information.models import Student as AcademicStudent try: extra_info = ExtraInfo.objects.get(id=student.roll_number) - + + # Section left blank at onboarding; assigned later in Section Assignment. academic_student, created = AcademicStudent.objects.get_or_create( id=extra_info, defaults={ @@ -656,7 +651,6 @@ def create_student_profiles_automatically(students_list): 'hall_no': 0, 'room_no': '', 'specialization': '', - 'section': compute_section(getattr(student, 'branch', None), student.roll_number, student.get_programme_name()), } ) From 99bfb130ac78b75480382f7312c77a4292ea36c2 Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Thu, 9 Jul 2026 21:00:42 +0530 Subject: [PATCH 5/9] Section-aware grade submission; allow acadadmin to submit - SubmitGradesProfAPI: allow acadadmin (lists all offerings that term) and return per-course sections (faculty = own offerings' section_labels, acadadmin = all); electives/interdisciplinary have none. - UploadGradesProfAPI: allow acadadmin (bypass instructor ownership), accept an optional section to scope the roster; each grade still binds to the student's own offering via resolve_offering. - download_template + PreviewGradesAPI: accept section to scope the roster. --- .../applications/examination/api/views.py | 103 +++++++++++++----- 1 file changed, 78 insertions(+), 25 deletions(-) diff --git a/FusionIIIT/applications/examination/api/views.py b/FusionIIIT/applications/examination/api/views.py index b9510f748..e777da90c 100644 --- a/FusionIIIT/applications/examination/api/views.py +++ b/FusionIIIT/applications/examination/api/views.py @@ -423,7 +423,12 @@ def download_template(request): course_info_query = course_info_query.filter( student_id__in=student_ids_with_programme ) - + + # Optional section scope (course "allotted in sections"); electives ignore it. + section = (request.data.get('section') or '').strip() or None + if section: + course_info_query = course_info_query.filter(student_id__section=section) + course_info = course_info_query.order_by("student_id_id") if not course_info.exists(): @@ -1505,8 +1510,9 @@ def post(self, request): academic_year = request.data.get("academic_year") semester_type = request.data.get("semester_type") programme_type = request.data.get("programme_type") - - if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): + + is_acadadmin = user_holds_any_role(request.user, ["acadadmin"]) + if not is_acadadmin and not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -1517,18 +1523,34 @@ def post(self, request): {"success": False, "error": "Academic year and semester type are required."}, status=400, ) - + instructor_id = request.user.username working_year, _ = parse_academic_year(academic_year=academic_year, semester_type=semester_type) + # acadadmin sees every offering this term; faculty only their own. + offerings_qs = CourseInstructor.objects.filter(year=working_year, semester_type=semester_type) + if not is_acadadmin: + offerings_qs = offerings_qs.filter(instructor_id_id=instructor_id) + unique_course_ids = ( - CourseInstructor.objects.filter(instructor_id_id=instructor_id, year = working_year, semester_type=semester_type) + offerings_qs .values("course_id_id") .distinct() .annotate(course_id_int=Cast("course_id_id", IntegerField())) ) + # Map course -> its section labels (only "allotted in sections" courses + # have any; electives/interdisciplinary have none). + from collections import defaultdict + section_map = defaultdict(set) + for o in offerings_qs: + if o.section_label: + try: + section_map[int(o.course_id_id)].add(o.section_label) + except (TypeError, ValueError): + pass + # Retrieve course details with programme type filtering courses_query = Courses.objects.filter( id__in=unique_course_ids.values_list("course_id_int", flat=True) @@ -1577,6 +1599,7 @@ def post(self, request): course['student_count'] = course_registrations.count() course['has_students'] = course['student_count'] > 0 course['programme_type'] = programme_type + course['sections'] = sorted(section_map.get(course['id'], set())) courses_data.append(course) return Response( @@ -1603,7 +1626,8 @@ def post(self, request): try: # 1) ROLE CHECK role = request.data.get("Role") - if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): + is_acadadmin = user_holds_any_role(request.user, ["acadadmin"]) + if not is_acadadmin and not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) @@ -1660,20 +1684,35 @@ def post(self, request): status=status.HTTP_400_BAD_REQUEST ) - # INSTRUCTOR OWNERSHIP: resolve the offering(s) this faculty owns for - # this course + term. Grading is scoped to these — a faculty may only - # grade the students in the section(s) they are assigned to teach. - my_offerings = list(CourseInstructor.objects.filter( + # OFFERINGS: acadadmin may grade any offering of this course; faculty + # only the offering(s) they are assigned to teach. An optional + # `section` narrows this to a single section (electives have none). + section = (request.data.get("section") or "").strip() or None + + all_offerings = list(CourseInstructor.objects.filter( course_id_id=course_id, - instructor_id_id=request.user.username, year=working_year, semester_type=semester_type, )) - if not my_offerings: - return Response( - {"error": "Access denied: you are not assigned to teach this course this term."}, - status=status.HTTP_403_FORBIDDEN - ) + if is_acadadmin: + my_offerings = all_offerings + else: + my_offerings = [o for o in all_offerings + if str(o.instructor_id_id) == str(request.user.username)] + if not my_offerings: + return Response( + {"error": "Access denied: you are not assigned to teach this course this term."}, + status=status.HTTP_403_FORBIDDEN + ) + + if section: + my_offerings = [o for o in my_offerings if o.section_label == section] + if not my_offerings: + return Response( + {"error": f"No section '{section}' offering you can grade for this course."}, + status=status.HTTP_403_FORBIDDEN + ) + 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 @@ -1682,7 +1721,7 @@ def post(self, request): regs = regs.filter(student_id__section__in=my_sections) if not regs.exists(): return Response( - {"error": "No students are registered in your section for this course."}, + {"error": "No students are registered in the selected section for this course."}, status=status.HTTP_400_BAD_REQUEST ) @@ -1835,14 +1874,23 @@ def post(self, request): ) continue - # SECTION OWNERSHIP: the student must belong to a section this - # faculty teaches, otherwise this user cannot grade them. + # Bind each grade to the student's own section offering. stud_offering = resolve_offering(stud, course, working_year, semester_type) - if stud_offering is None or stud_offering.id not in my_offering_ids: - errors.append( - f"Row {idx}: Student {roll_no} is not in a section you are assigned to teach." - ) - continue + if is_acadadmin: + # acadadmin may grade any student; if a section was chosen, + # accept only students of that section. + if section and (stud.section or None) != section: + errors.append( + f"Row {idx}: Student {roll_no} is not in section {section}." + ) + continue + else: + # Faculty may only grade students in a section they teach. + if stud_offering is None or stud_offering.id not in my_offering_ids: + errors.append( + f"Row {idx}: Student {roll_no} is not in a section you are assigned to teach." + ) + continue # Check if student belongs to the specified programme type if programme_type: @@ -2917,7 +2965,12 @@ def post(self, request): ).values_list('id', flat=True) registrations = registrations.filter(student_id__in=student_ids_with_programme) - + + # Optional section scope (course "allotted in sections"); electives ignore it. + section = (request.data.get("section") or "").strip() or None + if section: + registrations = registrations.filter(student_id__section=section) + # Build a set of registered roll numbers for fast lookup. registered_rollnos = set() for reg in registrations.select_related("student_id"): From 4a540a829be5a98e31fb01f74590280b71379955 Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Thu, 9 Jul 2026 21:19:00 +0530 Subject: [PATCH 6/9] Target course feedback at the student's section instructor student_questions now resolves each registered course's instructor via resolve_offering (the student's own section offering), falling back to any offering for single-offering / legacy no-section courses. --- .../academic_procedures/api/views.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/FusionIIIT/applications/academic_procedures/api/views.py b/FusionIIIT/applications/academic_procedures/api/views.py index 7fda692d4..9a174a928 100644 --- a/FusionIIIT/applications/academic_procedures/api/views.py +++ b/FusionIIIT/applications/academic_procedures/api/views.py @@ -5271,16 +5271,20 @@ def student_questions(request): semester_id__semester_no=semester_no, ).select_related("course_id") + from applications.academic_information.models import resolve_offering + courses = [] for reg in registrations: course = reg.course_id academic_year, _ = parse_academic_year(reg.session, reg.semester_type) - instructor_entry = CourseInstructor.objects.filter( - course_id=course, - semester_type=reg.semester_type, - year = academic_year - - ).first() + # Target the instructor of the student's own section; fall back to any + # offering for single-offering / legacy (no-section) courses. + instructor_entry = resolve_offering(student, course, academic_year, reg.semester_type) or \ + CourseInstructor.objects.filter( + course_id=course, + semester_type=reg.semester_type, + year=academic_year, + ).first() instructor_id = instructor_entry.id if instructor_entry else None instructor_name = ( From 5a056a34a51122410252bad9cf671aa3a73f5661 Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Fri, 10 Jul 2026 00:22:39 +0530 Subject: [PATCH 7/9] Section-aware course feedback - FeedbackResponse.course_instructor FK (migration 0022) so responses are tied to the offering rated; student_submit binds it via the shown offering. - student_questions: skip courses with no instructor; show the student's section instructor. - admin_course_list: split sectioned courses into per-instructor rows (+ an "unassigned" row for responses with no offering recorded). - admin_all_stats: filter by course_instructor (incl. isnull); fixed section order. --- .../academic_procedures/api/views.py | 95 ++++++++++++++++--- ...0022_feedbackresponse_course_instructor.py | 20 ++++ .../academic_procedures/models.py | 4 + 3 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 FusionIIIT/applications/academic_procedures/migrations/0022_feedbackresponse_course_instructor.py diff --git a/FusionIIIT/applications/academic_procedures/api/views.py b/FusionIIIT/applications/academic_procedures/api/views.py index 9a174a928..cc14bc00d 100644 --- a/FusionIIIT/applications/academic_procedures/api/views.py +++ b/FusionIIIT/applications/academic_procedures/api/views.py @@ -5277,8 +5277,7 @@ def student_questions(request): for reg in registrations: course = reg.course_id academic_year, _ = parse_academic_year(reg.session, reg.semester_type) - # Target the instructor of the student's own section; fall back to any - # offering for single-offering / legacy (no-section) courses. + # The student's own section instructor, else any offering (no-section courses). instructor_entry = resolve_offering(student, course, academic_year, reg.semester_type) or \ CourseInstructor.objects.filter( course_id=course, @@ -5286,17 +5285,20 @@ def student_questions(request): year=academic_year, ).first() - instructor_id = instructor_entry.id if instructor_entry else None + # No instructor -> nothing to give feedback on; skip. + if not instructor_entry: + continue + instructor_name = ( - f"{instructor_entry.instructor_id.id.user.first_name} {instructor_entry.instructor_id.id.user.last_name}" - if instructor_entry else "" - ) + f"{instructor_entry.instructor_id.id.user.first_name} " + f"{instructor_entry.instructor_id.id.user.last_name}" + ).strip() courses.append({ "course_id": course.id, "code": course.code, "name": course.name, - "instructor_id": instructor_id, + "instructor_id": instructor_entry.id, "instructor_name": instructor_name, }) @@ -5331,15 +5333,25 @@ def student_submit(request): if FeedbackFilled.objects.filter(student=student, semester_no = semester_no).exists(): return Response({"detail":"Already filled."}, status=status.HTTP_409_CONFLICT) + from applications.academic_information.models import resolve_offering with transaction.atomic(): for r in data["responses"]: reg = course_registration.objects.get(student_id =student, course_id_id = r["course_id"], semester_id__semester_no = student.curr_semester_no) + # Attribute to the offering the student was shown (their section's, else first). + academic_year, _ = parse_academic_year(reg.session, reg.semester_type) + offering = resolve_offering(student, reg.course_id, academic_year, reg.semester_type) or \ + CourseInstructor.objects.filter( + course_id=reg.course_id, + year=academic_year, + semester_type=reg.semester_type, + ).first() FeedbackResponse.objects.create( question_id = r["question_id"], option_id = r.get("option_id"), text_answer = r.get("text_answer",""), course_id = r["course_id"], + course_instructor = offering, section = r["section"], session = reg.session, semester_type = reg.semester_type, @@ -5459,6 +5471,12 @@ def admin_course_list(request): semester_type=semt, ).select_related("course").distinct() + academic_year, _ = parse_academic_year(sess, semt) + + def _instr_name(offering): + u = offering.instructor_id.id.user + return f"{u.first_name} {u.last_name}".strip() + seen = set() courses = [] for reg in regs: @@ -5466,11 +5484,51 @@ def admin_course_list(request): if c.id in seen: continue seen.add(c.id) - courses.append({ - "course_id": c.id, - "code": c.code, - "name": c.name, - }) + + offerings = list(CourseInstructor.objects.filter( + course_id=c, year=academic_year, semester_type=semt, + ).select_related("instructor_id__id__user")) + + sectioned = len(offerings) > 1 and any(o.section_label for o in offerings) + + if sectioned: + # One row per section offering. + for o in offerings: + courses.append({ + "course_id": c.id, + "code": c.code, + "name": c.name, + "instructor": _instr_name(o), + "section": o.section_label or "", + "course_instructor_id": o.id, + }) + # Surface responses not tied to an offering so the split doesn't hide them. + if FeedbackResponse.objects.filter( + course=c, session=sess, semester_type=semt, + course_instructor__isnull=True, + ).exists(): + courses.append({ + "course_id": c.id, + "code": c.code, + "name": c.name, + "instructor": "Section not recorded", + "section": "", + "course_instructor_id": "none", + }) + else: + names = [] + for o in offerings: + nm = _instr_name(o) + if nm and nm not in names: + names.append(nm) + courses.append({ + "course_id": c.id, + "code": c.code, + "name": c.name, + "instructor": ", ".join(names), + "section": "", + "course_instructor_id": None, + }) return Response(courses) @@ -5502,6 +5560,7 @@ def admin_all_stats(request): sess = request.query_params.get("session") semt = request.query_params.get("semester_type") cid = request.query_params.get("course_id") + course_instructor = request.query_params.get("course_instructor") if not sess or not semt or not cid: return Response( {"detail":"Provide 'session', 'semester_type', and 'course_id'."}, @@ -5516,6 +5575,11 @@ def admin_all_stats(request): session=sess, semester_type=semt, ) + # "none" = responses with no offering recorded. + if course_instructor == "none": + base = base.filter(course_instructor__isnull=True) + elif course_instructor: + base = base.filter(course_instructor_id=course_instructor) counts = { o.text: base.filter(option=o).count() for o in FeedbackOption.objects.filter(question=q) @@ -5542,10 +5606,15 @@ def admin_all_stats(request): "comments": item["comments"], }) + # Fixed display order: course-related sections first, lab-related last. + section_order = ["contents", "instructor", "attendance", "tutorial", "lab"] + def _sec_rank(s): + return section_order.index(s) if s in section_order else len(section_order) + response = { "sections": [ {"section": sec, "questions": qs} - for sec, qs in grouped.items() + for sec, qs in sorted(grouped.items(), key=lambda kv: _sec_rank(kv[0])) ] } diff --git a/FusionIIIT/applications/academic_procedures/migrations/0022_feedbackresponse_course_instructor.py b/FusionIIIT/applications/academic_procedures/migrations/0022_feedbackresponse_course_instructor.py new file mode 100644 index 000000000..93224460c --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0022_feedbackresponse_course_instructor.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.5 on 2026-07-09 22:35 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0034_studentbatchupload_section'), + ('academic_procedures', '0021_auto_20260704_1127'), + ] + + operations = [ + migrations.AddField( + model_name='feedbackresponse', + 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 c1531c885..70c54615b 100644 --- a/FusionIIIT/applications/academic_procedures/models.py +++ b/FusionIIIT/applications/academic_procedures/models.py @@ -1099,6 +1099,10 @@ class FeedbackResponse(models.Model): option = models.ForeignKey(FeedbackOption, on_delete=models.CASCADE, null=True, blank=True) text_answer = models.TextField(blank=True) course = models.ForeignKey(Courses, on_delete=models.CASCADE) + # Offering this feedback is about (null for no-section / legacy responses). + course_instructor = models.ForeignKey( + 'programme_curriculum.CourseInstructor', null=True, blank=True, + on_delete=models.SET_NULL) section = models.CharField(max_length=20, choices=FeedbackQuestion.SECTION_CHOICES) session = models.CharField(max_length=9) semester_type = models.CharField(max_length=20, choices=SEMESTER_CHOICES) From 7e50430b31c9140abd61095b1cbe37a170f4c771 Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Fri, 10 Jul 2026 00:23:50 +0530 Subject: [PATCH 8/9] Fix per-section grade status; scope roster/preview to owning faculty - GradeStatusAPI: report each offering by its own status; fall back to course-level only when no offering has grades bound (was marking unsubmitted sections as Submitted/Verified). - download_template & PreviewGradesAPI: faculty may only access courses they teach (acadadmin/Dean: any); previously any faculty could pull any roster. --- .../applications/examination/api/views.py | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/FusionIIIT/applications/examination/api/views.py b/FusionIIIT/applications/examination/api/views.py index e777da90c..220ebaea7 100644 --- a/FusionIIIT/applications/examination/api/views.py +++ b/FusionIIIT/applications/examination/api/views.py @@ -394,6 +394,24 @@ def download_template(request): if not user_holds_any_role(request.user, allowed_roles): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) + # Faculty may only pull rosters for courses they teach; acadadmin/Dean: any. + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): + try: + owns_year, _ = parse_academic_year(session_year, semester_type) + except Exception: + owns_year = None + owns = CourseInstructor.objects.filter( + course_id_id=course, + instructor_id_id=request.user.username, + year=owns_year, + semester_type=semester_type, + ).exists() + if not owns: + return Response( + {"error": "Access denied: you are not assigned to teach this course this term."}, + status=status.HTTP_403_FORBIDDEN, + ) + User = get_user_model() # Filter course_registration records using course, session (academic year), and semester_type. @@ -2933,6 +2951,24 @@ def post(self, request): status=status.HTTP_400_BAD_REQUEST, ) + # Faculty may only preview grades for courses they teach; acadadmin/Dean: any. + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): + try: + owns_year, _ = parse_academic_year(academic_year, semester_type) + except Exception: + owns_year = None + owns = CourseInstructor.objects.filter( + course_id_id=course_id, + instructor_id_id=request.user.username, + year=owns_year, + semester_type=semester_type, + ).exists() + if not owns: + return Response( + {"error": "Access denied: you are not assigned to teach this course this term."}, + status=status.HTTP_403_FORBIDDEN, + ) + # Extract the starting working year from academic_year. try: working_year = int(academic_year.split("-")[0]) @@ -3485,15 +3521,18 @@ def _validated(course_id, is_verified): for course in courses: offs = offerings_by_course.get(course.id, []) if offs: - # One row per section/offering, each with its own faculty + status. + # Fall back to course-level status only when no offering has + # bound grades (legacy rows without an offering FK). + course_has_bound = any( + off.id in submitted_offerings or off.id in verified_offerings + for off in offs + ) for off in offs: professor_name = users_map.get(off.instructor_id_id, off.instructor_id_id) - if off.id in submitted_offerings or off.id in verified_offerings: + if course_has_bound: is_sub = off.id in submitted_offerings is_ver = off.id in verified_offerings else: - # No grades bound to this offering yet (e.g. legacy rows - # with no offering) — fall back to course-level status. is_sub = course.id in submitted_courses is_ver = course.id in verified_courses grade_status_list.append({ From 213f6115a39fa743bce5b4ac50b3359a51c2f927 Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Fri, 10 Jul 2026 00:36:20 +0530 Subject: [PATCH 9/9] Scope grade reSubmit reset; validate feedback option/section - UploadGradesProfAPI: reset reSubmit only for this submission's roster (never course-wide when programme_type is empty). - student_submit: accept an option only if it belongs to the question, and derive the section from the question instead of trusting the request. --- .../applications/academic_procedures/api/views.py | 15 ++++++++++++--- FusionIIIT/applications/examination/api/views.py | 15 ++++----------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/FusionIIIT/applications/academic_procedures/api/views.py b/FusionIIIT/applications/academic_procedures/api/views.py index cc14bc00d..1f438ac3b 100644 --- a/FusionIIIT/applications/academic_procedures/api/views.py +++ b/FusionIIIT/applications/academic_procedures/api/views.py @@ -5346,13 +5346,22 @@ def student_submit(request): year=academic_year, semester_type=reg.semester_type, ).first() + # Trust the server for question/section; only accept an option that + # actually belongs to the question. + question = FeedbackQuestion.objects.filter(id=r.get("question_id")).first() + if not question: + continue + option = None + if r.get("option_id"): + option = FeedbackOption.objects.filter( + id=r["option_id"], question=question).first() FeedbackResponse.objects.create( - question_id = r["question_id"], - option_id = r.get("option_id"), + question = question, + option = option, text_answer = r.get("text_answer",""), course_id = r["course_id"], course_instructor = offering, - section = r["section"], + section = question.section, session = reg.session, semester_type = reg.semester_type, ) diff --git a/FusionIIIT/applications/examination/api/views.py b/FusionIIIT/applications/examination/api/views.py index 220ebaea7..1faefad1d 100644 --- a/FusionIIIT/applications/examination/api/views.py +++ b/FusionIIIT/applications/examination/api/views.py @@ -1848,21 +1848,14 @@ def post(self, request): # 10) ATOMIC PROCESSING errors = [] with transaction.atomic(): - # ─── Reset reSubmit flags for this course/year/semester/programme ─── + # Reset reSubmit only for this submission's roster (already scoped + # to the section + programme above), never course-wide. reset_query = Student_grades.objects.filter( course_id_id=course_id, academic_year=academic_year, - semester_type=semester_type + semester_type=semester_type, + roll_no__in=[reg.student_id_id for reg in regs], ) - - if programme_type: - if programme_type.upper() == 'UG': - ug_rolls = [reg.student_id_id for reg in regs] - reset_query = reset_query.filter(roll_no__in=ug_rolls) - elif programme_type.upper() == 'PG': - pg_rolls = [reg.student_id_id for reg in regs] - reset_query = reset_query.filter(roll_no__in=pg_rolls) - reset_query.update(reSubmit=False) # ─── Process each CSV row ───