diff --git a/FusionIIIT/Fusion/settings/development.py b/FusionIIIT/Fusion/settings/development.py index 65381434d..87836b129 100644 --- a/FusionIIIT/Fusion/settings/development.py +++ b/FusionIIIT/Fusion/settings/development.py @@ -15,7 +15,7 @@ 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', 'NAME': 'fusionlab', - 'HOST': os.environ.get("DB_HOST", default='localhost'), + 'HOST': os.environ.get("DB_HOST", default='127.0.0.1'), 'USER': 'fusion_admin', 'PASSWORD': 'hello123', } diff --git a/FusionIIIT/applications/academic_procedures/api/medium_of_instruction_certificate.py b/FusionIIIT/applications/academic_procedures/api/medium_of_instruction_certificate.py new file mode 100644 index 000000000..d008fc6d7 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/api/medium_of_instruction_certificate.py @@ -0,0 +1,337 @@ +import re +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.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.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT, TA_RIGHT +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.units import cm +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle +from rest_framework import status +from rest_framework.authentication import TokenAuthentication +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from applications.academic_information.models import Student +from applications.academic_procedures.models import BonafideCertificate +from applications.globals.decorators import role_required + +from .bonafide_certificate import ( + _certificate_reference, + _father_name, + _search_certificates, + _student_name, + _student_queryset, + build_certificate_context, + superscript_ordinal, +) + +CERTIFICATE_TYPE = 'medium_of_instruction' + + +def render_medium_of_instruction_pdf(context, reference_number, issued_on): + output = BytesIO() + document = SimpleDocTemplate( + output, + pagesize=A4, + topMargin=2.5 * cm, + rightMargin=1.5 * cm, + bottomMargin=2.5 * cm, + leftMargin=1.5 * cm, + title='Medium of Instruction 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=22, + alignment=TA_JUSTIFY, + ) + 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)}.' + ) + second_text = ( + 'Institute provides education mode of instruction to the students in English only.' + ) + + 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.9 * cm), + Paragraph(second_text, body_style), + Spacer(1, 1.65 * cm), + Paragraph(f'({escape(settings.BONAFIDE_SIGNATORY_NAME)})', signature_style), + ] + + document.build(story) + output.seek(0) + return output + + +def _certificate_filename(certificate): + roll_number = certificate.student.id.user.username + safe_roll_number = re.sub(r'[^A-Za-z0-9_-]', '_', roll_number) + return f'{safe_roll_number}_Medium_of_Instruction_Certificate_{certificate.pk:03d}.pdf' + + +def _certificate_pdf(certificate): + if certificate.pdf_content: + return bytes(certificate.pdf_content) + + context = build_certificate_context(certificate.student) + if not context['is_ready']: + return None + document = render_medium_of_instruction_pdf( + context, + _certificate_reference(certificate), + certificate.issued_at.date(), + ) + return document.getvalue() + + +@api_view(['GET']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def medium_of_instruction_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) + 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, + '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_medium_of_instruction_pdf(request): + student_id = request.data.get('student_id') + if not student_id: + return Response({'error': 'student_id is required.'}, status=status.HTTP_400_BAD_REQUEST) + + student = get_object_or_404(_student_queryset(), pk=student_id) + context = build_certificate_context(student) + if not context['is_ready']: + return Response( + {'error': 'Student data is incomplete.', 'details': context['validation_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='Medium of Instruction', + custom_purpose='', + 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 + certificate.save(update_fields=['reference_number']) + document = render_medium_of_instruction_pdf( + context, + reference_number, + issued_on, + ) + document_bytes = document.getvalue() + certificate.pdf_content = document_bytes + certificate.save(update_fields=['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 medium_of_instruction_certificates(request): + search = (request.query_params.get('search') or '').strip() + try: + page_size = int(request.query_params.get('page_size', 20)) + except (TypeError, ValueError): + page_size = 20 + page_size = min(max(page_size, 1), 100) + + queryset = BonafideCertificate.objects.filter( + certificate_type=CERTIFICATE_TYPE, + ).select_related( + 'student__id__user', + ).order_by('-issued_at', '-pk') + queryset = _search_certificates(queryset, search).distinct() + page = Paginator(queryset, page_size).get_page( + request.query_params.get('page', 1) + ) + + results = [] + for certificate in page.object_list: + results.append({ + 'id': certificate.pk, + 'serial_number': f'{certificate.pk:03d}', + 'reference_number': _certificate_reference(certificate), + 'roll_number': certificate.student.id.user.username, + 'name': _student_name(certificate.student), + 'purpose': 'Medium of Instruction', + 'issued_on': certificate.issued_at.strftime('%d.%m.%Y'), + }) + + return Response({ + 'results': results, + 'count': page.paginator.count, + 'page': page.number, + 'page_size': page_size, + 'total_pages': page.paginator.num_pages, + }) + + +@api_view(['GET']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def medium_of_instruction_certificate_pdf(request, certificate_id): + certificate = get_object_or_404( + BonafideCertificate.objects.filter( + certificate_type=CERTIFICATE_TYPE, + ).select_related( + 'student__id__user', + 'student__id__department', + 'student__batch_id__discipline', + 'student__batch_id__curriculum__programme', + ), + pk=certificate_id, + ) + document_bytes = _certificate_pdf(certificate) + if document_bytes is None: + return Response( + { + 'error': 'The certificate cannot be rendered because the ' + 'student record is now incomplete.' + }, + status=status.HTTP_409_CONFLICT, + ) + + disposition = ( + 'attachment' + if request.query_params.get('download') in {'1', 'true', 'True'} + else 'inline' + ) + response = HttpResponse(document_bytes, content_type='application/pdf') + response['Content-Disposition'] = ( + f'{disposition}; filename="{_certificate_filename(certificate)}"' + ) + response['X-Certificate-Reference'] = _certificate_reference(certificate) + return response diff --git a/FusionIIIT/applications/academic_procedures/api/urls.py b/FusionIIIT/applications/academic_procedures/api/urls.py index 8f808b2f8..f60ab49f3 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, + medium_of_instruction_certificate) from .. import views as procedures_views @@ -20,6 +21,18 @@ url(r'^acad/bonafide/certificates/(?P\d+)/pdf/$', bonafide_certificate.bonafide_certificate_pdf, name='bonafide-certificate-pdf'), + url(r'^acad/medium-of-instruction/student/$', + medium_of_instruction_certificate.medium_of_instruction_student, + name='medium-of-instruction-student'), + url(r'^acad/medium-of-instruction/pdf/$', + medium_of_instruction_certificate.generate_medium_of_instruction_pdf, + name='medium-of-instruction-pdf'), + url(r'^acad/medium-of-instruction/certificates/$', + medium_of_instruction_certificate.medium_of_instruction_certificates, + name='medium-of-instruction-certificates'), + url(r'^acad/medium-of-instruction/certificates/(?P\d+)/pdf/$', + medium_of_instruction_certificate.medium_of_instruction_certificate_pdf, + name='medium-of-instruction-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, diff --git a/FusionIIIT/applications/academic_procedures/migrations/0061_alter_bonafidecertificate_certificate_type.py b/FusionIIIT/applications/academic_procedures/migrations/0061_alter_bonafidecertificate_certificate_type.py new file mode 100644 index 000000000..dcf050bf0 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0061_alter_bonafidecertificate_certificate_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2026-10-12 09:49 + +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'), ('medium_of_instruction', 'Medium of Instruction Certificate')], db_index=True, default='bonafide', max_length=32), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/models.py b/FusionIIIT/applications/academic_procedures/models.py index 68d1f1e7c..347fd3283 100644 --- a/FusionIIIT/applications/academic_procedures/models.py +++ b/FusionIIIT/applications/academic_procedures/models.py @@ -387,10 +387,11 @@ class BonafideCertificate(models.Model): ('fee', 'Paid / Unpaid Fee Certificate'), ('demand', 'Demand Letter'), ('fee_structure', 'Fee Structure Certificate'), + ('medium_of_instruction', 'Medium of Instruction Certificate'), ) certificate_type = models.CharField( - max_length=16, choices=TYPE_CHOICES, default='bonafide', db_index=True) + max_length=32, 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) diff --git a/FusionIIIT/applications/globals/views.py b/FusionIIIT/applications/globals/views.py index d8fe9d05b..43a1b35fe 100644 --- a/FusionIIIT/applications/globals/views.py +++ b/FusionIIIT/applications/globals/views.py @@ -1,4 +1,4 @@ -from audioop import reverse +from django.urls import reverse import json from django.contrib.auth import logout diff --git a/requirements.txt b/requirements.txt index 21a3bfdec..be105eb54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,77 +1,79 @@ -amqp==5.0.2 -asgiref==3.3.1 -attrs==20.3.0 -beautifulsoup4==4.9.3 -billiard==3.6.3.0 -Brotli==1.0.9 -bs4==0.0.1 -celery==5.0.5 -certifi==2020.12.5 -cffi==1.14.4 -chardet==4.0.0 -click==7.1.2 -click-didyoumean==0.0.3 -click-plugins==1.1.1 -click-repl==0.1.6 -cryptography==36.0.2 -defusedxml==0.6.0 -Django==3.1.5 -django-allauth==0.44.0 -django-cleanup==5.1.0 -django-cors-headers==3.7.0 -django-debug-toolbar==3.2.4 -django-extensions==3.1.1 -django-filter==2.4.0 -django-markdown-deux==1.0.5 -django-model-utils==4.1.1 -django-notifications-hq==1.6.0 -django-pagedown==2.2.0 -djangorestframework==3.12.2 -django-semanticui-forms==1.6.5 -django-unused-media==0.2.2 -future==0.18.2 +amqp>=5.0.2 +asgiref>=3.3.1 +attrs>=20.3.0 +beautifulsoup4>=4.9.3 +billiard>=3.6.3.0 +Brotli>=1.0.9 +bs4>=0.0.1 +celery>=5.0.5 +certifi>=2020.12.5 +cffi>=1.14.4 +chardet>=4.0.0 +click>=7.1.2 +click-didyoumean>=0.0.3 +click-plugins>=1.1.1 +click-repl>=0.1.6 +cryptography>=36.0.2 +defusedxml>=0.6.0 +Django>=3.2.0,<4.0.0 +django-allauth>=0.44.0,<0.56.0 +django-cleanup>=5.1.0 +django-cors-headers>=3.7.0,<4.0.0 +django-debug-toolbar>=3.2.4,<4.0.0 +django-extensions>=3.1.1,<3.2.0 +django-filter>=2.4.0,<24.0.0 +django-markdown-deux>=1.0.5 +django-model-utils>=4.1.1 +django-notifications-hq>=1.6.0 +django-pagedown>=2.2.0 +djangorestframework>=3.12.2,<3.15.0 +django-semanticui-forms>=1.6.5 +django-unused-media>=0.2.2 +future>=0.18.2 # gunicorn -html-text==0.5.2 -html5lib==1.1 -idna==2.10 -ipython==8.2.0 -ipython-genutils==0.2.0 -jsonfield==3.1.0 -jsonschema==3.2.0 -kombu==5.0.2 -lxml==4.8.0 -markdown2==2.4.2 -numpy==1.22.3 -oauthlib==3.1.0 -openpyxl==3.0.7 -Pillow==8.1.0 -prompt-toolkit==3.0.10 -psycopg2-binary==2.8.6 -pycparser==2.20 -PyJWT==2.6.0 -PyPDF2==1.26.0 -pyrsistent==0.17.3 -python-bidi==0.4.2 -python-dateutil==2.9.0 -python3-openid==3.2.0 -pytz==2020.5 -reportlab==3.5.59 -requests==2.25.1 -requests-oauthlib==1.3.0 -six==1.15.0 -soupsieve==2.1 -sqlparse==0.4.2 -swapper==1.1.2.post1 -urllib3==1.26.8 -vine==5.0.0 -wcwidth==0.2.5 -webencodings==0.5.1 -whitenoise==5.2.0 -xhtml2pdf==0.2.5 -xlrd==2.0.1 -XlsxWriter==1.3.7 -xlwt==1.3.0 -django-crontab==0.7.1 -zipfile2==0.0.12 -pandas==2.0.3 -PyYAML==5.4.1 # placement_cell spec-driven test suite +html-text>=0.5.2 +html5lib>=1.1 +idna>=2.10 +ipython>=8.2.0 +ipython-genutils>=0.2.0 +jsonfield>=3.1.0,<3.2.0 +jsonschema>=3.2.0 +kombu>=5.0.2 +lxml>=4.8.0 +markdown2>=2.4.2 +numpy>=1.22.3 +oauthlib>=3.1.0 +openpyxl>=3.0.7 +Pillow>=8.1.0 +prompt-toolkit>=3.0.10 +psycopg2-binary>=2.8.6 +pycparser>=2.20 +PyJWT>=2.6.0 +PyPDF2>=1.26.0 +pyrsistent>=0.17.3 +python-bidi>=0.4.2 +python-dateutil>=2.9.0 +python3-openid>=3.2.0 +pytz>=2020.5 +reportlab>=3.5.59 +requests>=2.25.1 +requests-oauthlib>=1.3.0 +six>=1.15.0 +soupsieve>=2.1 +sqlparse>=0.4.2 +swapper>=1.1.2 +urllib3>=1.26.8 +vine>=5.0.0 +wcwidth>=0.2.5 +webencodings>=0.5.1 +whitenoise>=5.2.0 +xhtml2pdf>=0.2.5 +xlrd>=2.0.1 +XlsxWriter>=1.3.7 +xlwt>=1.3.0 +django-crontab>=0.7.1 +zipfile2>=0.0.12 +pandas>=2.0.3 +PyYAML>=5.4.1 + +