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