diff --git a/FusionIIIT/applications/academic_procedures/api/demand_letter.py b/FusionIIIT/applications/academic_procedures/api/demand_letter.py
new file mode 100644
index 000000000..6bf4361d8
--- /dev/null
+++ b/FusionIIIT/applications/academic_procedures/api/demand_letter.py
@@ -0,0 +1,524 @@
+import re
+from datetime import datetime
+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_JUSTIFY, 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, DemandLetterBankAccounts
+from applications.globals.decorators import role_required
+
+from .bonafide_certificate import (
+ _search_certificates,
+ _student_queryset,
+ build_certificate_context,
+ ordinal,
+)
+from .fee_certificate import _is_concession, fee_structure_for, indian_currency
+
+CERTIFICATE_TYPE = 'demand'
+
+
+def _programme_short(student):
+ return (student.programme or '').strip().upper().replace('.', '-')
+
+
+def _discipline_with_acronym(student):
+ batch = student.batch_id
+ if batch and batch.discipline:
+ name = batch.discipline.name
+ acronym = batch.discipline.acronym
+ return f'{name} ({acronym})' if acronym else name
+ return ''
+
+
+def semester_choices(structure):
+ if not structure:
+ return []
+ return [{'value': i, 'label': f'{ordinal(i)} Semester'}
+ for i in range(1, structure.semester_count + 1)]
+
+
+def financial_year_for(student, semester):
+ batch = student.batch_id
+ if not batch or not semester:
+ return None, ''
+ start = batch.year + (semester - 1) // 2
+ return start, f'{start}-{str(start + 1)[-2:]}'
+
+
+def build_demand_row(student, structure, semester):
+ column = 'concession' if _is_concession(student) else 'general'
+ academic_fee = structure.semester_amount(
+ column, semester, with_mess=False).quantize(Decimal('0.01'))
+ mess_fee = Decimal(structure.mess_advance_per_semester or 0).quantize(Decimal('0.01'))
+ category_label = (structure.concession_label if _is_concession(student)
+ else structure.general_label)
+ return {
+ 'programme_short': _programme_short(student),
+ 'batch_year': student.batch_id.year if student.batch_id else None,
+ 'category_label': category_label,
+ 'academic_fee': str(academic_fee),
+ 'academic_fee_display': indian_currency(academic_fee),
+ 'mess_fee': str(mess_fee),
+ 'mess_fee_display': indian_currency(mess_fee),
+ 'total_display': indian_currency(academic_fee + mess_fee),
+ }
+
+
+BANK_FIELDS = ('name', 'number', 'ifsc', 'bank_branch', 'account_type')
+
+
+def _bank_account(account):
+ return {field: (account or {}).get(field, '') for field in BANK_FIELDS}
+
+
+def _bank_details():
+ accounts = DemandLetterBankAccounts.load()
+ return {
+ 'academic': _bank_account(accounts.academic_fee_account),
+ 'mess': _bank_account(accounts.mess_fee_account),
+ }
+
+
+def _bank_account_missing(account):
+ return not all((account or {}).get(field) for field in ('name', 'number', 'ifsc'))
+
+
+def render_demand_letter_pdf(context, semester, academic_year_label, due_date,
+ demand, bank, reference_number, issued_on):
+ output = BytesIO()
+ document = SimpleDocTemplate(
+ output,
+ pagesize=A4,
+ topMargin=2.5 * cm,
+ rightMargin=1.5 * cm,
+ bottomMargin=2.5 * cm,
+ leftMargin=1.5 * cm,
+ title='Demand Letter',
+ author=settings.BONAFIDE_INSTITUTE_NAME,
+ )
+ header_style = ParagraphStyle(
+ 'Header', fontName='Helvetica-Bold', fontSize=12,
+ leading=16, alignment=TA_LEFT,
+ )
+ header_right_style = ParagraphStyle(
+ 'HeaderRight', parent=header_style, alignment=TA_RIGHT,
+ )
+ heading_style = ParagraphStyle(
+ 'Heading', fontName='Helvetica-Bold', fontSize=13.5,
+ leading=17, alignment=TA_CENTER,
+ )
+ body_style = ParagraphStyle(
+ 'Body', fontName='Helvetica', fontSize=12,
+ leading=18, alignment=TA_JUSTIFY,
+ )
+ sub_heading_style = ParagraphStyle(
+ 'SubHeading', fontName='Helvetica-Bold', fontSize=12,
+ leading=16, alignment=TA_CENTER,
+ )
+ cell_style = ParagraphStyle(
+ 'Cell', fontName='Helvetica', fontSize=11, leading=14, alignment=TA_LEFT,
+ )
+ cell_bold_style = ParagraphStyle(
+ 'CellBold', parent=cell_style, fontName='Helvetica-Bold',
+ )
+ cell_center_style = ParagraphStyle(
+ 'CellCenter', parent=cell_style, alignment=TA_CENTER,
+ )
+ cell_bold_right_style = ParagraphStyle(
+ 'CellBoldRight', parent=cell_bold_style, alignment=TA_RIGHT,
+ )
+ bank_label_style = ParagraphStyle(
+ 'BankLabel', fontName='Helvetica-Bold', fontSize=11, leading=14,
+ )
+ bank_value_style = ParagraphStyle(
+ 'BankValue', fontName='Helvetica', fontSize=11, leading=14,
+ )
+ signature_style = ParagraphStyle(
+ 'Signature', fontName='Helvetica-Bold', fontSize=12,
+ leading=16, 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),
+ ]))
+
+ body_text = (
+ 'This is 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 bonafide student of '
+ f'{escape(context["year_ordinal"])} year '
+ f'({escape(context["semester_ordinal"])} Semester) of '
+ f'{escape(context["programme"])} Programme in '
+ f'{escape(context["discipline_with_acronym"])} at '
+ f'{escape(settings.BONAFIDE_INSTITUTE_NAME)}. '
+ 'The Date of fees submission as detailed below for Semester '
+ f'{escape(ordinal(semester))}, {escape(academic_year_label)} '
+ f'is on or before {due_date.strftime("%B %d, %Y")}.'
+ )
+
+ fee_rows = [[
+ Paragraph(escape(str(demand['batch_year'] or '')), cell_style),
+ Paragraph(escape(demand['category_label']), cell_style),
+ Paragraph('Academic Fee', cell_style),
+ Paragraph(escape(f"{demand['academic_fee_display']}/-"), cell_style),
+ ], [
+ '', '',
+ Paragraph('Mess Fee', cell_style),
+ Paragraph(escape(f"{demand['mess_fee_display']}/-"), cell_style),
+ ], [
+ Paragraph('Total Rs. =', cell_bold_right_style), '', '',
+ Paragraph(escape(f"{demand['total_display']}/-"), cell_bold_style),
+ ]]
+ fee_header = [
+ Paragraph(escape(demand['programme_short']), cell_bold_style),
+ Paragraph('Category', cell_bold_style),
+ Paragraph('Fee', cell_bold_style),
+ Paragraph('Amount', cell_bold_style),
+ ]
+ fee_table = Table(
+ [fee_header] + fee_rows,
+ colWidths=[3 * cm, 3.5 * cm, 4 * cm, 3.5 * cm], hAlign='LEFT',
+ )
+ 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), 3),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 3),
+ ('SPAN', (0, 1), (0, 2)),
+ ('SPAN', (1, 1), (1, 2)),
+ ('SPAN', (0, 3), (2, 3)),
+ ]))
+
+ def bank_block(details):
+ rows = [
+ [Paragraph('Account
Name', bank_label_style),
+ Paragraph(escape(details['name']), bank_value_style)],
+ [Paragraph('Account
Number', bank_label_style),
+ Paragraph(escape(details['number']), bank_value_style)],
+ [Paragraph('IFSC', bank_label_style),
+ Paragraph(escape(details['ifsc']), bank_value_style)],
+ [Paragraph('Bank &
Branch', bank_label_style),
+ Paragraph(escape(details['bank_branch']), bank_value_style)],
+ [Paragraph('Account
Type', bank_label_style),
+ Paragraph(escape(details['account_type']), bank_value_style)],
+ ]
+ table = Table(rows, colWidths=[2.3 * cm, 5.7 * cm])
+ table.setStyle(TableStyle([
+ ('GRID', (0, 0), (-1, -1), 0.75, colors.black),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ('LEFTPADDING', (0, 0), (-1, -1), 5),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 5),
+ ('TOPPADDING', (0, 0), (-1, -1), 3),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 3),
+ ]))
+ return table
+
+ bank_side_by_side = Table(
+ [[
+ Paragraph('Academic Fee', sub_heading_style),
+ Paragraph('Mess Fee', sub_heading_style),
+ ], [
+ bank_block(bank['academic']),
+ bank_block(bank['mess']),
+ ]],
+ colWidths=[9 * cm, 9 * cm],
+ )
+ bank_side_by_side.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), 4),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
+ ]))
+
+ story = [
+ header,
+ Spacer(1, 1.2 * cm),
+ Paragraph('TO WHOM SO EVER IT MAY CONCERN', heading_style),
+ Spacer(1, 1 * cm),
+ Paragraph(body_text, body_style),
+ Spacer(1, 0.5 * cm),
+ fee_table,
+ Spacer(1, 0.4 * cm),
+ Paragraph(
+ 'The above mentioned fee is to be paid through online mode only, '
+ 'Institute bank details are as follows:', body_style,
+ ),
+ Spacer(1, 0.4 * cm),
+ Paragraph('Bank A/c Details for Transferring Fee', sub_heading_style),
+ Spacer(1, 0.3 * cm),
+ bank_side_by_side,
+ Spacer(1, 1.3 * cm),
+ Paragraph(f'({escape(settings.BONAFIDE_SIGNATORY_NAME)})', signature_style),
+ ]
+
+ document.build(story)
+ output.seek(0)
+ return output
+
+
+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}_Demand_Letter_{certificate.pk:03d}.pdf'
+
+
+def _certificate_reference(certificate):
+ if certificate.reference_number:
+ return certificate.reference_number
+ prefix = settings.BONAFIDE_REFERENCE_PREFIX.rstrip('/')
+ issued_on = certificate.issued_at.date()
+ roll_number = certificate.student.id.user.username
+ return (
+ f'{prefix}/{issued_on.year}/{issued_on.month:02d}/'
+ f'{roll_number}/{certificate.pk:03d}'
+ )
+
+
+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 demand_letter_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)
+ structure = fee_structure_for(student)
+ errors = list(context['validation_errors'])
+
+ 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.')
+
+ bank = _bank_details()
+ if _bank_account_missing(bank['academic']) or _bank_account_missing(bank['mess']):
+ errors.append(
+ 'The institute bank account details have not been set up yet. '
+ 'Add them before issuing this certificate.')
+
+ semester_param = request.query_params.get('semester')
+ default_semester = context['semester'] or 1
+ try:
+ semester = int(semester_param) if semester_param else default_semester
+ except (TypeError, ValueError):
+ semester = default_semester
+ if structure and not (1 <= semester <= structure.semester_count):
+ semester = min(max(default_semester, 1), structure.semester_count)
+
+ demand = build_demand_row(student, structure, semester) if structure else None
+ _, academic_year_label = financial_year_for(student, semester)
+
+ 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,
+ 'discipline_with_acronym': _discipline_with_acronym(student),
+ },
+ 'semesters': semester_choices(structure),
+ 'selected_semester': semester,
+ 'academic_year_label': academic_year_label,
+ 'demand': demand,
+ 'bank': bank,
+ '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_demand_letter_pdf(request):
+ student_id = request.data.get('student_id')
+ semester_raw = request.data.get('semester')
+ due_date_raw = (request.data.get('due_date') or '').strip()
+
+ if not student_id:
+ return Response({'error': 'student_id is required.'},
+ status=status.HTTP_400_BAD_REQUEST)
+ try:
+ semester = int(semester_raw)
+ except (TypeError, ValueError):
+ return Response({'error': 'Select a valid semester.'},
+ status=status.HTTP_400_BAD_REQUEST)
+ try:
+ due_date = datetime.strptime(due_date_raw, '%Y-%m-%d').date()
+ except ValueError:
+ return Response({'error': 'Select a valid due date.'},
+ status=status.HTTP_400_BAD_REQUEST)
+
+ student = get_object_or_404(_student_queryset(), pk=student_id)
+ context = build_certificate_context(student)
+ if not context['is_ready']:
+ return Response(
+ {'error': 'Student data is incomplete.',
+ 'details': context['validation_errors']},
+ status=status.HTTP_400_BAD_REQUEST)
+ context['discipline_with_acronym'] = _discipline_with_acronym(student)
+
+ 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)
+ if not (1 <= semester <= structure.semester_count):
+ return Response(
+ {'error': 'Semester is outside the published fee structure.'},
+ status=status.HTTP_400_BAD_REQUEST)
+
+ bank = _bank_details()
+ if _bank_account_missing(bank['academic']) or _bank_account_missing(bank['mess']):
+ return Response(
+ {'error': 'The institute bank account details have not been '
+ 'set up yet.'},
+ status=status.HTTP_400_BAD_REQUEST)
+
+ demand = build_demand_row(student, structure, semester)
+ _, academic_year_label = financial_year_for(student, semester)
+
+ 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=f'{ordinal(semester)} Semester Fee Demand',
+ fee_rows=[{
+ **demand,
+ 'semester': semester,
+ 'academic_year': academic_year_label,
+ 'due_date': due_date.isoformat(),
+ }],
+ 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_demand_letter_pdf(
+ context, semester, academic_year_label, due_date, demand, bank,
+ reference_number, issued_on)
+ 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 demand_letters(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,
+ 'total_pages': page.paginator.num_pages,
+ 'results': [{
+ 'id': certificate.pk,
+ 'serial_number': f'{certificate.pk:03d}',
+ 'reference_number': _certificate_reference(certificate),
+ 'roll_number': certificate.student.id.user.username,
+ 'name': certificate.student.id.user.get_full_name().strip(),
+ 'purpose': certificate.purpose,
+ 'issued_on': certificate.issued_at.strftime('%d.%m.%Y'),
+ } for certificate in page],
+ })
+
+
+@api_view(['GET'])
+@authentication_classes([TokenAuthentication])
+@permission_classes([IsAuthenticated])
+@role_required(['acadadmin'])
+def demand_letter_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)
+ disposition = (
+ 'attachment'
+ if request.query_params.get('download') in {'1', 'true', 'True'}
+ else 'inline'
+ )
+ response = HttpResponse(bytes(certificate.pdf_content),
+ content_type='application/pdf')
+ response['Content-Disposition'] = (
+ f'{disposition}; 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
index 8c1126440..4d85dc309 100644
--- a/FusionIIIT/applications/academic_procedures/api/fee_structure.py
+++ b/FusionIIIT/applications/academic_procedures/api/fee_structure.py
@@ -14,7 +14,7 @@
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
-from applications.academic_procedures.models import FeeStructure
+from applications.academic_procedures.models import DemandLetterBankAccounts, FeeStructure
from applications.globals.decorators import role_required
SECTIONS = ('one_time_heads', 'semester_heads', 'tuition_hostel_heads')
@@ -58,6 +58,16 @@ def _clean_amount(value):
raise ValueError(f'{value!r} is not an amount.') from exc
+BANK_FIELDS = ('name', 'number', 'ifsc', 'bank_branch', 'account_type')
+BANK_FIELD_LIMITS = {'name': 120, 'number': 30, 'ifsc': 11, 'bank_branch': 120,
+ 'account_type': 40}
+
+
+def _clean_bank_account(account):
+ return {field: str((account or {}).get(field) or '').strip()[:BANK_FIELD_LIMITS[field]]
+ for field in BANK_FIELDS}
+
+
def _clean_heads(rows, label):
cleaned = []
for row in rows or []:
@@ -263,3 +273,26 @@ def fee_structure_template(request):
],
**{section: _blank_heads(section) for section in SECTIONS},
})
+
+
+def _serialise_bank_accounts(accounts):
+ return {
+ 'academic_fee_account': accounts.academic_fee_account,
+ 'mess_fee_account': accounts.mess_fee_account,
+ 'updated_at': accounts.updated_at.strftime('%d.%m.%Y'),
+ }
+
+
+@api_view(['GET', 'PUT'])
+@authentication_classes([TokenAuthentication])
+@permission_classes([IsAuthenticated])
+@role_required(['acadadmin'])
+def demand_letter_bank_accounts(request):
+ accounts = DemandLetterBankAccounts.load()
+ if request.method == 'PUT':
+ accounts.academic_fee_account = _clean_bank_account(
+ request.data.get('academic_fee_account'))
+ accounts.mess_fee_account = _clean_bank_account(
+ request.data.get('mess_fee_account'))
+ accounts.save()
+ return Response(_serialise_bank_accounts(accounts))
diff --git a/FusionIIIT/applications/academic_procedures/api/fee_structure_certificate.py b/FusionIIIT/applications/academic_procedures/api/fee_structure_certificate.py
new file mode 100644
index 000000000..0d6ee67f2
--- /dev/null
+++ b/FusionIIIT/applications/academic_procedures/api/fee_structure_certificate.py
@@ -0,0 +1,415 @@
+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
+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
+from applications.globals.decorators import role_required
+
+from .bonafide_certificate import (
+ _search_certificates,
+ _student_queryset,
+ build_certificate_context,
+)
+from .demand_letter import financial_year_for
+from .fee_certificate import _is_concession, fee_structure_for, indian_currency
+
+CERTIFICATE_TYPE = 'fee_structure'
+
+ONE_TIME_LABEL = 'One-time payment is paid only once at the time of admission'
+
+
+def session_semesters(semester):
+ odd = semester if semester % 2 else semester - 1
+ return odd, odd + 1
+
+
+def build_structure_rows(student, structure, odd_semester):
+ column = 'concession' if _is_concession(student) else 'general'
+ even_semester = odd_semester + 1
+ rows = []
+ for heads in (structure.tuition_hostel_heads, structure.semester_heads):
+ for head in heads or []:
+ sem1 = structure._amount(head, column, odd_semester)
+ sem2 = structure._amount(head, column, even_semester)
+ rows.append({
+ 'label': head.get('head', ''),
+ 'sem1': str(sem1.quantize(Decimal('0.01'))),
+ 'sem2': str(sem2.quantize(Decimal('0.01'))),
+ 'total': str((sem1 + sem2).quantize(Decimal('0.01'))),
+ })
+
+ mess = Decimal(structure.mess_advance_per_semester or 0).quantize(Decimal('0.01'))
+ rows.append({
+ 'label': 'Mess Advance',
+ 'sem1': str(mess), 'sem2': str(mess),
+ 'total': str((mess * 2).quantize(Decimal('0.01'))),
+ })
+
+ one_time_sem1 = Decimal('0')
+ if odd_semester == 1:
+ one_time_sem1 = sum(
+ (structure._amount(head, column, odd_semester)
+ for head in structure.one_time_heads or []),
+ Decimal('0'),
+ )
+ rows.append({
+ 'label': ONE_TIME_LABEL,
+ 'sem1': str(one_time_sem1.quantize(Decimal('0.01'))),
+ 'sem2': '0.00',
+ 'total': str(one_time_sem1.quantize(Decimal('0.01'))),
+ })
+ return rows
+
+
+def _column_total(rows, key):
+ return sum((Decimal(row[key]) for row in rows), Decimal('0'))
+
+
+def render_fee_structure_pdf(context, structure, odd_semester, academic_year_label,
+ rows, reference_number, issued_on):
+ 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 Structure Certificate',
+ author=settings.BONAFIDE_INSTITUTE_NAME,
+ )
+ header_style = ParagraphStyle(
+ 'Header', fontName='Helvetica-Bold', fontSize=12, leading=16,
+ )
+ header_right_style = ParagraphStyle(
+ 'HeaderRight', parent=header_style, alignment=2,
+ )
+ label_style = ParagraphStyle(
+ 'Label', fontName='Helvetica-Bold', fontSize=12, leading=18,
+ )
+ value_style = ParagraphStyle(
+ 'Value', fontName='Helvetica', fontSize=12, leading=18,
+ )
+ note_style = ParagraphStyle(
+ 'Note', fontName='Helvetica-Bold', fontSize=12, leading=16,
+ )
+ cell_style = ParagraphStyle(
+ 'Cell', fontName='Helvetica', fontSize=12, leading=15, alignment=TA_LEFT,
+ )
+ cell_center_style = ParagraphStyle(
+ 'CellCenter', parent=cell_style, alignment=TA_CENTER,
+ )
+ cell_bold_style = ParagraphStyle(
+ 'CellBold', parent=cell_style, fontName='Helvetica-Bold',
+ )
+ signature_style = ParagraphStyle(
+ 'Signature', fontName='Helvetica-Bold', fontSize=12,
+ leading=16, 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),
+ ]))
+
+ def label_cell(text):
+ return Paragraph(text, label_style)
+
+ def value_cell(text):
+ return Paragraph(escape(str(text)), value_style)
+
+ details = Table(
+ [
+ [label_cell('Name:'), value_cell(context['name']), '', ''],
+ [label_cell("Father's Name:"),
+ 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('Programme:'), value_cell(context['programme_short'])],
+ [label_cell('Financial Year:'), value_cell(academic_year_label), '', ''],
+ ],
+ colWidths=[3.3 * cm, 2.3 * cm, 3.3 * cm, 9.1 * cm],
+ )
+ details.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), 4),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
+ ('SPAN', (1, 0), (3, 0)),
+ ('SPAN', (1, 1), (3, 1)),
+ ('SPAN', (1, 4), (3, 4)),
+ ]))
+
+ table_rows = [[
+ Paragraph(str(index), cell_center_style),
+ Paragraph(escape(row['label']), cell_style),
+ Paragraph(escape(indian_currency(Decimal(row['sem1']))), cell_style),
+ Paragraph(escape(indian_currency(Decimal(row['sem2']))), cell_style),
+ Paragraph(escape(indian_currency(Decimal(row['total']))), cell_style),
+ ] for index, row in enumerate(rows, start=1)]
+ table_rows.append([
+ Paragraph('Total (Rs)', cell_bold_style), '',
+ Paragraph(escape(indian_currency(_column_total(rows, 'sem1'))), cell_bold_style),
+ Paragraph(escape(indian_currency(_column_total(rows, 'sem2'))), cell_bold_style),
+ Paragraph(escape(indian_currency(_column_total(rows, 'total'))), cell_bold_style),
+ ])
+
+ structure_table = Table(
+ [[
+ Paragraph('S.No.', cell_bold_style),
+ Paragraph('Fee Head(s)', cell_bold_style),
+ Paragraph('Semester-I', cell_bold_style),
+ Paragraph('Semester-II', cell_bold_style),
+ Paragraph('Total', cell_bold_style),
+ ]] + table_rows,
+ colWidths=[1.8 * cm, 6.2 * cm, 2.7 * cm, 3.3 * cm, 3 * cm], hAlign='LEFT',
+ )
+ structure_table.setStyle(TableStyle([
+ ('GRID', (0, 0), (-1, -1), 0.75, colors.black),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ('LEFTPADDING', (0, 0), (-1, -1), 5),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 5),
+ ('TOPPADDING', (0, 0), (-1, -1), 3),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 3),
+ ('SPAN', (0, len(rows) + 1), (1, len(rows) + 1)),
+ ]))
+
+ story = [
+ header,
+ Spacer(1, 1 * cm),
+ details,
+ Spacer(1, 0.6 * cm),
+ Paragraph('Details Break-up:', note_style),
+ Spacer(1, 0.3 * cm),
+ structure_table,
+ Spacer(1, 1.6 * cm),
+ Paragraph(f'({escape(settings.BONAFIDE_SIGNATORY_NAME)})', signature_style),
+ ]
+
+ document.build(story)
+ output.seek(0)
+ return output
+
+
+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_Structure_Certificate_{certificate.pk:03d}.pdf'
+
+
+def _certificate_reference(certificate):
+ if certificate.reference_number:
+ return certificate.reference_number
+ prefix = settings.BONAFIDE_REFERENCE_PREFIX.rstrip('/')
+ issued_on = certificate.issued_at.date()
+ roll_number = certificate.student.id.user.username
+ return (
+ f'{prefix}/{issued_on.year}/{issued_on.month:02d}/'
+ f'{roll_number}/{certificate.pk:03d}'
+ )
+
+
+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_structure_certificate_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)
+ errors = list(context['validation_errors'])
+
+ rows = []
+ academic_year_label = ''
+ 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'])
+ rows = build_structure_rows(student, structure, odd_semester)
+ _, academic_year_label = financial_year_for(student, odd_semester)
+
+ 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},
+ 'structure': {
+ 'rows': rows,
+ 'academic_year_label': academic_year_label,
+ 'total_display': (
+ indian_currency(_column_total(rows, 'total')) if rows else ''
+ ),
+ },
+ '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_structure_pdf(request):
+ student_id = request.data.get('student_id')
+ if not student_id:
+ return Response({'error': 'student_id is required.'},
+ status=status.HTTP_400_BAD_REQUEST)
+
+ 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)
+
+ odd_semester, _ = session_semesters(context['semester'])
+ rows = build_structure_rows(student, structure, odd_semester)
+ _, academic_year_label = financial_year_for(student, odd_semester)
+
+ 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=f'Fee Structure {academic_year_label}',
+ 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_structure_pdf(
+ context, structure, odd_semester, academic_year_label, rows,
+ reference_number, issued_on)
+ 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_structure_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,
+ 'total_pages': page.paginator.num_pages,
+ 'results': [{
+ 'id': certificate.pk,
+ 'serial_number': f'{certificate.pk:03d}',
+ 'reference_number': _certificate_reference(certificate),
+ 'roll_number': certificate.student.id.user.username,
+ 'name': certificate.student.id.user.get_full_name().strip(),
+ 'purpose': certificate.purpose,
+ 'issued_on': certificate.issued_at.strftime('%d.%m.%Y'),
+ } for certificate in page],
+ })
+
+
+@api_view(['GET'])
+@authentication_classes([TokenAuthentication])
+@permission_classes([IsAuthenticated])
+@role_required(['acadadmin'])
+def fee_structure_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)
+ disposition = (
+ 'attachment'
+ if request.query_params.get('download') in {'1', 'true', 'True'}
+ else 'inline'
+ )
+ response = HttpResponse(bytes(certificate.pdf_content),
+ content_type='application/pdf')
+ response['Content-Disposition'] = (
+ f'{disposition}; filename="{_certificate_filename(certificate)}"')
+ return response
diff --git a/FusionIIIT/applications/academic_procedures/api/urls.py b/FusionIIIT/applications/academic_procedures/api/urls.py
index 31df7b52a..8f808b2f8 100644
--- a/FusionIIIT/applications/academic_procedures/api/urls.py
+++ b/FusionIIIT/applications/academic_procedures/api/urls.py
@@ -1,6 +1,7 @@
from django.conf.urls import url
from . import views
-from . import bonafide_certificate, fee_certificate, fee_structure
+from . import (bonafide_certificate, demand_letter, fee_certificate,
+ fee_structure, fee_structure_certificate)
from .. import views as procedures_views
@@ -29,6 +30,26 @@
url(r'^acad/fee-certificate/certificates/(?P\d+)/pdf/$',
fee_certificate.fee_certificate_pdf,
name='fee-certificate-pdf-detail'),
+ url(r'^acad/demand-letter/student/$', demand_letter.demand_letter_student,
+ name='demand-letter-student'),
+ url(r'^acad/demand-letter/pdf/$', demand_letter.generate_demand_letter_pdf,
+ name='demand-letter-pdf'),
+ url(r'^acad/demand-letter/certificates/$', demand_letter.demand_letters,
+ name='demand-letters'),
+ url(r'^acad/demand-letter/certificates/(?P\d+)/pdf/$',
+ demand_letter.demand_letter_pdf, name='demand-letter-pdf-detail'),
+ url(r'^acad/fee-structure-certificate/student/$',
+ fee_structure_certificate.fee_structure_certificate_student,
+ name='fee-structure-certificate-student'),
+ url(r'^acad/fee-structure-certificate/pdf/$',
+ fee_structure_certificate.generate_fee_structure_pdf,
+ name='fee-structure-certificate-pdf'),
+ url(r'^acad/fee-structure-certificate/certificates/$',
+ fee_structure_certificate.fee_structure_certificates,
+ name='fee-structure-certificates'),
+ url(r'^acad/fee-structure-certificate/certificates/(?P\d+)/pdf/$',
+ fee_structure_certificate.fee_structure_certificate_pdf,
+ name='fee-structure-certificate-pdf-detail'),
url(r'^acad/fee-structures/$', fee_structure.fee_structures,
name='fee-structures'),
url(r'^acad/fee-structures/template/$',
@@ -37,6 +58,8 @@
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'^acad/demand-letter-bank-accounts/$',
+ fee_structure.demand_letter_bank_accounts, name='demand-letter-bank-accounts'),
# 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/0059_demand_and_fee_structure_certificate_types.py b/FusionIIIT/applications/academic_procedures/migrations/0059_demand_and_fee_structure_certificate_types.py
new file mode 100644
index 000000000..43139b39c
--- /dev/null
+++ b/FusionIIIT/applications/academic_procedures/migrations/0059_demand_and_fee_structure_certificate_types.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1.5 on 2026-10-01 22:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('academic_procedures', '0058_fee_structure_display'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='bonafidecertificate',
+ name='certificate_type',
+ field=models.CharField(choices=[('bonafide', 'Bonafide Certificate'), ('fee', 'Paid / Unpaid Fee Certificate'), ('demand', 'Demand Letter'), ('fee_structure', 'Fee Structure Certificate')], db_index=True, default='bonafide', max_length=16),
+ ),
+ ]
diff --git a/FusionIIIT/applications/academic_procedures/migrations/0060_demandletterbankaccounts.py b/FusionIIIT/applications/academic_procedures/migrations/0060_demandletterbankaccounts.py
new file mode 100644
index 000000000..0c60c593c
--- /dev/null
+++ b/FusionIIIT/applications/academic_procedures/migrations/0060_demandletterbankaccounts.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.1.5 on 2026-10-01 22:51
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('academic_procedures', '0059_demand_and_fee_structure_certificate_types'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='DemandLetterBankAccounts',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('academic_fee_account', models.JSONField(blank=True, default=dict)),
+ ('mess_fee_account', models.JSONField(blank=True, default=dict)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'db_table': 'DemandLetterBankAccounts',
+ },
+ ),
+ ]
diff --git a/FusionIIIT/applications/academic_procedures/models.py b/FusionIIIT/applications/academic_procedures/models.py
index 000948930..68d1f1e7c 100644
--- a/FusionIIIT/applications/academic_procedures/models.py
+++ b/FusionIIIT/applications/academic_procedures/models.py
@@ -385,6 +385,8 @@ class BonafideCertificate(models.Model):
TYPE_CHOICES = (
('bonafide', 'Bonafide Certificate'),
('fee', 'Paid / Unpaid Fee Certificate'),
+ ('demand', 'Demand Letter'),
+ ('fee_structure', 'Fee Structure Certificate'),
)
certificate_type = models.CharField(
@@ -489,6 +491,25 @@ def section_total(self, section, column, semester=1):
def __str__(self):
return f'{self.programme_category} {self.academic_year}'
+
+class DemandLetterBankAccounts(models.Model):
+ academic_fee_account = models.JSONField(default=dict, blank=True)
+ mess_fee_account = models.JSONField(default=dict, blank=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ db_table = 'DemandLetterBankAccounts'
+
+ def save(self, *args, **kwargs):
+ self.pk = 1
+ super().save(*args, **kwargs)
+
+ @classmethod
+ def load(cls):
+ obj, _ = cls.objects.get_or_create(pk=1)
+ return obj
+
+
class AssistantshipClaim(models.Model):
Month_Choices = [
('Jan', 'January'),
diff --git a/FusionIIIT/applications/academic_procedures/test_demand_letter.py b/FusionIIIT/applications/academic_procedures/test_demand_letter.py
new file mode 100644
index 000000000..a2f7bc82c
--- /dev/null
+++ b/FusionIIIT/applications/academic_procedures/test_demand_letter.py
@@ -0,0 +1,243 @@
+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 demand_letter as dl
+from applications.academic_procedures.models import (
+ BonafideCertificate, DemandLetterBankAccounts, 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 DemandLetterTestCase(TestCase):
+ 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,
+ general_label='Gen/OBC/EWS', concession_label='PWD/SC/ST',
+ 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.'],
+ )
+
+ DemandLetterBankAccounts.objects.create(
+ pk=1,
+ academic_fee_account={
+ 'name': 'PDPM IIITDM STUDENT FEE ACCOUNT', 'number': '50030581281',
+ 'ifsc': 'IDIB000M694', 'bank_branch': 'INDIAN BANK, Meghawan IIITDM Jabalpur',
+ 'account_type': 'Current Account',
+ },
+ mess_fee_account={
+ 'name': 'IIITDMJ MESS ACCOUNT', 'number': '50035242857',
+ 'ifsc': 'IDIB000M694', 'bank_branch': 'INDIAN BANK, Meghawan IIITDM Jabalpur',
+ 'account_type': 'Saving Account',
+ },
+ )
+
+ self.student = self._student('26BCS1234', category='GEN', semester=3)
+
+ self.admin = User.objects.create_user(username='demand-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 FigureTests(DemandLetterTestCase):
+ def test_the_academic_and_mess_fee_are_split_apart(self):
+ row = dl.build_demand_row(self.student, self.structure, 3)
+ self.assertEqual(row['academic_fee'], '90250.00')
+ self.assertEqual(row['mess_fee'], '15000.00')
+
+ def test_concession_students_get_the_concession_figures(self):
+ student = self._student('26BCS2001', category='SC')
+ row = dl.build_demand_row(student, self.structure, 3)
+ self.assertEqual(row['academic_fee'], '18500.00')
+ self.assertEqual(row['category_label'], 'PWD/SC/ST')
+
+ def test_general_students_get_the_general_label(self):
+ row = dl.build_demand_row(self.student, self.structure, 3)
+ self.assertEqual(row['category_label'], 'Gen/OBC/EWS')
+
+ def test_the_admission_semester_folds_in_the_one_time_heads(self):
+ sem1 = dl.build_demand_row(self.student, self.structure, 1)
+ sem2 = dl.build_demand_row(self.student, self.structure, 2)
+ self.assertEqual(sem1['academic_fee'], '105750.00')
+ self.assertEqual(sem2['academic_fee'], '90250.00')
+
+ def test_programme_short_matches_the_circular(self):
+ self.assertEqual(dl._programme_short(self.student), 'B-TECH')
+
+ def test_the_bank_details_come_from_the_shared_record_not_a_constant(self):
+ bank = dl._bank_details()
+ self.assertEqual(bank['academic']['number'], '50030581281')
+ self.assertEqual(bank['mess']['number'], '50035242857')
+
+ accounts = DemandLetterBankAccounts.load()
+ accounts.academic_fee_account = {
+ **accounts.academic_fee_account, 'number': '99999999999'}
+ accounts.save()
+ self.assertEqual(dl._bank_details()['academic']['number'], '99999999999')
+
+ def test_a_blank_bank_account_is_detected_as_missing(self):
+ accounts = DemandLetterBankAccounts.load()
+ self.assertFalse(dl._bank_account_missing(accounts.academic_fee_account))
+ self.assertTrue(dl._bank_account_missing({}))
+ self.assertTrue(dl._bank_account_missing({'name': 'X'}))
+
+ def test_financial_year_pairs_odd_and_even_semesters(self):
+ self.assertEqual(dl.financial_year_for(self.student, 1)[1], '2026-27')
+ self.assertEqual(dl.financial_year_for(self.student, 2)[1], '2026-27')
+ self.assertEqual(dl.financial_year_for(self.student, 3)[1], '2027-28')
+ self.assertEqual(dl.financial_year_for(self.student, 4)[1], '2027-28')
+
+
+class IssuedCertificateTests(DemandLetterTestCase):
+ def _generate(self, semester=3, due_date='2027-01-03', **extra):
+ return self.client.post(
+ '/academic-procedures/api/acad/demand-letter/pdf/',
+ {'student_id': self.student.pk, 'semester': semester,
+ 'due_date': due_date, **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_a_certificate_is_recorded_against_the_student(self):
+ self._generate()
+ certificate = BonafideCertificate.objects.get(certificate_type='demand')
+ self.assertEqual(certificate.student_id, self.student.pk)
+ self.assertEqual(certificate.issued_by, self.admin)
+ self.assertEqual(certificate.fee_rows[0]['semester'], 3)
+ self.assertEqual(certificate.fee_rows[0]['due_date'], '2027-01-03')
+
+ def test_a_missing_due_date_is_refused(self):
+ self.assertEqual(self._generate(due_date='').status_code, 400)
+
+ def test_a_malformed_due_date_is_refused(self):
+ self.assertEqual(self._generate(due_date='03-01-2027').status_code, 400)
+
+ def test_a_semester_outside_the_structure_is_refused(self):
+ self.assertEqual(self._generate(semester=99).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_the_same_bank_accounts_apply_to_every_programme_and_year(self):
+ 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 CSE 2026', no_of_semester=4)
+ pg_batch = Batch.objects.create(
+ name='M.Tech', discipline=self.discipline, year=2027,
+ curriculum=pg_curriculum)
+ FeeStructure.objects.create(
+ programme_category='PG', start_year=2027, semester_count=4,
+ semester_heads=ACADEMIC, mess_advance_per_semester=Decimal(15000))
+ pg_student = self._student('27MCS0001', batch=pg_batch, semester=1)
+
+ response = self.client.post(
+ '/academic-procedures/api/acad/demand-letter/pdf/',
+ {'student_id': pg_student.pk, 'semester': 1, 'due_date': '2027-01-03'},
+ format='json')
+ self.assertEqual(response.status_code, 200)
+
+ def test_missing_bank_accounts_are_refused(self):
+ accounts = DemandLetterBankAccounts.load()
+ accounts.academic_fee_account = {}
+ accounts.save()
+ response = self._generate()
+ self.assertEqual(response.status_code, 400)
+ self.assertIn('bank account', response.json()['error'].lower())
+
+ def test_only_demand_letters_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/demand-letter/certificates/')
+ self.assertEqual(listing.json()['count'], 1)
+
+ def test_a_demand_letter_cannot_be_downloaded_as_a_bonafide_one(self):
+ self._generate()
+ demand = BonafideCertificate.objects.get(certificate_type='demand')
+ response = self.client.get(
+ f'/academic-procedures/api/acad/bonafide/certificates/{demand.pk}/pdf/')
+ self.assertEqual(response.status_code, 404)
+
+
+class BankAccountsEndpointTests(DemandLetterTestCase):
+ def test_the_accounts_can_be_fetched(self):
+ response = self.client.get(
+ '/academic-procedures/api/acad/demand-letter-bank-accounts/')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response.json()['academic_fee_account']['number'], '50030581281')
+
+ def test_the_accounts_can_be_edited_and_stay_a_single_row(self):
+ response = self.client.put(
+ '/academic-procedures/api/acad/demand-letter-bank-accounts/',
+ {
+ 'academic_fee_account': {
+ 'name': 'New Account', 'number': '111', 'ifsc': 'ABCD0001234',
+ 'bank_branch': 'Somewhere', 'account_type': 'Current Account',
+ },
+ 'mess_fee_account': {
+ 'name': 'Mess', 'number': '222', 'ifsc': 'ABCD0001234',
+ 'bank_branch': 'Somewhere', 'account_type': 'Saving Account',
+ },
+ },
+ format='json')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()['academic_fee_account']['number'], '111')
+ self.assertEqual(DemandLetterBankAccounts.objects.count(), 1)
diff --git a/FusionIIIT/applications/academic_procedures/test_fee_structure_certificate.py b/FusionIIIT/applications/academic_procedures/test_fee_structure_certificate.py
new file mode 100644
index 000000000..661f168e6
--- /dev/null
+++ b/FusionIIIT/applications/academic_procedures/test_fee_structure_certificate.py
@@ -0,0 +1,147 @@
+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_structure_certificate as fsc
+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 FeeStructureCertificateTestCase(TestCase):
+ 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),
+ )
+
+ self.student = self._student('26BCS1234', category='GEN', semester=1)
+
+ self.admin = User.objects.create_user(username='fsc-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=1, 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='M', 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 RowBuildingTests(FeeStructureCertificateTestCase):
+ def test_session_semesters_pairs_the_odd_semester_first(self):
+ self.assertEqual(fsc.session_semesters(1), (1, 2))
+ self.assertEqual(fsc.session_semesters(2), (1, 2))
+ self.assertEqual(fsc.session_semesters(3), (3, 4))
+ self.assertEqual(fsc.session_semesters(4), (3, 4))
+
+ def test_the_admission_semester_matches_the_published_circular(self):
+ rows = fsc.build_structure_rows(self.student, self.structure, 1)
+ self.assertEqual(fsc._column_total(rows, 'sem1'), Decimal('120750.00'))
+ self.assertEqual(fsc._column_total(rows, 'sem2'), Decimal('105250.00'))
+ self.assertEqual(fsc._column_total(rows, 'total'), Decimal('226000.00'))
+
+ def test_a_later_session_carries_no_one_time_heads(self):
+ rows = fsc.build_structure_rows(self.student, self.structure, 3)
+ self.assertEqual(fsc._column_total(rows, 'sem1'),
+ fsc._column_total(rows, 'sem2'))
+ one_time_row = next(r for r in rows if r['label'] == fsc.ONE_TIME_LABEL)
+ self.assertEqual(one_time_row['total'], '0.00')
+
+ def test_one_time_heads_are_combined_into_a_single_row(self):
+ rows = fsc.build_structure_rows(self.student, self.structure, 1)
+ one_time_row = next(r for r in rows if r['label'] == fsc.ONE_TIME_LABEL)
+ self.assertEqual(one_time_row['sem1'], '15500.00')
+ self.assertEqual(one_time_row['sem2'], '0.00')
+
+ def test_concession_students_get_the_concession_figures(self):
+ student = self._student('26BCS2001', category='SC')
+ rows = fsc.build_structure_rows(student, self.structure, 1)
+ tuition_row = next(r for r in rows if r['label'] == 'Tuition Fee')
+ self.assertEqual(tuition_row['sem1'], '0.00')
+
+
+class IssuedCertificateTests(FeeStructureCertificateTestCase):
+ def _generate(self):
+ return self.client.post(
+ '/academic-procedures/api/acad/fee-structure-certificate/pdf/',
+ {'student_id': self.student.pk}, 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_a_certificate_is_recorded_against_the_student(self):
+ self._generate()
+ certificate = BonafideCertificate.objects.get(
+ certificate_type='fee_structure')
+ self.assertEqual(certificate.student_id, self.student.pk)
+ self.assertEqual(certificate.issued_by, self.admin)
+ self.assertTrue(certificate.fee_rows)
+
+ 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_structure_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-structure-certificate/certificates/')
+ self.assertEqual(listing.json()['count'], 1)
+
+ def test_a_fee_structure_certificate_cannot_be_downloaded_as_a_bonafide_one(self):
+ self._generate()
+ certificate = BonafideCertificate.objects.get(
+ certificate_type='fee_structure')
+ response = self.client.get(
+ '/academic-procedures/api/acad/bonafide/certificates/'
+ f'{certificate.pk}/pdf/')
+ self.assertEqual(response.status_code, 404)