diff --git a/FusionIIIT/applications/academic_procedures/api/pointer_conversion_certificate.py b/FusionIIIT/applications/academic_procedures/api/pointer_conversion_certificate.py new file mode 100644 index 000000000..587929252 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/api/pointer_conversion_certificate.py @@ -0,0 +1,418 @@ +import os +from io import BytesIO +from xml.sax.saxutils import escape + +from django.conf import settings +from django.core.paginator import Paginator +from django.db import transaction +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, 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 applications.academic_procedures.models import BonafideCertificate +from applications.globals.decorators import role_required +from applications.globals.programme_scope import ( + canonical_programme_name, + programme_display_name, +) + +from .bonafide_certificate import ( + _search_certificates, + _student_name, + _student_queryset, + ordinal, + register_certificate_fonts, +) + +from applications.examination.api.views import calculate_cpi_for_student + +CERTIFICATE_TYPE = 'pointer_conv' + + +def resolve_student_cpi(student): + """ + Dynamically calculate student's CPI using the canonical calculate_cpi_for_student() + function (from examination) that calculates live weighted CPI across all completed + course grades. If current semester grades are not submitted yet, checks prior semesters. + Falls back to student.cpi if positive. + """ + curr_sem = student.curr_semester_no or 1 + # 1. Try calculate_cpi_for_student for current semester + try: + cpi_val, _, _ = calculate_cpi_for_student(student, curr_sem, None) + if cpi_val and float(cpi_val) > 0: + return float(cpi_val), curr_sem + except Exception: + pass + + # 2. Check previous semesters descending + for sem in range(curr_sem - 1, 0, -1): + try: + cpi_val, _, _ = calculate_cpi_for_student(student, sem, None) + if cpi_val and float(cpi_val) > 0: + return float(cpi_val), sem + except Exception: + pass + + # 3. Fallback to student.cpi if positive + if student.cpi and float(student.cpi) > 0: + return float(student.cpi), None + + return 0.0, None + + +def build_pointer_conversion_context(student): + batch = student.batch_id + name = _student_name(student) + gender = (student.id.sex or '').strip().upper() + is_female = gender == 'F' + salutation = 'Ms.' if is_female else 'Mr.' + roll_number = student.id.user.username + programme_full = programme_display_name(student.programme) + programme_abbr = canonical_programme_name(student.programme) + + discipline = '' + if batch and batch.discipline: + discipline = batch.discipline.name + elif student.id.department: + discipline = student.id.department.name + + semester = student.curr_semester_no + errors = [] + if not name: + errors.append('Student name is unavailable.') + if not roll_number: + errors.append('Roll number is unavailable.') + if not discipline: + errors.append('Discipline is unavailable.') + + cpi_val, till_sem = resolve_student_cpi(student) + if cpi_val and cpi_val > 0: + cpi_str = f"{cpi_val:.2f}" + if cpi_str.endswith('0'): + cpi_str = f"{cpi_val:.1f}" + cpi_display = cpi_str + else: + cpi_display = '' + + return { + 'student_id': student.pk, + 'name': name, + 'roll_number': roll_number, + 'gender': 'Female' if is_female else 'Male' if gender == 'M' else '', + 'salutation': salutation, + 'programme': programme_full, + 'programme_abbr': programme_abbr, + 'discipline': discipline, + 'semester': semester, + 'semester_ordinal': ordinal(semester) if semester else '', + 'cpi': cpi_val, + 'cpi_display': cpi_display, + 'min_cpi': '5.0', + 'is_ready': not errors, + 'validation_errors': errors, + } + + +def render_pointer_conversion_pdf( + context, cpi_text, min_cpi, reference_number, issued_on): + output = BytesIO() + document = SimpleDocTemplate( + output, + pagesize=A4, + topMargin=3.5 * cm, + rightMargin=1.5 * cm, + bottomMargin=2 * cm, + leftMargin=1.5 * cm, + title='Pointer Conversion Certificate', + author=settings.BONAFIDE_INSTITUTE_NAME, + ) + register_certificate_fonts() + + header_style = ParagraphStyle( + 'Header', fontName='Caladea-Bold', fontSize=11, + leading=15, alignment=TA_JUSTIFY, + ) + header_right_style = ParagraphStyle( + 'HeaderRight', parent=header_style, alignment=TA_RIGHT, + ) + heading_style = ParagraphStyle( + 'Heading', fontName='Caladea-Bold', fontSize=11, + leading=15, alignment=TA_CENTER, + ) + body_style = ParagraphStyle( + 'Body', fontName='Caladea', fontSize=12, + leading=20, alignment=TA_JUSTIFY, + ) + detail_label_style = ParagraphStyle( + 'DetailLabel', fontName='Caladea-Bold', fontSize=12, + leading=20, + ) + detail_val_style = ParagraphStyle( + 'DetailVal', fontName='Caladea', fontSize=12, + leading=20, + ) + signature_style = ParagraphStyle( + 'Signature', fontName='Caladea-Bold', fontSize=11, + leading=15, alignment=TA_JUSTIFY, + ) + + 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), + ])) + + detail_rows = [ + [ + Paragraph('Name', detail_label_style), + Paragraph(':', detail_label_style), + Paragraph(f'{escape(context["salutation"])} {escape(context["name"])}', detail_val_style), + ], + [ + Paragraph('Roll No.', detail_label_style), + Paragraph(':', detail_label_style), + Paragraph(escape(context["roll_number"]), detail_val_style), + ], + [ + Paragraph('Branch', detail_label_style), + Paragraph(':', detail_label_style), + Paragraph(escape(context["discipline"]), detail_val_style), + ], + [ + Paragraph('CPI', detail_label_style), + Paragraph(':', detail_label_style), + Paragraph(escape(str(cpi_text or '……………')), detail_val_style), + ], + ] + detail_table = Table(detail_rows, colWidths=[2.5 * cm, 0.6 * cm, 13 * cm]) + detail_table.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), 2), + ('BOTTOMPADDING', (0, 0), (-1, -1), 2), + ])) + + story = [ + header, + Spacer(1, 46), + Paragraph('TO WHOM SO EVER IT MAY CONCERN', heading_style), + Spacer(1, 36), + Paragraph('     This is to certify that:', body_style), + Spacer(1, 14), + Paragraph( + f'1. The graduation requirement for {escape(context["programme"])} ' + f'({escape(context["programme_abbr"])}) programme at PDPM-IIITDM Jabalpur ' + f'is a minimum CPI of {escape(str(min_cpi))} out of the maximum of 10.0.', + body_style, + ), + Spacer(1, 12), + Paragraph( + '2. Notionally, the CPI may be multiplied by a factor of 10 to obtain the numerical percentage.', + body_style, + ), + Spacer(1, 24), + Paragraph('Issued to :', body_style), + Spacer(1, 12), + detail_table, + Spacer(1, 46), + Paragraph( + f'{escape(settings.BONAFIDE_SIGNATORY_NAME)}
' + f'{escape(settings.BONAFIDE_SIGNATORY_TITLE)}', + signature_style, + ), + ] + + document.build(story) + return output + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +@role_required(['acadadmin']) +def pointer_conversion_student(request): + roll_number = (request.query_params.get('roll_number') or '').strip().upper() + if not roll_number: + return Response( + {'error': 'A roll number is required.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + student = _student_queryset().get(id__user__username=roll_number) + except Exception: + return Response( + {'error': f"No student found with roll number '{roll_number}'."}, + status=status.HTTP_404_NOT_FOUND, + ) + + context = build_pointer_conversion_context(student) + now = timezone.now() + prefix = settings.BONAFIDE_REFERENCE_PREFIX.rstrip('/') + reference_preview = f'{prefix}/{now.year}/{now.month:02d}/{roll_number}' + + certificate_meta = { + 'signatory_name': settings.BONAFIDE_SIGNATORY_NAME, + 'signatory_title': settings.BONAFIDE_SIGNATORY_TITLE, + 'reference_preview': reference_preview, + 'issued_on': now.strftime('%d.%m.%Y'), + 'institute_name': settings.BONAFIDE_INSTITUTE_NAME, + } + + return Response({ + 'student': context, + 'certificate': certificate_meta, + }) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +@role_required(['acadadmin']) +def generate_pointer_conversion_pdf(request): + student_id = request.data.get('student_id') + if not student_id: + return Response({'error': 'student_id is required.'}, status=status.HTTP_400_BAD_REQUEST) + + student = get_object_or_404(_student_queryset(), pk=student_id) + context = build_pointer_conversion_context(student) + if not context['is_ready']: + return Response({ + 'error': 'Cannot issue certificate for student with incomplete data.', + 'details': context['validation_errors'], + }, status=status.HTTP_400_BAD_REQUEST) + + cpi_text = request.data.get('cpi_text') + if not cpi_text: + cpi_text = context['cpi_display'] or '……………' + cpi_text = str(cpi_text).strip() + + min_cpi = request.data.get('min_cpi') + if not min_cpi: + min_cpi = context['min_cpi'] + min_cpi = str(min_cpi).strip() + + issued_on = timezone.now().date() + prefix = settings.BONAFIDE_REFERENCE_PREFIX.rstrip('/') + roll_number = student.id.user.username + reference_number = f'{prefix}/{issued_on.year}/{issued_on.month:02d}/{roll_number}' + + with transaction.atomic(): + certificate = BonafideCertificate.objects.create( + student=student, + purpose='Pointer Conversion', + custom_purpose='', + certificate_type=CERTIFICATE_TYPE, + reference_number=reference_number, + issued_by=request.user, + fee_rows={'cpi': cpi_text, 'min_cpi': min_cpi}, + ) + pdf_bytes = render_pointer_conversion_pdf( + context, cpi_text, min_cpi, reference_number, issued_on + ).getvalue() + certificate.pdf_content = pdf_bytes + certificate.save(update_fields=['pdf_content']) + + filename = f'{roll_number}_Pointer_Conversion_Certificate.pdf' + response = HttpResponse(pdf_bytes, content_type='application/pdf') + response['Content-Disposition'] = f'attachment; filename="{filename}"' + response['X-Certificate-Reference'] = reference_number + return response + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +@role_required(['acadadmin']) +def pointer_conversion_certificates(request): + """List previously issued pointer conversion certificates.""" + page_number = int(request.query_params.get('page', 1)) + page_size = min(int(request.query_params.get('page_size', 20)), 100) + search = (request.query_params.get('search') or '').strip() + + queryset = BonafideCertificate.objects.filter( + certificate_type=CERTIFICATE_TYPE + ).select_related('student__id__user', 'issued_by') + + queryset = _search_certificates(queryset, search) + paginator = Paginator(queryset, page_size) + page = paginator.get_page(page_number) + + results = [] + for cert in page.object_list: + student = cert.student + results.append({ + 'id': cert.pk, + 'reference_number': cert.reference_number or f'CERT-{cert.pk:04d}', + 'roll_number': student.id.user.username, + 'student_name': _student_name(student), + 'issued_on': cert.issued_at.strftime('%d.%m.%Y'), + 'issued_by': cert.issued_by.get_full_name() or cert.issued_by.username, + 'purpose': cert.purpose, + }) + + return Response({ + 'count': paginator.count, + 'page': page.number, + 'page_size': page_size, + 'total_pages': paginator.num_pages, + 'results': results, + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +@role_required(['acadadmin']) +def pointer_conversion_certificate_pdf(request, certificate_id): + cert = get_object_or_404( + BonafideCertificate.objects.select_related('student__id__user'), + pk=certificate_id, certificate_type=CERTIFICATE_TYPE, + ) + if not cert.pdf_content: + context = build_pointer_conversion_context(cert.student) + cpi_data = cert.fee_rows or {} + cpi_text = cpi_data.get('cpi') if isinstance(cpi_data, dict) else context['cpi_display'] + min_cpi = cpi_data.get('min_cpi') if isinstance(cpi_data, dict) else context['min_cpi'] + pdf_bytes = render_pointer_conversion_pdf( + context, cpi_text, min_cpi, cert.reference_number, cert.issued_at.date() + ).getvalue() + cert.pdf_content = pdf_bytes + cert.save(update_fields=['pdf_content']) + else: + pdf_bytes = bytes(cert.pdf_content) + + filename = f'{cert.student.id.user.username}_Pointer_Conversion_Certificate.pdf' + response = HttpResponse(pdf_bytes, content_type='application/pdf') + response['Content-Disposition'] = f'attachment; filename="{filename}"' + response['X-Certificate-Reference'] = cert.reference_number or f'CERT-{cert.pk:04d}' + return response diff --git a/FusionIIIT/applications/academic_procedures/api/urls.py b/FusionIIIT/applications/academic_procedures/api/urls.py index 8f808b2f8..146be6081 100644 --- a/FusionIIIT/applications/academic_procedures/api/urls.py +++ b/FusionIIIT/applications/academic_procedures/api/urls.py @@ -1,7 +1,8 @@ from django.conf.urls import url from . import views from . import (bonafide_certificate, demand_letter, fee_certificate, - fee_structure, fee_structure_certificate) + fee_structure, fee_structure_certificate, + pointer_conversion_certificate) from .. import views as procedures_views @@ -50,6 +51,18 @@ url(r'^acad/fee-structure-certificate/certificates/(?P\d+)/pdf/$', fee_structure_certificate.fee_structure_certificate_pdf, name='fee-structure-certificate-pdf-detail'), + url(r'^acad/pointer-conversion/student/$', + pointer_conversion_certificate.pointer_conversion_student, + name='pointer-conversion-student'), + url(r'^acad/pointer-conversion/pdf/$', + pointer_conversion_certificate.generate_pointer_conversion_pdf, + name='pointer-conversion-pdf'), + url(r'^acad/pointer-conversion/certificates/$', + pointer_conversion_certificate.pointer_conversion_certificates, + name='pointer-conversion-certificates'), + url(r'^acad/pointer-conversion/certificates/(?P\d+)/pdf/$', + pointer_conversion_certificate.pointer_conversion_certificate_pdf, + name='pointer-conversion-certificate-pdf-detail'), url(r'^acad/fee-structures/$', fee_structure.fee_structures, name='fee-structures'), url(r'^acad/fee-structures/template/$', diff --git a/FusionIIIT/applications/academic_procedures/migrations/0061_pointer_conversion_certificate_type.py b/FusionIIIT/applications/academic_procedures/migrations/0061_pointer_conversion_certificate_type.py new file mode 100644 index 000000000..914567429 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0061_pointer_conversion_certificate_type.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0060_demandletterbankaccounts'), + ] + + operations = [ + migrations.AlterField( + model_name='bonafidecertificate', + name='certificate_type', + field=models.CharField( + choices=[ + ('bonafide', 'Bonafide Certificate'), + ('fee', 'Paid / Unpaid Fee Certificate'), + ('demand', 'Demand Letter'), + ('fee_structure', 'Fee Structure Certificate'), + ('pointer_conv', 'Pointer Conversion Certificate'), + ], + db_index=True, + default='bonafide', + max_length=16, + ), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/models.py b/FusionIIIT/applications/academic_procedures/models.py index 68d1f1e7c..1e2dc0b10 100644 --- a/FusionIIIT/applications/academic_procedures/models.py +++ b/FusionIIIT/applications/academic_procedures/models.py @@ -387,6 +387,7 @@ class BonafideCertificate(models.Model): ('fee', 'Paid / Unpaid Fee Certificate'), ('demand', 'Demand Letter'), ('fee_structure', 'Fee Structure Certificate'), + ('pointer_conv', 'Pointer Conversion Certificate'), ) certificate_type = models.CharField( diff --git a/FusionIIIT/applications/academic_procedures/test_pointer_conversion_certificate.py b/FusionIIIT/applications/academic_procedures/test_pointer_conversion_certificate.py new file mode 100644 index 000000000..711ff9c77 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/test_pointer_conversion_certificate.py @@ -0,0 +1,121 @@ +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 pointer_conversion_certificate as pcc +from applications.academic_procedures.models import BonafideCertificate +from applications.globals.models import Designation, ExtraInfo, HoldsDesignation +from applications.programme_curriculum.models import Batch, Curriculum, Discipline, Programme + + +class PointerConversionCertificateTests(TestCase): + def setUp(self): + self.programme = Programme.objects.create( + category='UG', name='Bachelor of Technology', programme_begin_year=2026) + self.discipline = Discipline.objects.create( + name='Computer Science and Engineering', acronym='CSE') + self.curriculum = Curriculum.objects.create( + programme=self.programme, + name='B.Tech CSE 2026', + no_of_semester=8, + ) + self.batch = Batch.objects.create( + name='B.Tech', + discipline=self.discipline, + year=2026, + curriculum=self.curriculum, + ) + + self.student_user = User.objects.create_user( + username='21BCS032', + first_name='Arpan', + last_name='Karjee', + ) + self.student_extra = ExtraInfo.objects.create( + id='21BCS032', + user=self.student_user, + user_type='student', + sex='M', + user_status='PRESENT', + ) + self.student = Student.objects.create( + id=self.student_extra, + programme='B.Tech', + batch=2026, + batch_id=self.batch, + category='GEN', + father_name='Mr. Narayan Karjee', + curr_semester_no=6, + cpi=7.3, + ) + + self.admin = User.objects.create_user(username='certificate-admin') + designation = Designation.objects.create(name='acadadmin') + HoldsDesignation.objects.create( + user=self.admin, working=self.admin, designation=designation) + self.token = Token.objects.create(user=self.admin) + self.client = APIClient() + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') + + def test_student_context(self): + context = pcc.build_pointer_conversion_context(self.student) + self.assertTrue(context['is_ready']) + self.assertEqual(context['name'], 'Arpan Karjee') + self.assertEqual(context['roll_number'], '21BCS032') + self.assertEqual(context['salutation'], 'Mr.') + self.assertEqual(context['cpi_display'], '7.3') + self.assertEqual(context['min_cpi'], '5.0') + self.assertEqual(context['programme'], 'Bachelor of Technology') + self.assertEqual(context['programme_abbr'], 'B.Tech') + + def test_student_api(self): + response = self.client.get( + '/academic-procedures/api/acad/pointer-conversion/student/', + {'roll_number': '21BCS032'} + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['student']['name'], 'Arpan Karjee') + self.assertEqual(data['student']['cpi_display'], '7.3') + self.assertIn('reference_preview', data['certificate']) + + def test_generate_pdf_api(self): + response = self.client.post( + '/academic-procedures/api/acad/pointer-conversion/pdf/', + { + 'student_id': self.student.pk, + 'cpi_text': '7.3', + 'min_cpi': '5.0', + } + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'application/pdf') + self.assertIn('21BCS032_Pointer_Conversion_Certificate.pdf', response['Content-Disposition']) + + cert = BonafideCertificate.objects.filter( + certificate_type='pointer_conv', student=self.student + ).first() + self.assertIsNotNone(cert) + self.assertEqual(cert.fee_rows.get('cpi'), '7.3') + + def test_list_and_history_pdf_api(self): + # Generate one first + self.client.post( + '/academic-procedures/api/acad/pointer-conversion/pdf/', + {'student_id': self.student.pk} + ) + # Check list + response = self.client.get('/academic-procedures/api/acad/pointer-conversion/certificates/') + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['count'], 1) + cert_id = data['results'][0]['id'] + + # Check detail PDF + detail_response = self.client.get( + f'/academic-procedures/api/acad/pointer-conversion/certificates/{cert_id}/pdf/' + ) + self.assertEqual(detail_response.status_code, 200) + self.assertEqual(detail_response['Content-Type'], 'application/pdf')