From 625c5bdd2bc7a34945ee3eb503cbdd64f7d3f59b Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Mon, 5 Oct 2026 17:29:52 +0530 Subject: [PATCH] Let acad admin pick the financial year on the Fee Structure Certificate The certificate used to always cover the student's current semester pair. It now accepts an explicit semester, validates it against the published fee structure, and lists every valid financial year so the frontend can offer a picker. The semester number also renders with its ordinal suffix raised, matching the other certificates. --- .../api/fee_structure_certificate.py | 46 +++++++++++++++++-- .../test_fee_structure_certificate.py | 46 +++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/FusionIIIT/applications/academic_procedures/api/fee_structure_certificate.py b/FusionIIIT/applications/academic_procedures/api/fee_structure_certificate.py index 0d6ee67f2..08e5960e0 100644 --- a/FusionIIIT/applications/academic_procedures/api/fee_structure_certificate.py +++ b/FusionIIIT/applications/academic_procedures/api/fee_structure_certificate.py @@ -29,6 +29,7 @@ _search_certificates, _student_queryset, build_certificate_context, + superscript_ordinal, ) from .demand_letter import financial_year_for from .fee_certificate import _is_concession, fee_structure_for, indian_currency @@ -85,6 +86,16 @@ def _column_total(rows, key): return sum((Decimal(row[key]) for row in rows), Decimal('0')) +def financial_year_choices(student, structure): + if not structure: + return [] + choices = [] + for odd in range(1, structure.semester_count + 1, 2): + _, label = financial_year_for(student, odd) + choices.append({'value': odd, 'label': label}) + return choices + + def render_fee_structure_pdf(context, structure, odd_semester, academic_year_label, rows, reference_number, issued_on): output = BytesIO() @@ -163,7 +174,8 @@ def value_cell(text): value_cell(f'Mr. {context["father_name"]}'), '', ''], [label_cell('Roll No:'), value_cell(context['roll_number']), label_cell('Branch:'), value_cell(context['discipline'])], - [label_cell('Semester:'), value_cell(context['semester_ordinal']), + [label_cell('Semester:'), + Paragraph(superscript_ordinal(context['semester_ordinal']), value_style), label_cell('Programme:'), value_cell(context['programme_short'])], [label_cell('Financial Year:'), value_cell(academic_year_label), '', ''], ], @@ -276,13 +288,24 @@ def fee_structure_certificate_student(request): rows = [] academic_year_label = '' + odd_semester = None if structure is None: batch_label = context.get('programme') or 'unrecognised' errors.append( f'No fee structure is recorded for {batch_label} programmes. ' 'Add one before issuing this certificate.') - elif context['semester']: - odd_semester, _ = session_semesters(context['semester']) + else: + default_odd_semester = ( + session_semesters(context['semester'])[0] if context['semester'] else 1) + valid_odds = list(range(1, structure.semester_count + 1, 2)) + semester_param = request.query_params.get('semester') + try: + odd_semester = int(semester_param) if semester_param else default_odd_semester + except (TypeError, ValueError): + odd_semester = default_odd_semester + if odd_semester not in valid_odds: + odd_semester = (default_odd_semester if default_odd_semester in valid_odds + else valid_odds[0]) rows = build_structure_rows(student, structure, odd_semester) _, academic_year_label = financial_year_for(student, odd_semester) @@ -293,6 +316,8 @@ def fee_structure_certificate_student(request): return Response({ 'student': {**context, 'is_ready': not errors, 'validation_errors': errors}, + 'financial_years': financial_year_choices(student, structure), + 'selected_semester': odd_semester, 'structure': { 'rows': rows, 'academic_year_label': academic_year_label, @@ -318,6 +343,7 @@ def fee_structure_certificate_student(request): @role_required(['acadadmin']) def generate_fee_structure_pdf(request): student_id = request.data.get('student_id') + semester_raw = request.data.get('semester') if not student_id: return Response({'error': 'student_id is required.'}, status=status.HTTP_400_BAD_REQUEST) @@ -337,7 +363,19 @@ def generate_fee_structure_pdf(request): {'error': 'No fee structure is recorded for this programme.'}, status=status.HTTP_400_BAD_REQUEST) - odd_semester, _ = session_semesters(context['semester']) + default_odd_semester = ( + session_semesters(context['semester'])[0] if context['semester'] else 1) + try: + odd_semester = (int(semester_raw) if semester_raw not in (None, '') + else default_odd_semester) + except (TypeError, ValueError): + return Response({'error': 'Select a valid financial year.'}, + status=status.HTTP_400_BAD_REQUEST) + if odd_semester not in range(1, structure.semester_count + 1, 2): + return Response( + {'error': 'Financial year is outside the published fee structure.'}, + status=status.HTTP_400_BAD_REQUEST) + rows = build_structure_rows(student, structure, odd_semester) _, academic_year_label = financial_year_for(student, odd_semester) diff --git a/FusionIIIT/applications/academic_procedures/test_fee_structure_certificate.py b/FusionIIIT/applications/academic_procedures/test_fee_structure_certificate.py index 661f168e6..4e703497f 100644 --- a/FusionIIIT/applications/academic_procedures/test_fee_structure_certificate.py +++ b/FusionIIIT/applications/academic_procedures/test_fee_structure_certificate.py @@ -103,6 +103,52 @@ def test_concession_students_get_the_concession_figures(self): self.assertEqual(tuition_row['sem1'], '0.00') +class FinancialYearSelectionTests(FeeStructureCertificateTestCase): + def test_financial_year_choices_cover_every_session(self): + choices = fsc.financial_year_choices(self.student, self.structure) + self.assertEqual([c['value'] for c in choices], [1, 3, 5, 7]) + self.assertEqual(choices[0]['label'], '2026-27') + self.assertEqual(choices[3]['label'], '2029-30') + + def test_student_endpoint_defaults_to_the_current_session(self): + student = self._student('26BCS3001', semester=7) + response = self.client.get( + '/academic-procedures/api/acad/fee-structure-certificate/student/', + {'roll_number': student.pk}) + data = response.json() + self.assertEqual(data['selected_semester'], 7) + self.assertEqual(data['structure']['academic_year_label'], '2029-30') + + def test_student_endpoint_honours_an_explicit_semester(self): + response = self.client.get( + '/academic-procedures/api/acad/fee-structure-certificate/student/', + {'roll_number': self.student.pk, 'semester': 5}) + data = response.json() + self.assertEqual(data['selected_semester'], 5) + self.assertEqual(data['structure']['academic_year_label'], '2028-29') + + def test_student_endpoint_falls_back_on_an_invalid_semester(self): + response = self.client.get( + '/academic-procedures/api/acad/fee-structure-certificate/student/', + {'roll_number': self.student.pk, 'semester': 4}) + self.assertEqual(response.json()['selected_semester'], 1) + + def test_pdf_generation_honours_an_explicit_semester(self): + response = self.client.post( + '/academic-procedures/api/acad/fee-structure-certificate/pdf/', + {'student_id': self.student.pk, 'semester': 5}, format='json') + self.assertEqual(response.status_code, 200) + certificate = BonafideCertificate.objects.get( + certificate_type='fee_structure') + self.assertEqual(certificate.purpose, 'Fee Structure 2028-29') + + def test_pdf_generation_rejects_a_semester_outside_the_structure(self): + response = self.client.post( + '/academic-procedures/api/acad/fee-structure-certificate/pdf/', + {'student_id': self.student.pk, 'semester': 9}, format='json') + self.assertEqual(response.status_code, 400) + + class IssuedCertificateTests(FeeStructureCertificateTestCase): def _generate(self): return self.client.post(