diff --git a/FusionIIIT/applications/academic_procedures/api/bonafide_certificate.py b/FusionIIIT/applications/academic_procedures/api/bonafide_certificate.py
index 790588ec9..877a582a0 100644
--- a/FusionIIIT/applications/academic_procedures/api/bonafide_certificate.py
+++ b/FusionIIIT/applications/academic_procedures/api/bonafide_certificate.py
@@ -27,6 +27,9 @@
from applications.globals.decorators import role_required
from applications.globals.programme_scope import programme_display_name
+#: Both certificates share the table; each screen lists only its own kind.
+CERTIFICATE_TYPE = 'bonafide'
+
NUMBER_WORDS = {
1: 'One', 2: 'Two', 3: 'Three', 4: 'Four', 5: 'Five',
6: 'Six', 7: 'Seven', 8: 'Eight', 9: 'Nine', 10: 'Ten',
@@ -58,20 +61,28 @@ def superscript_ordinal(value):
return f'{match.group(1)}{escape(match.group(2))}'
-def _programme_duration(student):
+def programme_category(student):
+ """UG, PG or PHD — the unit fees and durations are actually set against."""
batch = student.batch_id
category = ''
if batch and batch.curriculum and batch.curriculum.programme:
category = (batch.curriculum.programme.category or '').upper()
+ if category:
+ return category
canonical = (student.programme or '').upper().replace('.', '')
- if not category:
- if canonical in {'BTECH', 'BDES'}:
- category = 'UG'
- elif canonical in {'MTECH', 'MDES'}:
- category = 'PG'
- elif canonical == 'PHD':
- category = 'PHD'
+ if canonical in {'BTECH', 'BDES'}:
+ return 'UG'
+ if canonical in {'MTECH', 'MDES'}:
+ return 'PG'
+ if canonical == 'PHD':
+ return 'PHD'
+ return ''
+
+
+def _programme_duration(student):
+ batch = student.batch_id
+ category = programme_category(student)
durations = {
'UG': settings.BONAFIDE_UG_DURATION_YEARS,
@@ -416,6 +427,7 @@ def generate_bonafide_pdf(request):
with transaction.atomic():
certificate = BonafideCertificate.objects.create(
student=student,
+ certificate_type=CERTIFICATE_TYPE,
purpose=purpose,
custom_purpose=custom_purpose if purpose == 'Other' else '',
issued_by=request.user,
@@ -459,7 +471,9 @@ def bonafide_certificates(request):
page_size = 20
page_size = min(max(page_size, 1), 100)
- queryset = BonafideCertificate.objects.select_related(
+ queryset = BonafideCertificate.objects.filter(
+ certificate_type=CERTIFICATE_TYPE,
+ ).select_related(
'student__id__user',
).order_by('-issued_at', '-pk')
queryset = _search_certificates(queryset, search).distinct()
@@ -493,7 +507,9 @@ def bonafide_certificates(request):
@role_required(['acadadmin'])
def bonafide_certificate_pdf(request, certificate_id):
certificate = get_object_or_404(
- BonafideCertificate.objects.select_related(
+ BonafideCertificate.objects.filter(
+ certificate_type=CERTIFICATE_TYPE,
+ ).select_related(
'student__id__user', 'student__id__department',
'student__batch_id__discipline',
'student__batch_id__curriculum__programme',
diff --git a/FusionIIIT/applications/academic_procedures/api/fee_certificate.py b/FusionIIIT/applications/academic_procedures/api/fee_certificate.py
new file mode 100644
index 000000000..76adeae68
--- /dev/null
+++ b/FusionIIIT/applications/academic_procedures/api/fee_certificate.py
@@ -0,0 +1,481 @@
+"""The Paid / Unpaid Fee Certificate.
+
+Built on the bonafide certificate: the same header, the same signatory, the
+same reference numbering and the same record table. What differs is one line
+of body text and a semester-wise fee table whose Paid / Unpaid column the
+academic office sets before generating.
+"""
+import re
+from decimal import Decimal
+from io import BytesIO
+
+from django.conf import settings
+from django.core.paginator import Paginator
+from django.db import transaction
+from django.db.models import Max
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404
+from django.utils import timezone
+from reportlab.lib import colors
+from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
+from reportlab.lib.pagesizes import A4
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.units import cm
+from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
+from rest_framework import status
+from rest_framework.authentication import TokenAuthentication
+from rest_framework.decorators import api_view, authentication_classes, permission_classes
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from xml.sax.saxutils import escape
+
+from applications.academic_procedures.models import BonafideCertificate, FeeStructure
+from applications.globals.decorators import role_required
+
+from .bonafide_certificate import (
+ _certificate_reference,
+ programme_category,
+ _search_certificates,
+ _student_queryset,
+ build_certificate_context,
+ ordinal,
+ superscript_ordinal,
+)
+
+CERTIFICATE_TYPE = 'fee'
+
+PURPOSE_CHOICES = (
+ ('Scholarship', 'Scholarship'),
+ ('Education Loan', 'Education Loan'),
+ ('Bihar Student Credit Card', 'Bihar Student Credit Card'),
+ ('Other', 'Other'),
+)
+
+PAID = 'Paid'
+UNPAID = 'Unpaid'
+
+#: Concession holders. EWS is not one: the waiver follows caste and disability.
+CONCESSION_CATEGORIES = {'SC', 'ST'}
+
+
+def _is_concession(student):
+ return (student.category or '').strip().upper() in CONCESSION_CATEGORIES \
+ or bool(student.is_pwd)
+
+
+def indian_currency(amount):
+ """1,05,250.00 — grouped in lakhs, which is what the certificate shows."""
+ quantised = Decimal(amount).quantize(Decimal('0.01'))
+ whole, _, fraction = f'{quantised:.2f}'.partition('.')
+ sign, whole = ('-', whole[1:]) if whole.startswith('-') else ('', whole)
+ if len(whole) > 3:
+ head, tail = whole[:-3], whole[-3:]
+ head = re.sub(r'(\d)(?=(\d\d)+$)', r'\1,', head)
+ whole = f'{head},{tail}'
+ return f'{sign}{whole}.{fraction}'
+
+
+def fee_structure_for(student):
+ """The structure for this student's category and session, or the newest
+ earlier one. A batch's year is the session its fees were published for."""
+ category = programme_category(student)
+ batch = student.batch_id
+ if not category or batch is None:
+ return None
+ rows = FeeStructure.objects.filter(programme_category__iexact=category,
+ is_active=True)
+ return (rows.filter(start_year__lte=batch.year).order_by('-start_year').first()
+ or rows.order_by('-start_year').first())
+
+
+def build_fee_rows(student, structure, current_semester, semesters=None):
+ """One row per semester, with the status the office would expect.
+
+ The first semester carries the one-time heads; every semester carries the
+ recurring ones plus mess, which the published grand total leaves out.
+
+ Semesters already behind the student default to Paid, the one they are in
+ is left blank so it has to be chosen, and the rest default to Unpaid.
+ """
+ column = 'concession' if _is_concession(student) else 'general'
+ total_semesters = semesters or structure.semester_count
+
+ rows = []
+ for index in range(1, total_semesters + 1):
+ # Quantised, so a stored row reads the same however the heads were typed.
+ amount = structure.semester_amount(column, index).quantize(Decimal('0.01'))
+ if index < current_semester:
+ payment_status = PAID
+ elif index == current_semester:
+ payment_status = ''
+ else:
+ payment_status = UNPAID
+ rows.append({
+ 'semester': index,
+ 'label': f'{ordinal(index)} Semester',
+ 'amount': str(amount),
+ 'amount_display': indian_currency(amount),
+ 'status': payment_status,
+ })
+ return rows
+
+
+def _clean_rows(submitted, expected):
+ """Validate what came back from the form against what we offered."""
+ by_semester = {}
+ for row in submitted or []:
+ try:
+ by_semester[int(row.get('semester'))] = (row.get('status') or '').strip()
+ except (TypeError, ValueError):
+ continue
+
+ cleaned, errors = [], []
+ for row in expected:
+ chosen = by_semester.get(row['semester'], '')
+ if chosen not in (PAID, UNPAID):
+ errors.append(f'Select Paid or Unpaid for the {row["label"]}.')
+ continue
+ cleaned.append({**row, 'status': chosen})
+ return cleaned, errors
+
+
+def _total(rows):
+ return sum((Decimal(row['amount']) for row in rows), Decimal('0'))
+
+
+def render_fee_pdf(context, purpose, reference_number, issued_on, rows,
+ notes=()):
+ output = BytesIO()
+ document = SimpleDocTemplate(
+ output,
+ pagesize=A4,
+ topMargin=2.5 * cm,
+ rightMargin=1.5 * cm,
+ bottomMargin=2.5 * cm,
+ leftMargin=1.5 * cm,
+ title='Fee Certificate',
+ author=settings.BONAFIDE_INSTITUTE_NAME,
+ )
+ header_style = ParagraphStyle(
+ 'Header', fontName='Helvetica-Bold', fontSize=13.5,
+ leading=18, alignment=TA_LEFT,
+ )
+ header_right_style = ParagraphStyle(
+ 'HeaderRight', parent=header_style, alignment=TA_RIGHT,
+ )
+ heading_style = ParagraphStyle(
+ 'Heading', fontName='Helvetica-Bold', fontSize=15,
+ leading=19, alignment=TA_CENTER,
+ )
+ body_style = ParagraphStyle(
+ 'Body', fontName='Helvetica', fontSize=13.5,
+ leading=19, alignment=TA_LEFT,
+ )
+ bullet_style = ParagraphStyle(
+ 'Bullet', parent=body_style, fontName='Helvetica-Bold',
+ leftIndent=0.6 * cm, bulletIndent=0.1 * cm,
+ )
+ note_style = ParagraphStyle(
+ 'Note', parent=body_style, fontName='Helvetica',
+ leftIndent=0.6 * cm, bulletIndent=0.1 * cm, leading=18,
+ )
+ cell_style = ParagraphStyle(
+ # Room for the raised ordinal suffix, which overflows a tighter line.
+ 'Cell', fontName='Helvetica', fontSize=12.5, leading=16,
+ alignment=TA_LEFT,
+ )
+ signature_style = ParagraphStyle(
+ 'Signature', fontName='Helvetica-Bold', fontSize=13.5,
+ leading=18, alignment=TA_LEFT,
+ )
+
+ header = Table(
+ [[
+ Paragraph(
+ f'{escape(settings.BONAFIDE_SIGNATORY_NAME)}
'
+ f'{escape(settings.BONAFIDE_SIGNATORY_TITLE)}',
+ header_style,
+ ),
+ Paragraph(
+ f'{escape(reference_number)}
'
+ f'Date: {issued_on.strftime("%d.%m.%Y")}',
+ header_right_style,
+ ),
+ ]],
+ colWidths=[9 * cm, 9 * cm],
+ )
+ header.setStyle(TableStyle([
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
+ ('LEFTPADDING', (0, 0), (-1, -1), 0),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 0),
+ ('TOPPADDING', (0, 0), (-1, -1), 0),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 0),
+ ]))
+
+ first_text = (
+ 'This is to certify that '
+ f'{escape(context["salutation"])} {escape(context["name"])} '
+ f'(Roll No. {escape(context["roll_number"])}) '
+ f'{escape(context["relation"])} '
+ f'MR. {escape(context["father_name"])} is a student of '
+ f'{superscript_ordinal(context["year_ordinal"])} Year '
+ f'({superscript_ordinal(context["semester_ordinal"])} Semester) '
+ f'{escape(context["programme"])} in '
+ f'{escape(context["discipline"])} '
+ f'({escape(context["duration_text"])} course duration: '
+ f'{context["start_year"]} to {context["end_year"]}) at '
+ f'{escape(settings.BONAFIDE_INSTITUTE_NAME)}.'
+ )
+ # The short form, as the certificate shows it: "B.Tech", not the
+ # expanded name the opening sentence uses.
+ # Continues the same paragraph; the circular has no break after Jabalpur.
+ first_text += (
+ f' The fee details of {escape(context["programme_short"])} '
+ f'Programme for the purpose of applying for {escape(purpose)} '
+ f'are given below:'
+ )
+
+ table_rows = [[
+ Paragraph(str(row['semester']), cell_style),
+ Paragraph(superscript_ordinal(ordinal(row['semester'])) + ' Semester',
+ cell_style),
+ Paragraph(escape(row['amount_display']), cell_style),
+ Paragraph(escape(row['status']), cell_style),
+ ] for row in rows]
+ table_rows.append([
+ Paragraph('Total', ParagraphStyle('TotalCell', parent=cell_style,
+ alignment=TA_CENTER)),
+ '',
+ Paragraph(escape(indian_currency(_total(rows))), cell_style),
+ '',
+ ])
+
+ fee_table = Table(table_rows,
+ colWidths=[1.1 * cm, 4.4 * cm, 3.4 * cm, 2.4 * cm],
+ hAlign='CENTER')
+ fee_table.setStyle(TableStyle([
+ ('GRID', (0, 0), (-1, -1), 0.75, colors.black),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ('LEFTPADDING', (0, 0), (-1, -1), 6),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 6),
+ ('TOPPADDING', (0, 0), (-1, -1), 2.5),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 2.5),
+ # The total spans the serial and semester columns, as on the original.
+ ('SPAN', (0, len(rows)), (1, len(rows))),
+ ]))
+
+ story = [
+ header,
+ Spacer(1, 1.65 * cm),
+ Paragraph('TO WHOM SO EVER IT MAY CONCERN', heading_style),
+ Spacer(1, 1.25 * cm),
+ Paragraph(first_text, body_style),
+ Spacer(1, 0.4 * cm),
+ Paragraph('Semester Wise Fee Details:', bullet_style, bulletText='•'),
+ Spacer(1, 0.2 * cm),
+ fee_table,
+ Spacer(1, 0.45 * cm),
+ ]
+ # Printed from the structure, so rewording one needs no deploy.
+ for note in notes or []:
+ story.append(Paragraph(escape(note), note_style, bulletText='•'))
+ story.extend([
+ Spacer(1, 1.6 * cm),
+ Paragraph(f'({escape(settings.BONAFIDE_SIGNATORY_NAME)})', signature_style),
+ ])
+
+ document.build(story)
+ output.seek(0)
+ return output
+
+
+def _effective_purpose(certificate):
+ if certificate.purpose == 'Other':
+ return certificate.custom_purpose
+ return certificate.purpose
+
+
+def _certificate_filename(certificate):
+ roll_number = certificate.student.id.user.username
+ safe_roll_number = re.sub(r'[^A-Za-z0-9_-]', '_', roll_number)
+ return f'{safe_roll_number}_Fee_Certificate_{certificate.pk:03d}.pdf'
+
+
+def _certificates_queryset():
+ return BonafideCertificate.objects.filter(
+ certificate_type=CERTIFICATE_TYPE).select_related(
+ 'student__id__user', 'issued_by')
+
+
+@api_view(['GET'])
+@authentication_classes([TokenAuthentication])
+@permission_classes([IsAuthenticated])
+@role_required(['acadadmin'])
+def fee_student(request):
+ roll_number = (request.query_params.get('roll_number') or '').strip().upper()
+ if not roll_number:
+ return Response({'error': 'roll_number is required.'},
+ status=status.HTTP_400_BAD_REQUEST)
+
+ student = get_object_or_404(
+ _student_queryset(), pk=roll_number,
+ id__user__is_active=True, id__user_status='PRESENT',
+ )
+ context = build_certificate_context(student)
+ context['programme_short'] = (student.programme or '').strip()
+ structure = fee_structure_for(student)
+ batch_label = programme_category(student) or 'unrecognised'
+ rows, errors = [], list(context['validation_errors'])
+ notes = []
+
+ if structure is None:
+ errors.append(
+ f'No fee structure is recorded for {batch_label} programmes. '
+ 'Add one before issuing this certificate.')
+ elif structure.semester_heads:
+ rows = build_fee_rows(student, structure, context['semester'])
+ notes = structure.notes
+
+ issued_on = timezone.now().date()
+ prefix = settings.BONAFIDE_REFERENCE_PREFIX.rstrip('/')
+ next_serial = (BonafideCertificate.objects.aggregate(
+ max_id=Max('pk'))['max_id'] or 0) + 1
+
+ return Response({
+ 'student': {**context, 'is_ready': not errors,
+ 'validation_errors': errors},
+ 'fee': {
+ 'rows': rows,
+ 'total_display': indian_currency(_total(rows)) if rows else '',
+ 'notes': list(notes),
+ 'is_concession': _is_concession(student),
+ },
+ 'purposes': [{'value': value, 'label': label}
+ for value, label in PURPOSE_CHOICES],
+ 'certificate': {
+ 'signatory_name': settings.BONAFIDE_SIGNATORY_NAME,
+ 'signatory_title': settings.BONAFIDE_SIGNATORY_TITLE,
+ 'institute_name': settings.BONAFIDE_INSTITUTE_NAME,
+ 'issued_on': issued_on.strftime('%d.%m.%Y'),
+ 'reference_preview': (
+ f'{prefix}/{issued_on.year}/{issued_on.month:02d}/'
+ f'{context["roll_number"]}/{next_serial:03d}'),
+ },
+ })
+
+
+@api_view(['POST'])
+@authentication_classes([TokenAuthentication])
+@permission_classes([IsAuthenticated])
+@role_required(['acadadmin'])
+def generate_fee_pdf(request):
+ student_id = request.data.get('student_id')
+ purpose = request.data.get('purpose')
+ custom_purpose = (request.data.get('custom_purpose') or '').strip()
+ valid_purposes = dict(PURPOSE_CHOICES)
+
+ if not student_id:
+ return Response({'error': 'student_id is required.'},
+ status=status.HTTP_400_BAD_REQUEST)
+ if purpose not in valid_purposes:
+ return Response({'error': 'Select a valid certificate purpose.'},
+ status=status.HTTP_400_BAD_REQUEST)
+ if purpose == 'Other' and not custom_purpose:
+ return Response({'error': 'Enter the certificate purpose.'},
+ status=status.HTTP_400_BAD_REQUEST)
+ if len(custom_purpose) > 150:
+ return Response(
+ {'error': 'The certificate purpose cannot exceed 150 characters.'},
+ status=status.HTTP_400_BAD_REQUEST)
+
+ effective_purpose = custom_purpose if purpose == 'Other' else purpose
+ student = get_object_or_404(_student_queryset(), pk=student_id)
+ context = build_certificate_context(student)
+ context['programme_short'] = (student.programme or '').strip()
+ if not context['is_ready']:
+ return Response(
+ {'error': 'Student data is incomplete.',
+ 'details': context['validation_errors']},
+ status=status.HTTP_400_BAD_REQUEST)
+
+ structure = fee_structure_for(student)
+ if structure is None:
+ return Response(
+ {'error': 'No fee structure is recorded for this programme.'},
+ status=status.HTTP_400_BAD_REQUEST)
+
+ expected = build_fee_rows(student, structure, context['semester'])
+ rows, errors = _clean_rows(request.data.get('rows'), expected)
+ if errors:
+ return Response({'error': 'Every semester needs a Paid or Unpaid mark.',
+ 'details': errors},
+ status=status.HTTP_400_BAD_REQUEST)
+
+ issued_on = timezone.now().date()
+ prefix = settings.BONAFIDE_REFERENCE_PREFIX.rstrip('/')
+ with transaction.atomic():
+ certificate = BonafideCertificate.objects.create(
+ student=student,
+ certificate_type=CERTIFICATE_TYPE,
+ purpose=purpose,
+ custom_purpose=custom_purpose if purpose == 'Other' else '',
+ fee_rows=rows,
+ issued_by=request.user,
+ )
+ reference_number = (
+ f'{prefix}/{issued_on.year}/{issued_on.month:02d}/'
+ f'{context["roll_number"]}/{certificate.pk:03d}')
+ certificate.reference_number = reference_number
+ document = render_fee_pdf(
+ context, effective_purpose, reference_number, issued_on, rows,
+ notes=structure.notes)
+ document_bytes = document.getvalue()
+ certificate.pdf_content = document_bytes
+ certificate.save(update_fields=['reference_number', 'pdf_content'])
+
+ response = HttpResponse(document_bytes, content_type='application/pdf')
+ response['Content-Disposition'] = (
+ f'attachment; filename="{_certificate_filename(certificate)}"')
+ response['X-Certificate-Reference'] = reference_number
+ return response
+
+
+@api_view(['GET'])
+@authentication_classes([TokenAuthentication])
+@permission_classes([IsAuthenticated])
+@role_required(['acadadmin'])
+def fee_certificates(request):
+ queryset = _search_certificates(
+ _certificates_queryset(), (request.query_params.get('search') or '').strip())
+ page = Paginator(queryset, 10).get_page(request.query_params.get('page') or 1)
+ return Response({
+ 'count': page.paginator.count,
+ 'page': page.number,
+ 'num_pages': page.paginator.num_pages,
+ 'results': [{
+ 'id': certificate.pk,
+ 'reference_number': _certificate_reference(certificate),
+ 'roll_number': certificate.student.id.user.username,
+ 'name': certificate.student.id.user.get_full_name().strip(),
+ 'purpose': _effective_purpose(certificate),
+ 'issued_on': certificate.issued_at.strftime('%d.%m.%Y'),
+ 'issued_by': certificate.issued_by.get_full_name().strip(),
+ } for certificate in page],
+ })
+
+
+@api_view(['GET'])
+@authentication_classes([TokenAuthentication])
+@permission_classes([IsAuthenticated])
+@role_required(['acadadmin'])
+def fee_certificate_pdf(request, certificate_id):
+ certificate = get_object_or_404(_certificates_queryset(), pk=certificate_id)
+ if not certificate.pdf_content:
+ return Response(
+ {'error': 'The stored certificate is unavailable. Generate it again.'},
+ status=status.HTTP_404_NOT_FOUND)
+ response = HttpResponse(bytes(certificate.pdf_content),
+ content_type='application/pdf')
+ response['Content-Disposition'] = (
+ f'attachment; filename="{_certificate_filename(certificate)}"')
+ return response
diff --git a/FusionIIIT/applications/academic_procedures/api/fee_structure.py b/FusionIIIT/applications/academic_procedures/api/fee_structure.py
new file mode 100644
index 000000000..8c1126440
--- /dev/null
+++ b/FusionIIIT/applications/academic_procedures/api/fee_structure.py
@@ -0,0 +1,265 @@
+"""Maintaining the published fee structure.
+
+Everything the circular contains is data here — the heads, their amounts, the
+column headings, the number of semesters and the footnotes — so a fee revision
+is a screen edit rather than a deploy.
+"""
+from decimal import Decimal, InvalidOperation
+
+from django.db import transaction
+from django.shortcuts import get_object_or_404
+from rest_framework import status
+from rest_framework.authentication import TokenAuthentication
+from rest_framework.decorators import api_view, authentication_classes, permission_classes
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+
+from applications.academic_procedures.models import FeeStructure
+from applications.globals.decorators import role_required
+
+SECTIONS = ('one_time_heads', 'semester_heads', 'tuition_hostel_heads')
+
+#: What the screen shows in the Name column; the long form is the tab label.
+SHORT_NAMES = {'UG': 'UG', 'PG': 'PG', 'PHD': 'Ph.D.'}
+
+#: Only the starting point for a new session — every one of these is editable,
+#: and the office may add or remove rows.
+DEFAULT_HEADS = {
+ 'one_time_heads': [
+ 'Admission', 'Grade Card', 'Provisional Certificate',
+ 'Alumni Association Subscription', 'I Card', 'Career Development Fund',
+ "Student's Welfare", 'Caution Money (Refundable)',
+ ],
+ 'semester_heads': [
+ 'Gymkhana Fee', 'Examination Fee', 'Registration Fee',
+ 'Medical Insurance + PHC', 'Internet and computer Charge',
+ ],
+ 'tuition_hostel_heads': ['Tuition Fee', 'Hostel Fees'],
+}
+
+DEFAULT_SEMESTERS = {'UG': 8, 'PG': 4, 'PHD': 10}
+
+
+def _blank_heads(section):
+ return [{'head': head, 'general': '', 'concession': ''}
+ for head in DEFAULT_HEADS[section]]
+
+
+def _clean_amount(value):
+ """A head's amount: a number, a per-semester list, or NIL as printed."""
+ if isinstance(value, list):
+ return [_clean_amount(item) for item in value]
+ text = str(value if value is not None else '').strip()
+ if text.upper() in ('', 'NIL', '-'):
+ return text.upper() if text else ''
+ try:
+ return str(Decimal(text.replace(',', '')))
+ except InvalidOperation as exc:
+ raise ValueError(f'{value!r} is not an amount.') from exc
+
+
+def _clean_heads(rows, label):
+ cleaned = []
+ for row in rows or []:
+ head = (row.get('head') or '').strip()
+ if not head:
+ continue # a blank row is one they abandoned
+ cleaned.append({
+ 'head': head[:120],
+ 'general': _clean_amount(row.get('general')),
+ 'concession': _clean_amount(row.get('concession')),
+ })
+ if not cleaned:
+ raise ValueError(f'{label} needs at least one head.')
+ return cleaned
+
+
+def _serialise(structure):
+ sections = {section: getattr(structure, section) for section in SECTIONS}
+ totals = {
+ section: {
+ column: str(structure.section_total(section, column))
+ for column in ('general', 'concession')
+ }
+ for section in SECTIONS
+ }
+ grand_total = [
+ {
+ 'semester': semester,
+ # A+B+C as the circular prints it; mess is section D on its own.
+ 'general': str(structure.semester_amount('general', semester, False)),
+ 'concession': str(
+ structure.semester_amount('concession', semester, False)),
+ }
+ for semester in range(1, structure.semester_count + 1)
+ ]
+ return {
+ 'id': structure.pk,
+ 'programme_category': structure.programme_category,
+ 'name': SHORT_NAMES.get(structure.programme_category,
+ structure.programme_category),
+ 'long_name': structure.get_programme_category_display(),
+ 'start_year': structure.start_year,
+ 'academic_year': structure.academic_year,
+ 'semester_count': structure.semester_count,
+ 'general_label': structure.general_label,
+ 'concession_label': structure.concession_label,
+ 'mess_advance_per_semester': str(structure.mess_advance_per_semester),
+ 'notes': structure.notes,
+ 'is_active': structure.is_active,
+ **sections,
+ 'section_totals': totals,
+ # Computed here so the printed certificate and the screen cannot differ.
+ 'grand_total': grand_total,
+ 'updated_at': structure.updated_at.strftime('%d.%m.%Y'),
+ }
+
+
+def _apply(structure, data):
+ category = (data.get('programme_category') or '').strip().upper()
+ if category not in dict(FeeStructure.CATEGORY_CHOICES):
+ raise ValueError('Select UG, PG or PhD.')
+
+ try:
+ start_year = int(data.get('start_year'))
+ except (TypeError, ValueError) as exc:
+ raise ValueError('Enter the starting year of the session.') from exc
+ if not 1900 < start_year < 2200:
+ raise ValueError('Enter the starting year of the session.')
+
+ try:
+ semester_count = int(data.get('semester_count')
+ or DEFAULT_SEMESTERS.get(category, 8))
+ except (TypeError, ValueError) as exc:
+ raise ValueError('Enter how many semesters the programme runs.') from exc
+ if not 1 <= semester_count <= 20:
+ raise ValueError('A programme runs between 1 and 20 semesters.')
+
+ structure.programme_category = category
+ structure.start_year = start_year
+ structure.semester_count = semester_count
+ structure.general_label = (data.get('general_label')
+ or 'Gen/OBC/EWS').strip()[:40]
+ structure.concession_label = (data.get('concession_label')
+ or 'PWD/SC/ST').strip()[:40]
+ structure.mess_advance_per_semester = _clean_amount(
+ data.get('mess_advance_per_semester') or 0) or 0
+ structure.notes = [str(note).strip() for note in (data.get('notes') or [])
+ if str(note).strip()]
+ structure.is_active = bool(data.get('is_active', True))
+
+ labels = {'one_time_heads': 'One-time payment',
+ 'semester_heads': 'Semester fees (academic)',
+ 'tuition_hostel_heads': 'Semester fees (tuition and hostel)'}
+ for section in SECTIONS:
+ setattr(structure, section, _clean_heads(data.get(section), labels[section]))
+ return structure
+
+
+def _duplicate(category, start_year, exclude_pk=None):
+ rows = FeeStructure.objects.filter(programme_category=category,
+ start_year=start_year)
+ if exclude_pk:
+ rows = rows.exclude(pk=exclude_pk)
+ return rows.exists()
+
+
+@api_view(['GET', 'POST'])
+@authentication_classes([TokenAuthentication])
+@permission_classes([IsAuthenticated])
+@role_required(['acadadmin'])
+def fee_structures(request):
+ if request.method == 'GET':
+ return Response({'results': [_serialise(row) for row
+ in FeeStructure.objects.all()]})
+
+ structure = FeeStructure()
+ try:
+ _apply(structure, request.data)
+ except ValueError as exc:
+ return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
+ if _duplicate(structure.programme_category, structure.start_year):
+ return Response(
+ {'error': f'{structure.programme_category} already has a structure '
+ f'for {structure.academic_year}.'},
+ status=status.HTTP_400_BAD_REQUEST)
+ structure.save()
+ return Response(_serialise(structure), status=status.HTTP_201_CREATED)
+
+
+@api_view(['GET', 'PUT', 'DELETE'])
+@authentication_classes([TokenAuthentication])
+@permission_classes([IsAuthenticated])
+@role_required(['acadadmin'])
+def fee_structure_detail(request, structure_id):
+ structure = get_object_or_404(FeeStructure, pk=structure_id)
+
+ if request.method == 'GET':
+ return Response(_serialise(structure))
+
+ if request.method == 'DELETE':
+ structure.delete()
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+ try:
+ _apply(structure, request.data)
+ except ValueError as exc:
+ return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
+ if _duplicate(structure.programme_category, structure.start_year,
+ exclude_pk=structure.pk):
+ return Response(
+ {'error': f'{structure.programme_category} already has a structure '
+ f'for {structure.academic_year}.'},
+ status=status.HTTP_400_BAD_REQUEST)
+ structure.save()
+ return Response(_serialise(structure))
+
+
+@api_view(['POST'])
+@authentication_classes([TokenAuthentication])
+@permission_classes([IsAuthenticated])
+@role_required(['acadadmin'])
+def replicate_fee_structure(request, structure_id):
+ """Copy a session's structure into a new one, so only changes are typed."""
+ source = get_object_or_404(FeeStructure, pk=structure_id)
+ try:
+ start_year = int(request.data.get('start_year'))
+ except (TypeError, ValueError):
+ return Response({'error': 'Enter the starting year of the new session.'},
+ status=status.HTTP_400_BAD_REQUEST)
+
+ if _duplicate(source.programme_category, start_year):
+ return Response(
+ {'error': f'{source.programme_category} already has a structure for '
+ f'{start_year}-{str(start_year + 1)[-2:]}.'},
+ status=status.HTTP_400_BAD_REQUEST)
+
+ with transaction.atomic():
+ copy = FeeStructure.objects.get(pk=source.pk)
+ copy.pk = None
+ copy.start_year = start_year
+ copy.save()
+ return Response(_serialise(copy), status=status.HTTP_201_CREATED)
+
+
+@api_view(['GET'])
+@authentication_classes([TokenAuthentication])
+@permission_classes([IsAuthenticated])
+@role_required(['acadadmin'])
+def fee_structure_template(request):
+ """A blank structure with the usual heads already listed."""
+ category = (request.query_params.get('programme_category') or 'UG').upper()
+ return Response({
+ 'programme_category': category,
+ 'semester_count': DEFAULT_SEMESTERS.get(category, 8),
+ 'general_label': 'Gen/OBC/EWS',
+ 'concession_label': 'PWD/SC/ST',
+ 'mess_advance_per_semester': '',
+ 'notes': [
+ 'The above fee structure includes Mess Advance Fee of Rs. ____/- '
+ '(per semester).',
+ '*Note: The above fee structure etc. may be changed any time by '
+ 'the Institute',
+ ],
+ **{section: _blank_heads(section) for section in SECTIONS},
+ })
diff --git a/FusionIIIT/applications/academic_procedures/api/urls.py b/FusionIIIT/applications/academic_procedures/api/urls.py
index e86373450..31df7b52a 100644
--- a/FusionIIIT/applications/academic_procedures/api/urls.py
+++ b/FusionIIIT/applications/academic_procedures/api/urls.py
@@ -1,6 +1,6 @@
from django.conf.urls import url
from . import views
-from . import bonafide_certificate
+from . import bonafide_certificate, fee_certificate, fee_structure
from .. import views as procedures_views
@@ -19,6 +19,24 @@
url(r'^acad/bonafide/certificates/(?P\d+)/pdf/$',
bonafide_certificate.bonafide_certificate_pdf,
name='bonafide-certificate-pdf'),
+ url(r'^acad/fee-certificate/student/$', fee_certificate.fee_student,
+ name='fee-certificate-student'),
+ url(r'^acad/fee-certificate/pdf/$', fee_certificate.generate_fee_pdf,
+ name='fee-certificate-pdf'),
+ url(r'^acad/fee-certificate/certificates/$',
+ fee_certificate.fee_certificates,
+ name='fee-certificates'),
+ url(r'^acad/fee-certificate/certificates/(?P\d+)/pdf/$',
+ fee_certificate.fee_certificate_pdf,
+ name='fee-certificate-pdf-detail'),
+ url(r'^acad/fee-structures/$', fee_structure.fee_structures,
+ name='fee-structures'),
+ url(r'^acad/fee-structures/template/$',
+ fee_structure.fee_structure_template, name='fee-structure-template'),
+ url(r'^acad/fee-structures/(?P\d+)/$',
+ fee_structure.fee_structure_detail, name='fee-structure-detail'),
+ url(r'^acad/fee-structures/(?P\d+)/replicate/$',
+ fee_structure.replicate_fee_structure, name='fee-structure-replicate'),
# url(r'^stu/details', views.academic_procedures_student, name='student_procedures'),
# url(r'^stu/pre_registration' , views.student_pre_registration , name = 'pre_registration'),
url(r'^stu/final_registration' , views.final_registration , name = 'final_registration'),
diff --git a/FusionIIIT/applications/academic_procedures/migrations/0057_fee_certificate.py b/FusionIIIT/applications/academic_procedures/migrations/0057_fee_certificate.py
new file mode 100644
index 000000000..ec8a93a98
--- /dev/null
+++ b/FusionIIIT/applications/academic_procedures/migrations/0057_fee_certificate.py
@@ -0,0 +1,45 @@
+# Generated by Django 3.1.5 on 2026-09-30 00:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('academic_procedures', '0056_thesiscommitteechangerequest'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='FeeStructure',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('programme_category', models.CharField(choices=[('UG', 'Undergraduate'), ('PG', 'Post Graduate'), ('PHD', 'Doctor of Philosophy')], db_index=True, max_length=3)),
+ ('start_year', models.PositiveSmallIntegerField(db_index=True)),
+ ('one_time_heads', models.JSONField(blank=True, default=list)),
+ ('semester_heads', models.JSONField(blank=True, default=list)),
+ ('tuition_hostel_heads', models.JSONField(blank=True, default=list)),
+ ('mess_advance_per_semester', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
+ ('is_active', models.BooleanField(default=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'db_table': 'FeeStructure',
+ 'ordering': ('programme_category', '-start_year'),
+ },
+ ),
+ migrations.AddField(
+ model_name='bonafidecertificate',
+ name='certificate_type',
+ field=models.CharField(choices=[('bonafide', 'Bonafide Certificate'), ('fee', 'Paid / Unpaid Fee Certificate')], db_index=True, default='bonafide', max_length=16),
+ ),
+ migrations.AddField(
+ model_name='bonafidecertificate',
+ name='fee_rows',
+ field=models.JSONField(blank=True, default=list),
+ ),
+ migrations.AddConstraint(
+ model_name='feestructure',
+ constraint=models.UniqueConstraint(fields=('programme_category', 'start_year'), name='fee_structure_one_per_session'),
+ ),
+ ]
diff --git a/FusionIIIT/applications/academic_procedures/migrations/0058_fee_structure_display.py b/FusionIIIT/applications/academic_procedures/migrations/0058_fee_structure_display.py
new file mode 100644
index 000000000..73bf881c0
--- /dev/null
+++ b/FusionIIIT/applications/academic_procedures/migrations/0058_fee_structure_display.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.1.5 on 2026-09-30 00:23
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('academic_procedures', '0057_fee_certificate'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='feestructure',
+ name='concession_label',
+ field=models.CharField(default='PWD/SC/ST', max_length=40),
+ ),
+ migrations.AddField(
+ model_name='feestructure',
+ name='general_label',
+ field=models.CharField(default='Gen/OBC/EWS', max_length=40),
+ ),
+ migrations.AddField(
+ model_name='feestructure',
+ name='notes',
+ field=models.JSONField(blank=True, default=list),
+ ),
+ migrations.AddField(
+ model_name='feestructure',
+ name='semester_count',
+ field=models.PositiveSmallIntegerField(default=8),
+ ),
+ ]
diff --git a/FusionIIIT/applications/academic_procedures/models.py b/FusionIIIT/applications/academic_procedures/models.py
index 65ebe2d25..000948930 100644
--- a/FusionIIIT/applications/academic_procedures/models.py
+++ b/FusionIIIT/applications/academic_procedures/models.py
@@ -382,10 +382,113 @@ class BonafideCertificate(models.Model):
related_name='issued_bonafide_certificates')
issued_at = models.DateTimeField(auto_now_add=True)
+ TYPE_CHOICES = (
+ ('bonafide', 'Bonafide Certificate'),
+ ('fee', 'Paid / Unpaid Fee Certificate'),
+ )
+
+ certificate_type = models.CharField(
+ max_length=16, choices=TYPE_CHOICES, default='bonafide', db_index=True)
+ #: The semester rows exactly as printed, so a reissue matches the original.
+ fee_rows = models.JSONField(default=list, blank=True)
+
class Meta:
db_table = 'BonafideCertificate'
ordering = ('-issued_at',)
+
+class FeeStructure(models.Model):
+ """The published fee structure for a category, for one academic session.
+
+ Stored as the heads the institute publishes rather than as per-semester
+ totals, because the totals are derived from them: the first semester adds
+ the one-time heads, every semester carries the academic and tuition heads,
+ and mess is added on top. Keeping the heads means the certificate can show
+ a figure the office can reconcile against its own circular.
+ """
+
+ CATEGORY_CHOICES = (('UG', 'Undergraduate'), ('PG', 'Post Graduate'),
+ ('PHD', 'Doctor of Philosophy'))
+
+ #: Fees are published per category, not per programme: one table covers
+ #: B.Tech and B.Des, another every M.Tech specialisation.
+ programme_category = models.CharField(
+ max_length=3, choices=CATEGORY_CHOICES, db_index=True)
+ #: 2026 means the 2026-27 session, and matches the batch's own year.
+ start_year = models.PositiveSmallIntegerField(db_index=True)
+
+ #: Each head is {"head": str, "general": str, "concession": str}.
+ one_time_heads = models.JSONField(default=list, blank=True)
+ semester_heads = models.JSONField(default=list, blank=True)
+ tuition_hostel_heads = models.JSONField(default=list, blank=True)
+ #: Never part of the grand total; added to every semester on the certificate.
+ mess_advance_per_semester = models.DecimalField(
+ max_digits=10, decimal_places=2, default=0)
+
+ #: Data, not a constant, so a change of programme length needs no deploy.
+ semester_count = models.PositiveSmallIntegerField(default=8)
+ #: The two column headings the circular prints.
+ general_label = models.CharField(max_length=40, default='Gen/OBC/EWS')
+ concession_label = models.CharField(max_length=40, default='PWD/SC/ST')
+ #: Footnotes printed under the table, in order.
+ notes = models.JSONField(default=list, blank=True)
+
+ is_active = models.BooleanField(default=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ db_table = 'FeeStructure'
+ ordering = ('programme_category', '-start_year')
+ constraints = [
+ models.UniqueConstraint(fields=['programme_category', 'start_year'],
+ name='fee_structure_one_per_session'),
+ ]
+
+ @property
+ def academic_year(self):
+ return f'{self.start_year}-{str(self.start_year + 1)[-2:]}'
+
+ @staticmethod
+ def _amount(head, column, semester):
+ """A head's value for one semester.
+
+ A list is per semester, because PG tuition rises after the second;
+ anything shorter than the programme holds its last value. NIL is what
+ the circular prints where a category pays nothing.
+ """
+ raw = head.get(column, head.get('general', ''))
+ if isinstance(raw, list):
+ raw = raw[min(semester - 1, len(raw) - 1)] if raw else ''
+ raw = str(raw or '').strip()
+ if raw.upper() in ('', 'NIL', '-'):
+ return Decimal('0')
+ return Decimal(raw)
+
+ def _sum(self, heads, column, semester):
+ return sum((self._amount(head, column, semester) for head in heads or []),
+ Decimal('0'))
+
+ def semester_amount(self, column, semester, with_mess=True):
+ """What this semester costs: A only in the first, then B and C.
+
+ The circular's Grand Total stops there; the certificate adds mess, so
+ the screen and the certificate ask for different answers.
+ """
+ amount = (self._sum(self.semester_heads, column, semester)
+ + self._sum(self.tuition_hostel_heads, column, semester))
+ if semester == 1:
+ amount += self._sum(self.one_time_heads, column, semester)
+ if with_mess:
+ amount += Decimal(self.mess_advance_per_semester or 0)
+ return amount
+
+ def section_total(self, section, column, semester=1):
+ """One section's total, as the published table shows it."""
+ return self._sum(getattr(self, section) or [], column, semester)
+
+ def __str__(self):
+ return f'{self.programme_category} {self.academic_year}'
+
class AssistantshipClaim(models.Model):
Month_Choices = [
('Jan', 'January'),
diff --git a/FusionIIIT/applications/academic_procedures/test_fee_certificate.py b/FusionIIIT/applications/academic_procedures/test_fee_certificate.py
new file mode 100644
index 000000000..27707d345
--- /dev/null
+++ b/FusionIIIT/applications/academic_procedures/test_fee_certificate.py
@@ -0,0 +1,305 @@
+from decimal import Decimal
+
+from django.contrib.auth.models import User
+from django.test import TestCase
+from rest_framework.authtoken.models import Token
+from rest_framework.test import APIClient
+
+from applications.academic_information.models import Student
+from applications.academic_procedures.api import fee_certificate as fc
+from applications.academic_procedures.models import BonafideCertificate, FeeStructure
+from applications.globals.models import Designation, ExtraInfo, HoldsDesignation
+from applications.programme_curriculum.models import Batch, Curriculum, Discipline, Programme
+
+ACADEMIC = [
+ {'head': 'Gymkhana Fee', 'general': 3000, 'concession': 3000},
+ {'head': 'Examination Fee', 'general': 1000, 'concession': 1000},
+ {'head': 'Registration Fee', 'general': 1000, 'concession': 1000},
+ {'head': 'Medical Insurance + PHC', 'general': 1500, 'concession': 1500},
+ {'head': 'Internet and computer Charge', 'general': 1000, 'concession': 1000},
+]
+
+
+def one_time(*amounts):
+ return [{'head': f'Head {index}', 'general': amount, 'concession': amount}
+ for index, amount in enumerate(amounts, start=1)]
+
+
+class FeeCertificateTestCase(TestCase):
+ """A UG batch of 2026, with the published 2026-27 structure behind it."""
+
+ def setUp(self):
+ self.programme = Programme.objects.create(
+ category='UG', name='Bachelor of Technology',
+ programme_begin_year=2026)
+ self.discipline = Discipline.objects.create(
+ name='Computer Science and Engineering', acronym='CSE')
+ self.curriculum = Curriculum.objects.create(
+ programme=self.programme, name='B.Tech CSE 2026', no_of_semester=8)
+ self.batch = Batch.objects.create(
+ name='B.Tech', discipline=self.discipline, year=2026,
+ curriculum=self.curriculum)
+
+ self.structure = FeeStructure.objects.create(
+ programme_category='UG', start_year=2026, semester_count=8,
+ one_time_heads=one_time(2000, 2000, 500, 1500, 500, 2000, 2000, 5000),
+ semester_heads=ACADEMIC,
+ tuition_hostel_heads=[
+ {'head': 'Tuition Fee', 'general': 71750, 'concession': 'NIL'},
+ {'head': 'Hostel Fees', 'general': 11000, 'concession': 11000},
+ ],
+ mess_advance_per_semester=Decimal(15000),
+ notes=['*Note: fees may change.'],
+ )
+
+ self.student = self._student('26BCS1234', category='GEN', semester=3)
+
+ self.admin = User.objects.create_user(username='fee-admin')
+ designation = Designation.objects.create(name='acadadmin')
+ HoldsDesignation.objects.create(
+ user=self.admin, working=self.admin, designation=designation)
+ self.client = APIClient()
+ self.client.credentials(
+ HTTP_AUTHORIZATION=f'Token {Token.objects.create(user=self.admin).key}')
+
+ def _student(self, roll, category='GEN', is_pwd=False, semester=3, batch=None):
+ user = User.objects.create_user(username=roll, first_name='A',
+ last_name='Student')
+ extra = ExtraInfo.objects.create(id=roll, user=user, user_type='student',
+ sex='F', user_status='PRESENT')
+ return Student.objects.create(
+ id=extra, programme='B.Tech', batch=(batch or self.batch).year,
+ batch_id=batch or self.batch, category=category, is_pwd=is_pwd,
+ father_name='Mr. A Parent', curr_semester_no=semester)
+
+
+class PublishedFiguresTests(FeeCertificateTestCase):
+ """The certificate must reproduce the circular, or it is worthless."""
+
+ def test_the_general_figures_match_the_published_table(self):
+ rows = fc.build_fee_rows(self.student, self.structure, 1)
+ # Grand total plus mess, which the published table leaves out.
+ self.assertEqual([row['amount'] for row in rows[:2]],
+ ['120750.00', '105250.00'])
+ self.assertEqual(fc._total(rows), Decimal('857500.00'))
+
+ def test_sc_st_and_pwd_pay_the_concession_figures(self):
+ for roll, kwargs in (('26BCS2001', {'category': 'SC'}),
+ ('26BCS2002', {'category': 'ST'}),
+ ('26BCS2003', {'category': 'OBC', 'is_pwd': True})):
+ student = self._student(roll, **kwargs)
+ rows = fc.build_fee_rows(student, self.structure, 1)
+ self.assertEqual([row['amount'] for row in rows[:2]],
+ ['49000.00', '33500.00'], roll)
+ self.assertEqual(fc._total(rows), Decimal('283500.00'), roll)
+
+ def test_only_the_first_semester_carries_the_one_time_heads(self):
+ rows = fc.build_fee_rows(self.student, self.structure, 1)
+ difference = Decimal(rows[0]['amount']) - Decimal(rows[1]['amount'])
+ self.assertEqual(difference, Decimal('15500'))
+
+ def test_a_head_that_varies_by_semester_is_read_per_semester(self):
+ """PG tuition rises after the second semester."""
+ self.structure.tuition_hostel_heads = [
+ {'head': 'Tuition Fee',
+ 'general': [50000, 50000, 60000, 60000], 'concession': 'NIL'},
+ {'head': 'Hostel Fees', 'general': 11000, 'concession': 11000},
+ ]
+ self.structure.semester_count = 4
+ self.structure.mess_advance_per_semester = Decimal(20000)
+ self.structure.one_time_heads = one_time(2000, 1000, 500, 1500, 500,
+ 1000, 1000, 2500)
+ self.structure.save()
+
+ rows = fc.build_fee_rows(self.student, self.structure, 1)
+ published = [int(Decimal(row['amount']) - Decimal(20000)) for row in rows]
+ self.assertEqual(published, [78500, 68500, 78500, 78500])
+
+ def test_nil_is_read_as_nothing_rather_than_refused(self):
+ student = self._student('26BCS2100', category='SC')
+ rows = fc.build_fee_rows(student, self.structure, 1)
+ self.assertEqual(rows[1]['amount'], '33500.00')
+
+
+class PaidUnpaidTests(FeeCertificateTestCase):
+ def test_earlier_semesters_default_to_paid_and_later_to_unpaid(self):
+ rows = fc.build_fee_rows(self.student, self.structure, 3)
+ self.assertEqual([row['status'] for row in rows],
+ ['Paid', 'Paid', '', 'Unpaid', 'Unpaid', 'Unpaid',
+ 'Unpaid', 'Unpaid'])
+
+ def test_the_current_semester_has_no_default_and_must_be_chosen(self):
+ """Defaulting it would silently assert a payment nobody confirmed."""
+ rows = fc.build_fee_rows(self.student, self.structure, 3)
+ _, errors = fc._clean_rows(
+ [{'semester': row['semester'], 'status': row['status']}
+ for row in rows], rows)
+ self.assertEqual(len(errors), 1)
+ self.assertIn('3rd Semester', errors[0])
+
+ def test_every_semester_marked_is_accepted(self):
+ rows = fc.build_fee_rows(self.student, self.structure, 3)
+ submitted = [{'semester': row['semester'], 'status': row['status'] or 'Paid'}
+ for row in rows]
+ cleaned, errors = fc._clean_rows(submitted, rows)
+ self.assertEqual(errors, [])
+ self.assertEqual(len(cleaned), 8)
+
+ def test_a_status_that_is_neither_paid_nor_unpaid_is_refused(self):
+ rows = fc.build_fee_rows(self.student, self.structure, 3)
+ submitted = [{'semester': row['semester'], 'status': 'Maybe'}
+ for row in rows]
+ _, errors = fc._clean_rows(submitted, rows)
+ self.assertEqual(len(errors), 8)
+
+
+class StructureBindingTests(FeeCertificateTestCase):
+ """A batch keeps the fees published for its own session."""
+
+ def test_a_later_session_does_not_change_an_earlier_batch(self):
+ FeeStructure.objects.create(
+ programme_category='UG', start_year=2027, semester_count=8,
+ one_time_heads=one_time(9999),
+ semester_heads=ACADEMIC,
+ tuition_hostel_heads=[
+ {'head': 'Tuition Fee', 'general': 99999, 'concession': 'NIL'}],
+ mess_advance_per_semester=Decimal(15000))
+
+ chosen = fc.fee_structure_for(self.student)
+ self.assertEqual(chosen.start_year, 2026)
+
+ def test_a_newer_batch_gets_the_newer_session(self):
+ newer = Batch.objects.create(name='B.Tech', discipline=self.discipline,
+ year=2027, curriculum=self.curriculum)
+ FeeStructure.objects.create(
+ programme_category='UG', start_year=2027, semester_count=8,
+ one_time_heads=one_time(1000), semester_heads=ACADEMIC,
+ tuition_hostel_heads=[
+ {'head': 'Tuition Fee', 'general': 80000, 'concession': 'NIL'}],
+ mess_advance_per_semester=Decimal(15000))
+
+ student = self._student('27BCS0001', batch=newer)
+ self.assertEqual(fc.fee_structure_for(student).start_year, 2027)
+
+ def test_a_batch_before_any_published_session_falls_back(self):
+ older = Batch.objects.create(name='B.Tech', discipline=self.discipline,
+ year=2020, curriculum=self.curriculum)
+ student = self._student('20BCS0001', batch=older)
+ self.assertEqual(fc.fee_structure_for(student).start_year, 2026)
+
+ def test_every_specialisation_shares_its_category(self):
+ """One PG structure covers M.Tech AI & ML and the rest."""
+ pg_programme = Programme.objects.create(
+ category='PG', name='Master of Technology', programme_begin_year=2026)
+ pg_curriculum = Curriculum.objects.create(
+ programme=pg_programme, name='M.Tech AI 2026', no_of_semester=4)
+ pg_batch = Batch.objects.create(name='M.Tech AI & ML',
+ discipline=self.discipline, year=2026,
+ curriculum=pg_curriculum)
+ pg_structure = FeeStructure.objects.create(
+ programme_category='PG', start_year=2026, semester_count=4,
+ one_time_heads=one_time(10000), semester_heads=ACADEMIC,
+ tuition_hostel_heads=[
+ {'head': 'Tuition Fee', 'general': 50000, 'concession': 'NIL'}],
+ mess_advance_per_semester=Decimal(20000))
+
+ student = self._student('26MCS0001', batch=pg_batch)
+ self.assertEqual(fc.fee_structure_for(student), pg_structure)
+
+
+class IssuedCertificateTests(FeeCertificateTestCase):
+ def _generate(self, purpose='Education Loan', **extra):
+ rows = fc.build_fee_rows(self.student, self.structure, 3)
+ return self.client.post('/academic-procedures/api/acad/fee-certificate/pdf/',
+ {'student_id': self.student.pk,
+ 'purpose': purpose,
+ 'rows': [{'semester': row['semester'],
+ 'status': row['status'] or 'Paid'}
+ for row in rows],
+ **extra},
+ format='json')
+
+ def test_a_certificate_is_issued_as_a_pdf(self):
+ response = self._generate()
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response['Content-Type'], 'application/pdf')
+ self.assertTrue(response['X-Certificate-Reference'])
+
+ def test_an_issued_certificate_keeps_the_rows_it_printed(self):
+ """A later fee revision must not rewrite a document already handed over."""
+ self._generate()
+ certificate = BonafideCertificate.objects.get(certificate_type='fee')
+ printed = [row['amount'] for row in certificate.fee_rows]
+
+ self.structure.tuition_hostel_heads = [
+ {'head': 'Tuition Fee', 'general': 999999, 'concession': 'NIL'}]
+ self.structure.save()
+
+ certificate.refresh_from_db()
+ self.assertEqual([row['amount'] for row in certificate.fee_rows], printed)
+ self.assertTrue(certificate.pdf_content)
+
+ def test_a_certificate_is_recorded_against_the_student(self):
+ self._generate()
+ certificate = BonafideCertificate.objects.get(certificate_type='fee')
+ self.assertEqual(certificate.student_id, self.student.pk)
+ self.assertEqual(certificate.certificate_type, 'fee')
+ self.assertEqual(certificate.issued_by, self.admin)
+
+ def test_an_unmarked_semester_is_refused(self):
+ response = self.client.post(
+ '/academic-procedures/api/acad/fee-certificate/pdf/',
+ {'student_id': self.student.pk, 'purpose': 'Scholarship',
+ 'rows': [{'semester': 1, 'status': 'Paid'}]},
+ format='json')
+ self.assertEqual(response.status_code, 400)
+
+ def test_other_needs_its_own_wording(self):
+ self.assertEqual(self._generate(purpose='Other').status_code, 400)
+ self.assertEqual(
+ self._generate(purpose='Other',
+ custom_purpose='Passport application').status_code, 200)
+
+ def test_a_purpose_outside_the_list_is_refused(self):
+ self.assertEqual(self._generate(purpose='Railway Pass').status_code, 400)
+
+ def test_a_student_without_a_structure_is_told_so(self):
+ FeeStructure.objects.all().delete()
+ response = self._generate()
+ self.assertEqual(response.status_code, 400)
+ self.assertIn('fee structure', response.json()['error'].lower())
+
+ def test_only_fee_certificates_are_listed(self):
+ self._generate()
+ BonafideCertificate.objects.create(
+ student=self.student, purpose='Scholarship', issued_by=self.admin)
+ listing = self.client.get(
+ '/academic-procedures/api/acad/fee-certificate/certificates/')
+ self.assertEqual(listing.json()['count'], 1)
+
+ def test_only_bonafide_certificates_are_listed_on_the_other_screen(self):
+ self._generate()
+ BonafideCertificate.objects.create(
+ student=self.student, purpose='Scholarship', issued_by=self.admin)
+ listing = self.client.get(
+ '/academic-procedures/api/acad/bonafide/certificates/')
+ self.assertEqual(listing.json()['count'], 1)
+ self.assertEqual(listing.json()['results'][0]['purpose'], 'Scholarship')
+ self.assertNotIn('fee',
+ [row['purpose'].lower()
+ for row in listing.json()['results']])
+
+ def test_a_fee_certificate_cannot_be_downloaded_as_a_bonafide_one(self):
+ self._generate()
+ fee = BonafideCertificate.objects.get(certificate_type='fee')
+ response = self.client.get(
+ f'/academic-procedures/api/acad/bonafide/certificates/{fee.pk}/pdf/')
+ self.assertEqual(response.status_code, 404)
+
+ def test_a_bonafide_certificate_cannot_be_downloaded_as_a_fee_one(self):
+ bonafide = BonafideCertificate.objects.create(
+ student=self.student, purpose='Scholarship', issued_by=self.admin)
+ response = self.client.get(
+ '/academic-procedures/api/acad/fee-certificate/certificates/'
+ f'{bonafide.pk}/pdf/')
+ self.assertEqual(response.status_code, 404)
diff --git a/FusionIIIT/applications/central_mess/migrations/0001_initial.py b/FusionIIIT/applications/central_mess/migrations/0001_initial.py
index 7e80bedf5..6543d1f0b 100644
--- a/FusionIIIT/applications/central_mess/migrations/0001_initial.py
+++ b/FusionIIIT/applications/central_mess/migrations/0001_initial.py
@@ -203,20 +203,6 @@ class Migration(migrations.Migration):
('student_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='academic_information.student')),
],
),
- migrations.CreateModel(
- name='Payments',
- fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('amount_paid', models.IntegerField(default=0)),
- ('payment_month', models.CharField(default=applications.central_mess.models.current_month, max_length=20)),
- ('payment_year', models.IntegerField(default=applications.central_mess.models.current_year)),
- ('payment_date', models.DateField(default=datetime.date(2024, 7, 16))),
- ('student_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='academic_information.student')),
- ],
- options={
- 'unique_together': {('student_id', 'payment_date')},
- },
- ),
migrations.CreateModel(
name='Monthly_bill',
fields=[
diff --git a/FusionIIIT/applications/globals/api/views.py b/FusionIIIT/applications/globals/api/views.py
index 73c40c3c2..160455d0c 100644
--- a/FusionIIIT/applications/globals/api/views.py
+++ b/FusionIIIT/applications/globals/api/views.py
@@ -37,6 +37,7 @@
from notifications.models import Notification
from notifications.signals import notify
from applications.globals.decorators import role_required
+from applications.globals import iam_bridge
from applications.globals.programme_scope import (
ALL_ACAD_ROLES,
STUDENT_CATEGORY_PATH,
@@ -72,7 +73,13 @@ def login(request):
'token' : data['auth_token'],
'designations': designation
}
- return Response(data=resp, status=status.HTTP_200_OK)
+ response = Response(data=resp, status=status.HTTP_200_OK)
+ # One login for every app. Best effort: the IAM being down must not block it.
+ return iam_bridge.attach_session(
+ response,
+ iam_bridge.mint_session(serializer.validated_data.get('username', ''),
+ serializer.validated_data.get('password', '')),
+ )
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@@ -82,7 +89,9 @@ def logout(request):
request.user.auth_token.delete()
except Exception:
pass # token already deleted or doesn't exist — still return success
- return Response({'message': 'User logged out successfully'}, status=status.HTTP_200_OK)
+ response = Response({'message': 'User logged out successfully'},
+ status=status.HTTP_200_OK)
+ return iam_bridge.clear_session(response)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@@ -113,6 +122,16 @@ def auth_view(request):
filtered_modules[field_name] = getattr(module_access, field_name)
accessible_modules[designation] = filtered_modules
+
+ # Granted by the IAM, kept under the designation that grants each one.
+ iam_token = request.COOKIES.get(iam_bridge.COOKIE_NAME)
+ external, plugged_nav = iam_bridge.plugged_modules(iam_token)
+ for designation, codes in external.items():
+ if not codes:
+ continue
+ modules = accessible_modules.setdefault(designation, {})
+ for code in codes:
+ modules[code] = True
# A first-login student (has an incomplete batch-upload record) must finish
# the profile-completion popup before using the app.
@@ -135,6 +154,8 @@ def auth_view(request):
'accessible_modules': accessible_modules,
'last_selected_role': last_selected_role,
'must_complete_profile': must_complete_profile,
+ # The menus plugged modules contribute, so the sidebar can expand them.
+ 'plugged_navigation': plugged_nav,
}
return Response(data=resp,status=status.HTTP_200_OK)
diff --git a/FusionIIIT/applications/globals/iam_bridge.py b/FusionIIIT/applications/globals/iam_bridge.py
new file mode 100644
index 000000000..fab98bc32
--- /dev/null
+++ b/FusionIIIT/applications/globals/iam_bridge.py
@@ -0,0 +1,88 @@
+"""Talk to Fusion_System_Administrator, so one login reaches every Fusion app.
+
+This portal keeps its own token and its own session; the bridge only ADDS an
+IAM session beside them, so a user who signs in here can walk into
+Fusion-Integrated without signing in again.
+
+Everything here fails soft. The IAM being unreachable must never stop somebody
+logging into this portal — they simply do not get the extra modules until it is
+back. Configuration is read from the environment so no settings file changes.
+"""
+import logging
+import os
+
+import requests
+
+log = logging.getLogger("fusion.iam_bridge")
+
+BASE_URL = os.environ.get("IAM_BASE_URL", "http://127.0.0.1:8001")
+API_PREFIX = os.environ.get("IAM_API_PREFIX", "/api")
+TIMEOUT = float(os.environ.get("IAM_TIMEOUT_SECONDS", "5"))
+
+#: Must match what Fusion-Integrated reads, or the walk-across silently fails.
+COOKIE_NAME = os.environ.get("IAM_AUTH_COOKIE_NAME", "auth_token")
+#: Blank in development; set to .iiitdmj.ac.in in production.
+COOKIE_DOMAIN = os.environ.get("IAM_AUTH_COOKIE_DOMAIN", "")
+COOKIE_MAX_AGE = int(os.environ.get("IAM_AUTH_COOKIE_MAX_AGE", str(12 * 60 * 60)))
+COOKIE_SECURE = os.environ.get("IAM_AUTH_COOKIE_SECURE", "0") == "1"
+
+
+def _url(path):
+ return f"{BASE_URL.rstrip('/')}{API_PREFIX.rstrip('/')}/{path.lstrip('/')}"
+
+
+def mint_session(username, password):
+ """A session token at the IAM for credentials this portal just accepted."""
+ try:
+ r = requests.post(_url("iam/v1/auth/login"),
+ json={"username": username, "password": password},
+ timeout=TIMEOUT)
+ if r.status_code >= 400:
+ log.warning("iam login returned %s for %s", r.status_code, username)
+ return None
+ return r.json().get("token")
+ except (requests.RequestException, ValueError):
+ log.exception("iam login failed")
+ return None
+
+
+def attach_session(response, token):
+ """Put the IAM session beside this portal's own credential."""
+ if not token:
+ return response
+ kwargs = {"max_age": COOKIE_MAX_AGE, "httponly": True,
+ "samesite": "Lax", "secure": COOKIE_SECURE, "path": "/"}
+ if COOKIE_DOMAIN:
+ kwargs["domain"] = COOKIE_DOMAIN
+ response.set_cookie(COOKIE_NAME, token, **kwargs)
+ return response
+
+
+def clear_session(response):
+ kwargs = {"path": "/"}
+ if COOKIE_DOMAIN:
+ kwargs["domain"] = COOKIE_DOMAIN
+ response.delete_cookie(COOKIE_NAME, **kwargs)
+ return response
+
+
+def _me(token):
+ """The IAM's session payload, or None if it cannot be reached."""
+ if not token:
+ return None
+ try:
+ r = requests.get(_url("iam/v1/me"),
+ headers={"Authorization": f"Token {token}"},
+ timeout=TIMEOUT)
+ return r.json() if r.status_code < 400 else None
+ except (requests.RequestException, ValueError):
+ log.exception("iam /me failed")
+ return None
+
+
+
+def plugged_modules(token):
+ """(modules by designation, navigation) from the other services, in one call."""
+ payload = _me(token) or {}
+ return (dict(payload.get("modules_by_role") or {}),
+ list(payload.get("navigation") or []))
diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0026_add_database_indexes.py b/FusionIIIT/applications/programme_curriculum/migrations/0026_add_database_indexes.py
index 2afda5843..6a6506941 100644
--- a/FusionIIIT/applications/programme_curriculum/migrations/0026_add_database_indexes.py
+++ b/FusionIIIT/applications/programme_curriculum/migrations/0026_add_database_indexes.py
@@ -9,6 +9,9 @@ class Migration(migrations.Migration):
dependencies = [
('programme_curriculum', '0025_update_minority_values'),
+ # The indexed table and every column named below belong to that app;
+ # without this the order is undefined and a fresh database fails here.
+ ('academic_procedures', '0010_populate_session_and_type'),
]
operations = [