From d17b0d185f2ddae198b619761d4a94b59eb2ffe9 Mon Sep 17 00:00:00 2001 From: Vikrant Date: Sat, 3 Oct 2026 00:08:24 +0530 Subject: [PATCH] Add the Demand Letter and Fee Structure Certificate Both build on the bonafide certificate: the same header, signatory, reference numbering and record table (two new certificate_type values on BonafideCertificate). Demand Letter: a semester-wise fee demand for a chosen semester and due date, with the Academic/Mess fee split read from the same FeeStructure the student is linked to, and the institute's bank transfer details read from a new shared DemandLetterBankAccounts singleton -- one record, common to every programme and academic year, editable from the Fee Structure page rather than hardcoded. Fee Structure Certificate: the full per-head fee break-up (Semester-I / Semester-II / Total) for the student's current academic session, derived from the same FeeStructure heads the Fee Structure admin screen already manages. --- .../academic_procedures/api/demand_letter.py | 524 ++++++++++++++++++ .../academic_procedures/api/fee_structure.py | 35 +- .../api/fee_structure_certificate.py | 415 ++++++++++++++ .../academic_procedures/api/urls.py | 25 +- ...and_and_fee_structure_certificate_types.py | 18 + .../0060_demandletterbankaccounts.py | 25 + .../academic_procedures/models.py | 21 + .../academic_procedures/test_demand_letter.py | 243 ++++++++ .../test_fee_structure_certificate.py | 147 +++++ 9 files changed, 1451 insertions(+), 2 deletions(-) create mode 100644 FusionIIIT/applications/academic_procedures/api/demand_letter.py create mode 100644 FusionIIIT/applications/academic_procedures/api/fee_structure_certificate.py create mode 100644 FusionIIIT/applications/academic_procedures/migrations/0059_demand_and_fee_structure_certificate_types.py create mode 100644 FusionIIIT/applications/academic_procedures/migrations/0060_demandletterbankaccounts.py create mode 100644 FusionIIIT/applications/academic_procedures/test_demand_letter.py create mode 100644 FusionIIIT/applications/academic_procedures/test_fee_structure_certificate.py 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)