Skip to content
Merged
5 changes: 5 additions & 0 deletions FusionIIIT/applications/academic_information/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),

Expand Down
128 changes: 116 additions & 12 deletions FusionIIIT/applications/academic_information/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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', '')
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}"'

Expand Down Expand Up @@ -1298,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('-')
Expand All @@ -1312,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)

Expand Down Expand Up @@ -1487,4 +1511,84 @@ def export_all_courses_zip(request):
response['Content-Disposition'] = f'attachment; filename="{zip_filename}"'
return response

return Response(data)
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})
Empty file.
Original file line number Diff line number Diff line change
@@ -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."))
Original file line number Diff line number Diff line change
@@ -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),
),
]
Loading
Loading