From f69ae2f565f973be2088fd6da064a42ed89058d1 Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Tue, 29 Sep 2026 21:09:41 +0530 Subject: [PATCH 1/4] Globals: one login, and the modules other Fusion services serve Signing in here now mints an IAM session beside this portal's own token, so a student who logs in walks into the placement module without signing in again. Best effort throughout: if the IAM cannot be reached the login still succeeds and the extra modules simply do not appear. A new dependency must never be able to lock people out of the portal. /api/auth/me carries two additions. Modules another service grants are merged into accessible_modules under the designation that actually grants each one, so switching role hides them exactly as it hides a local module. Their menus travel too, so the sidebar can expand a plugged module without holding a second copy of its screens. Configuration is read from the environment: no settings file changes, and a production host sets the cookie domain there. --- FusionIIIT/applications/globals/api/views.py | 25 ++++- FusionIIIT/applications/globals/iam_bridge.py | 103 ++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 FusionIIIT/applications/globals/iam_bridge.py diff --git a/FusionIIIT/applications/globals/api/views.py b/FusionIIIT/applications/globals/api/views.py index 73c40c3c2..91816407b 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 = iam_bridge.granted_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': iam_bridge.granted_navigation(iam_token), } 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..cfe3299d3 --- /dev/null +++ b/FusionIIIT/applications/globals/iam_bridge.py @@ -0,0 +1,103 @@ +"""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 granted_navigation(token): + """The menu each plugged module contributes, already permission-filtered. + + Sent through so this portal's sidebar can draw a plugged module's screens + without holding a second copy of them. + """ + payload = _me(token) or {} + return list(payload.get("navigation") or []) + + +def granted_modules(token): + """Which modules the OTHER Fusion apps grant, per designation. + + Per designation, not as one list: a module granted to somebody's acadadmin + role must not follow them into their student role. + + An empty mapping is the honest answer whenever the IAM cannot be reached — + the sidebar then shows this portal's own modules and nothing is broken. + """ + payload = _me(token) or {} + return dict(payload.get("modules_by_role") or {}) From 6607ae96528478c38c11d6dad4a9640b65d94f47 Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Tue, 29 Sep 2026 23:01:26 +0530 Subject: [PATCH 2/4] Globals: one IAM call per session check, not two The grants and the plugged modules' menus came back from the same endpoint, asked for separately. This is the login path, so the second round trip was paid on every request that resolved a session. plugged_modules returns both. Its failure case is unchanged: no modules and no menus, never an exception, because the IAM being unreachable must not stop somebody using this portal. --- FusionIIIT/applications/globals/api/views.py | 4 ++-- FusionIIIT/applications/globals/iam_bridge.py | 23 ++++--------------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/FusionIIIT/applications/globals/api/views.py b/FusionIIIT/applications/globals/api/views.py index 91816407b..160455d0c 100644 --- a/FusionIIIT/applications/globals/api/views.py +++ b/FusionIIIT/applications/globals/api/views.py @@ -125,7 +125,7 @@ def auth_view(request): # Granted by the IAM, kept under the designation that grants each one. iam_token = request.COOKIES.get(iam_bridge.COOKIE_NAME) - external = iam_bridge.granted_modules(iam_token) + external, plugged_nav = iam_bridge.plugged_modules(iam_token) for designation, codes in external.items(): if not codes: continue @@ -155,7 +155,7 @@ def auth_view(request): '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': iam_bridge.granted_navigation(iam_token), + '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 index cfe3299d3..fab98bc32 100644 --- a/FusionIIIT/applications/globals/iam_bridge.py +++ b/FusionIIIT/applications/globals/iam_bridge.py @@ -80,24 +80,9 @@ def _me(token): return None -def granted_navigation(token): - """The menu each plugged module contributes, already permission-filtered. - Sent through so this portal's sidebar can draw a plugged module's screens - without holding a second copy of them. - """ +def plugged_modules(token): + """(modules by designation, navigation) from the other services, in one call.""" payload = _me(token) or {} - return list(payload.get("navigation") or []) - - -def granted_modules(token): - """Which modules the OTHER Fusion apps grant, per designation. - - Per designation, not as one list: a module granted to somebody's acadadmin - role must not follow them into their student role. - - An empty mapping is the honest answer whenever the IAM cannot be reached — - the sidebar then shows this portal's own modules and nothing is broken. - """ - payload = _me(token) or {} - return dict(payload.get("modules_by_role") or {}) + return (dict(payload.get("modules_by_role") or {}), + list(payload.get("navigation") or [])) From 1845df0458c5101c1051443daef6c613b49996d5 Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Wed, 30 Sep 2026 04:58:25 +0530 Subject: [PATCH 3/4] Let a fresh database build the whole migration history central_mess.0001 created Payments twice, and programme_curriculum.0026 indexed course_registration without depending on the app that owns it, so the order was undefined. Either one stops a test database being built. --- .../central_mess/migrations/0001_initial.py | 14 -------------- .../migrations/0026_add_database_indexes.py | 3 +++ 2 files changed, 3 insertions(+), 14 deletions(-) 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/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 = [ From a2459e9ac55f55fcbc26817801fe5fe95ec1ac0b Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Wed, 30 Sep 2026 04:58:32 +0530 Subject: [PATCH 4/4] Add the paid/unpaid fee certificate and the fee structure behind it The certificate repeats the bonafide wording, adds the semester-wise fee table and is recorded in the same table under a certificate_type, so each screen lists only its own kind. Every figure comes from a published FeeStructure: the heads as the circular prints them, per category and session, with the totals derived rather than typed. A head may vary by semester, and the office can add or remove heads without a code change. A student's batch fixes the session, so a later revision cannot rewrite a certificate already handed over. Paid defaults before the current semester, Unpaid after it, and the current one must be chosen. --- .../api/bonafide_certificate.py | 36 +- .../api/fee_certificate.py | 481 ++++++++++++++++++ .../academic_procedures/api/fee_structure.py | 265 ++++++++++ .../academic_procedures/api/urls.py | 20 +- .../migrations/0057_fee_certificate.py | 45 ++ .../migrations/0058_fee_structure_display.py | 33 ++ .../academic_procedures/models.py | 103 ++++ .../test_fee_certificate.py | 305 +++++++++++ 8 files changed, 1277 insertions(+), 11 deletions(-) create mode 100644 FusionIIIT/applications/academic_procedures/api/fee_certificate.py create mode 100644 FusionIIIT/applications/academic_procedures/api/fee_structure.py create mode 100644 FusionIIIT/applications/academic_procedures/migrations/0057_fee_certificate.py create mode 100644 FusionIIIT/applications/academic_procedures/migrations/0058_fee_structure_display.py create mode 100644 FusionIIIT/applications/academic_procedures/test_fee_certificate.py 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)