diff --git a/FusionIIIT/Fusion/settings/common.py b/FusionIIIT/Fusion/settings/common.py index bc97f1548..878c39aea 100644 --- a/FusionIIIT/Fusion/settings/common.py +++ b/FusionIIIT/Fusion/settings/common.py @@ -85,10 +85,20 @@ CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' CELERY_TIMEZONE = 'Asia/Calcutta' + +# Base URL of the React frontend, used to build links (e.g. emailed thesis +# examiner invitation/review links) that must point at frontend pages rather +# than raw backend API endpoints. +FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:5173') + CELERY_BEAT_SCHEDULE = { 'leave-migration-task': { 'task': 'applications.leave.tasks.execute_leave_migrations', 'schedule': crontab(minute='1', hour='0') + }, + 'phd-thesis-review-invitations-task': { + 'task': 'applications.academic_procedures.tasks.process_review_invitations', + 'schedule': crontab(minute='0', hour='2') } } diff --git a/FusionIIIT/Fusion/settings/development.py b/FusionIIIT/Fusion/settings/development.py index 63587a11f..65381434d 100644 --- a/FusionIIIT/Fusion/settings/development.py +++ b/FusionIIIT/Fusion/settings/development.py @@ -6,6 +6,11 @@ ALLOWED_HOSTS = ['*'] +# Local dev has no real SMTP credentials (EMAIL_HOST_PASSWORD is only set in +# production.py from the environment), so outgoing mail is printed to the +# runserver console instead of actually being sent. +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', diff --git a/FusionIIIT/applications/academic_information/api/views.py b/FusionIIIT/applications/academic_information/api/views.py index 6cfd4ec95..e29ea49e3 100644 --- a/FusionIIIT/applications/academic_information/api/views.py +++ b/FusionIIIT/applications/academic_information/api/views.py @@ -698,7 +698,9 @@ def generate_xlsheet_api(request): if programme_type.upper() == 'UG': sql += " AND s.programme IN ('B.Tech', 'B.Des')" elif programme_type.upper() == 'PG': - sql += " AND s.programme IN ('M.Tech', 'M.Des', 'PhD')" + sql += " AND s.programme IN ('M.Tech', 'M.Des')" + elif programme_type.upper() == 'PHD': + sql += " AND s.programme = 'PhD'" else: sql += " AND s.programme = %s" params.append(programme_type) diff --git a/FusionIIIT/applications/academic_information/migrations/0002_thesis_registration_models.py b/FusionIIIT/applications/academic_information/migrations/0002_thesis_registration_models.py new file mode 100644 index 000000000..aa4999df1 --- /dev/null +++ b/FusionIIIT/applications/academic_information/migrations/0002_thesis_registration_models.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-06 11:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='student', + name='specialization', + field=models.CharField(choices=[('Power and Control', 'Power and Control'), ('Power & Control', 'Power & Control'), ('Microwave and Communication Engineering', 'Microwave and Communication Engineering'), ('Communication and Signal Processing', 'Communication and Signal Processing'), ('Micro-nano Electronics', 'Micro-nano Electronics'), ('Nanoelectronics and VLSI Design', 'Nanoelectronics and VLSI Design'), ('CAD/CAM', 'CAD/CAM'), ('Design', 'Design'), ('Manufacturing', 'Manufacturing'), ('Manufacturing and Automation', 'Manufacturing and Automation'), ('CSE', 'CSE'), ('AI & ML', 'AI & ML'), ('Data Science', 'Data Science'), ('Mechatronics', 'Mechatronics'), ('MDes', 'MDes'), ('None', 'None'), ('', 'No Specialization')], default='', max_length=40, null=True), + ), + ] diff --git a/FusionIIIT/applications/academic_information/migrations/0003_merge_20260314_1604.py b/FusionIIIT/applications/academic_information/migrations/0003_merge_20260314_1604.py new file mode 100644 index 000000000..28931dbae --- /dev/null +++ b/FusionIIIT/applications/academic_information/migrations/0003_merge_20260314_1604.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-03-14 16:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0002_thesis_registration_models'), + ('academic_information', '0002_auto_20260210_1820'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/academic_information/views.py b/FusionIIIT/applications/academic_information/views.py index 0a3cfeffd..faabdf77a 100755 --- a/FusionIIIT/applications/academic_information/views.py +++ b/FusionIIIT/applications/academic_information/views.py @@ -843,7 +843,6 @@ def update_calendar(request): #Generate Attendance Sheet -@login_required(login_url='/accounts/login') def sem_for_generate_sheet(): """ This function generates semester grade sheet @@ -1426,7 +1425,6 @@ def add_new_profile (request): return render(request, "ais/ais.html", context) -@login_required(login_url='/accounts/login') def get_faculty_list(): """ to get faculty list from database diff --git a/FusionIIIT/applications/academic_procedures/admin.py b/FusionIIIT/applications/academic_procedures/admin.py index b3d30260c..a4d3d9df7 100644 --- a/FusionIIIT/applications/academic_procedures/admin.py +++ b/FusionIIIT/applications/academic_procedures/admin.py @@ -1,14 +1,183 @@ from django.contrib import admin +from django.utils.html import format_html +from django.urls import reverse +from django.utils import timezone from .models import (BranchChange, CoursesMtech, FeePayments, FinalRegistration, InitialRegistration,StudentRegistrationChecks, MinimumCredits, Register, Thesis, CourseRequested, ThesisTopicProcess, FeePayment, TeachingCreditRegistration, - SemesterMarks, MarkSubmissionCheck,Dues,MTechGraduateSeminarReport,PhDProgressExamination,AssistantshipClaim,MessDue,Assistantship_status, course_registration) + SemesterMarks, MarkSubmissionCheck,Dues,MTechGraduateSeminarReport,PhDProgressExamination,AssistantshipClaim,MessDue,Assistantship_status, course_registration, + ThesisSubmission, ReviewInvitation) class RegisterAdmin(admin.ModelAdmin): model = Register search_fields = ('curr_id__course_code',) + +@admin.register(ThesisSubmission) +class ThesisSubmissionAdmin(admin.ModelAdmin): + list_display = ['id', 'get_thesis_title', 'get_student', 'status', 'submitted_at', 'supervisor_approval', 'director_approval'] + list_filter = ['status', 'submitted_at', 'supervisor_approved_at', 'director_approved_at'] + search_fields = ['thesis__research_theme', 'thesis__student__id__user__username', 'thesis__student__id__user__first_name', 'thesis__student__id__user__last_name'] + readonly_fields = ['file_token', 'submitted_at', 'updated_at', 'get_synopsis_link', 'get_report_link'] + date_hierarchy = 'submitted_at' + ordering = ['-submitted_at'] + + fieldsets = ( + ('Basic Information', { + 'fields': ('thesis', 'status', 'file_token') + }), + ('Files', { + 'fields': ('synopsis', 'get_synopsis_link', 'thesis_report', 'get_report_link') + }), + ('Approvals', { + 'fields': ('supervisor', 'supervisor_approved_at', 'director', 'director_approved_at') + }), + ('Timestamps', { + 'fields': ('submitted_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def get_thesis_title(self, obj): + return obj.thesis.research_theme + get_thesis_title.short_description = 'Thesis Title' + get_thesis_title.admin_order_field = 'thesis__research_theme' + + def get_student(self, obj): + if obj.thesis and obj.thesis.student: + return obj.thesis.student.id.user.get_full_name() or obj.thesis.student.id.user.username + return '-' + get_student.short_description = 'Student' + + def supervisor_approval(self, obj): + if obj.supervisor_approved_at: + return format_html('✓ {}', obj.supervisor_approved_at.strftime('%Y-%m-%d')) + return format_html('Pending') + supervisor_approval.short_description = 'Supervisor' + + def director_approval(self, obj): + if obj.director_approved_at: + return format_html('✓ {}', obj.director_approved_at.strftime('%Y-%m-%d')) + return format_html('Pending') + director_approval.short_description = 'Director' + + def get_synopsis_link(self, obj): + if obj.synopsis: + return format_html('View Synopsis', obj.synopsis.url) + return '-' + get_synopsis_link.short_description = 'Synopsis Link' + + def get_report_link(self, obj): + if obj.thesis_report: + return format_html('View Report', obj.thesis_report.url) + return '-' + get_report_link.short_description = 'Report Link' + + +@admin.register(ReviewInvitation) +class ReviewInvitationAdmin(admin.ModelAdmin): + list_display = ['id', 'prof_name', 'prof_email', 'get_thesis', 'status', 'priority', 'last_sent', 'expires_at', 'is_expired_badge'] + list_filter = ['status', 'priority', 'created_at', 'expires_at'] + search_fields = ['prof_name', 'prof_email', 'submission__thesis__research_theme'] + readonly_fields = ['token', 'created_at', 'updated_at', 'get_accept_url', 'get_reject_url', 'get_review_url'] + date_hierarchy = 'created_at' + ordering = ['submission', 'priority'] + actions = ['resend_invitation', 'mark_expired', 'send_review_form'] + + fieldsets = ( + ('Professor Information', { + 'fields': ('prof_name', 'prof_position', 'prof_email', 'prof_phone', 'prof_address', 'prof_time_ranking') + }), + ('Invitation Details', { + 'fields': ('submission', 'priority', 'token', 'status') + }), + ('Action URLs', { + 'fields': ('get_accept_url', 'get_reject_url', 'get_review_url'), + 'classes': ('collapse',) + }), + ('Email Tracking', { + 'fields': ('last_sent', 'review_form_sent', 'expires_at') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def get_thesis(self, obj): + return obj.submission.thesis.research_theme + get_thesis.short_description = 'Thesis' + get_thesis.admin_order_field = 'submission__thesis__research_theme' + + def is_expired_badge(self, obj): + if obj.is_expired(): + return format_html('EXPIRED') + elif obj.expires_at: + days_left = (obj.expires_at - timezone.now()).days + if days_left <= 7: + return format_html('{} days left', days_left) + return format_html('{} days left', days_left) + return '-' + is_expired_badge.short_description = 'Expiry Status' + + def get_accept_url(self, obj): + from django.conf import settings + url = getattr(settings, 'SITE_URL', 'http://localhost:8000') + url += reverse('procedures:invitation_action', args=[obj.token, 'accept']) + return format_html('{}', url, url) + get_accept_url.short_description = 'Accept URL' + + def get_reject_url(self, obj): + from django.conf import settings + url = getattr(settings, 'SITE_URL', 'http://localhost:8000') + url += reverse('procedures:invitation_action', args=[obj.token, 'reject']) + return format_html('{}', url, url) + get_reject_url.short_description = 'Reject URL' + + def get_review_url(self, obj): + from django.conf import settings + url = getattr(settings, 'SITE_URL', 'http://localhost:8000') + url += reverse('procedures:review_detail', args=[obj.token]) + return format_html('{}', url, url) + get_review_url.short_description = 'Review Form URL' + + def resend_invitation(self, request, queryset): + from .utils import send_invitation_email + count = 0 + for inv in queryset: + if inv.status == 'pending' and not inv.is_expired(): + try: + send_invitation_email(inv) + inv.last_sent = timezone.now() + inv.save(update_fields=['last_sent']) + count += 1 + except Exception as e: + self.message_user(request, f"Failed to send to {inv.prof_email}: {str(e)}", level='error') + self.message_user(request, f"Successfully resent {count} invitation(s)") + resend_invitation.short_description = "Resend invitation email to selected reviewers" + + def mark_expired(self, request, queryset): + count = queryset.filter(status='pending').update(status='expired') + self.message_user(request, f"Marked {count} invitation(s) as expired") + mark_expired.short_description = "Mark selected invitations as expired" + + def send_review_form(self, request, queryset): + from .utils import send_review_form_email + count = 0 + for inv in queryset: + if inv.status == 'accepted': + try: + send_review_form_email(inv) + inv.review_form_sent = timezone.now() + inv.save(update_fields=['review_form_sent']) + count += 1 + except Exception as e: + self.message_user(request, f"Failed to send to {inv.prof_email}: {str(e)}", level='error') + self.message_user(request, f"Successfully sent review form to {count} reviewer(s)") + send_review_form.short_description = "Send review form email to accepted reviewers" + + admin.site.register(Thesis) admin.site.register(Register,RegisterAdmin) admin.site.register(BranchChange) @@ -31,3 +200,4 @@ class RegisterAdmin(admin.ModelAdmin): admin.site.register(FinalRegistration) admin.site.register(StudentRegistrationChecks) admin.site.register(course_registration) + diff --git a/FusionIIIT/applications/academic_procedures/api/urls.py b/FusionIIIT/applications/academic_procedures/api/urls.py index 5750cd216..c2fa5d1b4 100644 --- a/FusionIIIT/applications/academic_procedures/api/urls.py +++ b/FusionIIIT/applications/academic_procedures/api/urls.py @@ -132,4 +132,91 @@ # pg TA url(r'^ta/stipends/$', views.ta_stipends), + + # ======================================================================== + # PhD-SPECIFIC URLS (Added for PhD student management) + # ======================================================================== + + # PhD Thesis Registration endpoints + + # Student endpoints + url(r'^stu/thesis/$', views.student_thesis_api, name='student-thesis'), + url(r'^stu/thesis/download/$', views.student_download_pdf_api, name='student-thesis-download'), + + # Faculty list for dropdowns + url(r'^faculty/$', views.faculty_list_api, name='faculty-list'), + + # Supervisor endpoints + url(r'^supervisor/dashboard/$', views.supervisor_thesis_topic_dashboard, name='supervisor-dashboard'), + url(r'^supervisor/thesis/(?P\d+)/review/$', views.supervisor_review_api, name='supervisor-thesis-review'), + + # HOD endpoints + url(r'^hod/dashboard/$', views.hod_dashboard, name='hod-dashboard'), + url(r'^hod/thesis/(?P\d+)/review/$', views.hod_review_api, name='hod-thesis-review'), + + # Dean endpoints + url(r'^dean/dashboard/$', views.dean_dashboard, name='dean-dashboard'), + url(r'^dean/thesis/(?P\d+)/review/$', views.dean_review_api, name='dean-thesis-review'), + url(r'^dean/thesis/(?P\d+)/generate/$', views.dean_generate_pdf_api, name='dean-thesis-generate'), + + # PhD Seminar endpoints + + # Student + url(r'^seminar-reports/$', views.list_reports), + url(r'^seminar-reports/create/(?P\d+)/$', views.create_report), + url(r'^seminar-reports/(?P\d+)/$', views.detail_report), + + # RPC + url(r'^seminar-reports/list/$', views.rpc_seminar_list), + url(r'^seminar-reports/(?P\d+)/rpc-detail/$', views.rpc_detail), + url(r'^seminar-reports/(?P\d+)/rpc-consent/$', views.rpc_consent), + url(r'^seminar-reports/(?P\d+)/rpc-finalize/$', views.rpc_finalize), + + # ======================================================================== + # Thesis Slot Semester-Level Registration (Enrollment) + # ======================================================================== + # Student + url(r'^stu/thesis-enrollment/$', views.student_thesis_enrollment_api, name='student-thesis-enrollment'), + # Acad Admin + url(r'^acadadmin/thesis-enrollments/$', views.admin_thesis_enrollment_list, name='admin-thesis-enrollment-list'), + url(r'^acadadmin/thesis-enrollments/verify/$', views.admin_verify_enrollments, name='admin-verify-enrollments'), + url(r'^acadadmin/thesis-enrollments/reject/$', views.admin_reject_enrollments, name='admin-reject-enrollments'), + + # PhD Thesis Evaluation (block-based S/X grades) + # Supervisor — all blocks for a student are graded and submitted together + url(r'^supervisor/thesis-grades/$', views.supervisor_thesis_grades, name='supervisor-thesis-grades'), + # All blocks comprehensive upload + url(r'^supervisor/thesis-grades-all-template/$', views.supervisor_download_all_thesis_grades_template, name='supervisor-thesis-grades-all-template'), + url(r'^supervisor/thesis-grades-all/upload/$', views.supervisor_upload_all_thesis_grades, name='supervisor-thesis-grades-all-upload'), + url(r'^supervisor/thesis-grades-all/bulk-submit/$', views.supervisor_bulk_submit_all_thesis_grades, name='supervisor-thesis-grades-all-bulk-submit'), + # Acad Admin + url(r'^acadadmin/thesis-grades/$', views.admin_thesis_grades_list, name='admin-thesis-grades-list'), + url(r'^acadadmin/thesis-grades/verify/$', views.admin_verify_thesis_grades, name='admin-thesis-grades-verify'), + url(r'^acadadmin/thesis-grades/announce/$', views.admin_announce_thesis_grades, name='admin-thesis-grades-announce'), + + # PhD Thesis Submission + + # Student + url(r'^thesis/submit/$', views.thesis_submit, name='thesis_submit'), + + # Supervisor + url(r'^thesis/supervisor-dashboard/$', views.supervisor_dashboard, name='supervisor_dashboard'), + url(r'^thesis/submission-detail/(?P\d+)/$', views.supervisor_submission_detail, name='supervisor_submission_detail'), + url(r'^thesis/supervisor-assign/$', views.supervisor_assign, name='supervisor_assign'), + + # Dean (panel approval + invitation dispatch) + url(r'^thesis/dean-dashboard/$', views.dean_panel_dashboard, name='dean_panel_dashboard'), + url(r'^thesis/dean-panel-approve/$', views.dean_panel_approve, name='dean_panel_approve'), + url(r'^thesis/dean-send-invitations/$', views.dean_send_invitations, name='dean_send_invitations'), + + # Director + url(r'^thesis/director-dashboard/$', views.director_dashboard, name='director_dashboard'), + url(r'^thesis/director-approve/$', views.director_approve, name='director_approve'), + + # Professor Invitation (External reviewers) + url(r'^invitation/(?P[0-9a-f-]+)/(?Paccept|reject)/$', + views.invitation_action, name='invitation_action'), + + # Review Form (External reviewers) + url(r'^review/(?P[0-9a-f-]+)/$', views.review_detail, name='review_detail'), ] \ No newline at end of file diff --git a/FusionIIIT/applications/academic_procedures/api/views.py b/FusionIIIT/applications/academic_procedures/api/views.py index 7ad04ed03..20b8e10a7 100644 --- a/FusionIIIT/applications/academic_procedures/api/views.py +++ b/FusionIIIT/applications/academic_procedures/api/views.py @@ -2,10 +2,12 @@ import random import logging import traceback +import re from collections import defaultdict, deque, OrderedDict from functools import wraps from datetime import date from django.utils import timezone +from django.conf import settings logger = logging.getLogger(__name__) from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404, redirect @@ -1028,6 +1030,7 @@ def faculty_assigned_courses(request): @api_view(['POST']) @permission_classes([IsAuthenticated]) +@role_required(['student']) def get_next_sem_courses(request): try: next_sem = request.data.get('next_sem') @@ -1267,9 +1270,35 @@ def verify_course(request): # lists for selects (no serializers) course_list = list(Courses.objects.values("id", "code", "name", "credit")) - semester_list = list( - Semester.objects.filter(curriculum=curr).values("id", "semester_no") - ) + + # For PhD students, show only current semester + next semester (no summer terms) + batch_name = student.batch_id.name if student.batch_id else "" + is_phd = batch_name.upper().startswith('PHD') + + if is_phd: + current_sem = student.curr_semester_no + all_semesters = Semester.objects.filter( + curriculum=curr + ).exclude( + semester_no__in=[4, 6, 8, 10, 12] + ).order_by('semester_no') + + phd_semester_list = [] + for sem in all_semesters: + if sem.semester_no == current_sem or sem.semester_no == current_sem + 1: + phd_semester_list.append({ + "id": sem.id, + "semester_no": sem.semester_no + }) + + semester_list = phd_semester_list if phd_semester_list else list( + Semester.objects.filter(curriculum=curr).values("id", "semester_no") + ) + else: + semester_list = list( + Semester.objects.filter(curriculum=curr).values("id", "semester_no") + ) + courseslot_list = list( CourseSlot.objects.filter(semester__in=[s["id"] for s in semester_list]).values("id", "name") ) @@ -2056,6 +2085,7 @@ def allot_courses(request): sheet = book.sheet_by_index(0) checks, pre_regs, final_regs, course_regs = [], [], [], [] + row_errors = [] seen = set() for i in range(1, sheet.nrows): @@ -2102,14 +2132,39 @@ def allot_courses(request): semester_type = sem_type )) except Exception as e: - pass # Handle error silently or log it + row_errors.append({ + "row": i + 1, + "roll_no": str(sheet.cell_value(i, 0)).strip() if sheet.ncols > 0 else "", + "slot": str(sheet.cell_value(i, 1)).strip() if sheet.ncols > 1 else "", + "course_code": str(sheet.cell_value(i, 2)).strip() if sheet.ncols > 2 else "", + "error": str(e) + }) + + inserted = len(course_regs) + if inserted == 0: + return Response( + { + 'error': 'No valid rows were found in the uploaded file.', + 'failed_rows': row_errors[:25], + 'failed_rows_count': len(row_errors) + }, + status=status.HTTP_400_BAD_REQUEST + ) StudentRegistrationChecks.objects.bulk_create(checks, ignore_conflicts=True) InitialRegistration.objects.bulk_create(pre_regs, ignore_conflicts=True) FinalRegistration.objects.bulk_create(final_regs, ignore_conflicts=True) course_registration.objects.bulk_create(course_regs, ignore_conflicts=True) - return Response({'message': 'Successfully uploaded!'}) + if row_errors: + return Response({ + 'message': 'Upload completed with partial success.', + 'inserted_rows': inserted, + 'failed_rows_count': len(row_errors), + 'failed_rows': row_errors[:25] + }, status=status.HTTP_207_MULTI_STATUS) + + return Response({'message': 'Successfully uploaded!', 'inserted_rows': inserted}) except Batch.DoesNotExist: return Response({'error': 'Invalid batch id.'}, status=status.HTTP_400_BAD_REQUEST) except Semester.DoesNotExist: @@ -2136,8 +2191,22 @@ def student_next_sem_courses(request): return Response({"error": "User is not a student"}, status=status.HTTP_403_FORBIDDEN) # 403 Forbidden - DRF style obj = Student.objects.select_related('id', 'id__user', 'id__department').get(id=user_details.id) + + # Check if PhD student - they don't have semester-based courses like UG/PG + is_phd_student = obj.programme and obj.programme.upper() == 'PHD' + if is_phd_student: + return Response({ + "courses_list": [], + "message": "PhD students don't follow semester-based course structure" + }, status=status.HTTP_200_OK) + batch = obj.batch_id + if not batch: + return Response({"error": "Student batch not found"}, status=status.HTTP_404_NOT_FOUND) + curr_id = batch.curriculum + if not curr_id: + return Response({"error": "Curriculum not found for student batch"}, status=status.HTTP_404_NOT_FOUND) try: semester_no = obj.curr_semester_no @@ -2164,6 +2233,16 @@ def course_registration_view(request): user_details = current_user.extrainfo student = Student.objects.get(id=user_details) + # Check if PhD student - they don't have course registrations like UG/PG + is_phd_student = student.programme and student.programme.upper() == 'PHD' + if is_phd_student: + return Response({ + "reg_data": [], + "sem_no": 1, + "semester_type": "Odd Semester", + "message": "PhD students don't follow traditional course registration" + }, status=status.HTTP_200_OK) + semester_no = request.query_params.get('semester', student.curr_semester_no) semester_type = request.query_params.get('semester_type', 'Even Semester' if student.curr_semester_no%2==0 else 'Odd Semester') try: @@ -3767,8 +3846,24 @@ def upload_excel_replacement(request): } def check_role(request, required_role): - role = request.query_params.get('role') if request.method=='GET' else request.data.get('role') - return role == required_role + # Authorize from the user's real designation, never a client-supplied role + from django.db.models import Q + user = request.user + if not getattr(user, "is_authenticated", False): + return False + held = HoldsDesignation.objects.filter(Q(working=user) | Q(user=user)) + if required_role == 'hod': + return held.filter(designation__name__istartswith='HOD').exists() + if required_role == 'faculty': + return ( + Faculty.objects.filter(id__user=user).exists() + or held.filter( + designation__name__in=[ + 'Professor', 'Associate Professor', 'Assistant Professor', + ] + ).exists() + ) + return held.filter(designation__name=required_role).exists() def get_allowed_specs(user): """ @@ -3941,7 +4036,11 @@ def hod_approve(request, sid): def faculty_assignments(request): # if not check_role(request,'faculty'): # return Response({'error':'role=faculty required'}, status=status.HTTP_403_FORBIDDEN) - qs = Assignment.objects.filter(faculty__id__username='skjain') + try: + faculty = Faculty.objects.get(id=request.user.extrainfo) + qs = Assignment.objects.filter(faculty=faculty) + except Faculty.DoesNotExist: + return Response({'error': 'Faculty profile not found'}, status=status.HTTP_404_NOT_FOUND) data = [{ 'id': a.id, 'ta_username': a.ta.id.user.username, @@ -3957,9 +4056,13 @@ def faculty_assignments(request): def faculty_pending(request): if not check_role(request,'faculty'): return Response({'error':'role=faculty required'}, status=status.HTTP_403_FORBIDDEN) - now = datetime.now() + try: + faculty = Faculty.objects.get(id=request.user.extrainfo) + except Faculty.DoesNotExist: + return Response({'error': 'Faculty profile not found'}, status=status.HTTP_404_NOT_FOUND) + now = datetime.datetime.now() qs = StipendRequest.objects.filter( - assignment__faculty=request.user.faculty, + assignment__faculty=faculty, status=StipendRequest.PENDING ).filter( Q(year__lt=now.year) | @@ -3974,8 +4077,12 @@ def faculty_pending(request): def faculty_approved(request): if not check_role(request,'faculty'): return Response({'error':'role=faculty required'}, status=status.HTTP_403_FORBIDDEN) + try: + faculty = Faculty.objects.get(id=request.user.extrainfo) + except Faculty.DoesNotExist: + return Response({'error': 'Faculty profile not found'}, status=status.HTTP_404_NOT_FOUND) qs = StipendRequest.objects.filter( - assignment__faculty=request.user.faculty, + assignment__faculty=faculty, status=StipendRequest.FAC_APPROVED ) data = [{'id': s.id, 'ta': s.assignment.ta.id.user.username, @@ -4024,7 +4131,7 @@ def registered_slots(request): session, semester_type = generate_current_session(datetime.datetime.now().year, student.curr_semester_no) eligibility_resp = get_replace_registration_eligibility(timezone.now().date(), student.curr_semester_no, datetime.datetime.now().year) if isinstance(eligibility_resp, JsonResponse): - return eligibility_resp + return JsonResponse([], safe=False) # Exclude slots with pending drop requests pending_drop_slots = CourseDropRequest.objects.filter( @@ -5655,11 +5762,24 @@ def apply_promotion(request): continue old_sem = student.curr_semester_no new_sem = old_sem + 1 + + # For PhD students, dynamically create next semester + is_phd = hasattr(student, 'programme') and student.programme == 'PHD' + try: - semester_obj = Semester.objects.get(curriculum=student.batch_id.curriculum,semester_no=new_sem) + semester_obj = Semester.objects.get(curriculum=student.batch_id.curriculum, semester_no=new_sem) except Semester.DoesNotExist: - errors.append({"index": idx, "detail": f"Semester {new_sem} not defined."}) - continue + if is_phd: + # Create the semester for PhD students + semester_obj = Semester.objects.create( + curriculum=student.batch_id.curriculum, + semester_no=new_sem, + semester_name=f"Semester {new_sem}" + ) + # Note: Admin needs to manually add thesis course to this semester via CourseSlot + else: + errors.append({"index": idx, "detail": f"Semester {new_sem} not defined for student {sid}."}) + continue student.curr_semester_no = new_sem student.save() frs = FinalRegistration.objects.filter(student_id=student, verified=False, semester_id = semester_obj) @@ -5792,4 +5912,2169 @@ def apply_promotion(request): # ) # created.append(uname) # status_code = status.HTTP_207_MULTI_STATUS if errors else status.HTTP_201_CREATED -# return JsonResponse({"created": created, "errors": errors}, status=status_code) \ No newline at end of file +# return JsonResponse({"created": created, "errors": errors}, status=status_code) + + +# ============================================================================ +# PhD-SPECIFIC VIEW FUNCTIONS (Added for PhD student management) +# ============================================================================ +# These functions handle PhD-specific workflows like thesis registration, +# seminar reports, RPC committees, and external review invitations. +# They are separate from UG/PG functions to maintain production stability. +# ============================================================================ + +def thesis_to_dict(t): + """Serialize a ThesisTopic instance for JSON responses.""" + return { + "id": t.id, + "student_roll": t.student.id.id, + "student_name": t.student.id.user.get_full_name(), + "student_discipline": t.student.specialization, + "category": t.category, + "broad_area": t.broad_area, + "research_theme": t.research_theme, + "supervisor": {"id": t.supervisor.id.id, "name": str(t.supervisor), "discipline": (t.supervisor.id.department.name if t.supervisor.id.department else "")}, + "co_supervisor": ( + {"id": t.co_supervisor.id.id, "name": str(t.co_supervisor), "discipline": (t.co_supervisor.id.department.name if t.co_supervisor.id.department else "")} + if t.co_supervisor else None + ), + "supervisor_consented": t.supervisor_consented, + "co_supervisor_consented": t.co_supervisor_consented, + "external": { + "ext_name": t.external_name, + "ext_email": t.external_email, + "ext_discipline": t.external_discipline, + "ext_institution": t.external_institution, + }, + "load": { + "pg_single": t.pg_single, + "pg_shared": t.pg_shared, + "phd_single": t.phd_single, + "phd_shared": t.phd_shared, + }, + "committee": [ + { + "id": cm.member.id.id, + "name": str(cm.member), + "discipline": (cm.member.id.department.name if cm.member.id.department else ""), + } + for cm in CommitteeMember.objects.filter(thesis = t).all() + ], + "status": t.status, + "hod_remarks": t.hod_remarks, + "dean_remarks" : t.dean_remarks + } + + +# 1. Student APIs + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def student_thesis_api(request): + """ + GET /stu/thesis/ → fetch ({} if none) + POST /stu/thesis/ → create/update when status == supervisor_pending or new + """ + user = request.user + try: + user_details = user.extrainfo + student = Student.objects.get(id=user_details) + except Student.DoesNotExist: + return JsonResponse({"error": "Student record not found"}, status=404) + except Exception as e: + return JsonResponse({"error": f"User setup error: {type(e).__name__}: {e}"}, status=400) + + try: + thesis = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + + if request.method == 'GET': + return JsonResponse(thesis_to_dict(thesis) if thesis else {}, status=200) + except Exception as e: + return JsonResponse({"error": f"Internal error: {type(e).__name__}: {e}"}, status=500) + + # POST: only if no thesis yet or status is supervisor_pending + if thesis and thesis.status != 'supervisor_pending': + return JsonResponse( + {"error": "Cannot edit once under review past supervisor."}, + status=403 + ) + + data = request.data + if not thesis: + thesis = ThesisTopic(student=student) + + thesis.category = data['category'] + thesis.broad_area = data['broad_area'] + thesis.research_theme = data['research_theme'] + thesis.supervisor_id = data['supervisor_id'] + thesis.co_supervisor_id = data.get('co_supervisor_id') + thesis.external_name = data.get('external_name', '') + thesis.external_email = data.get('external_email', '') + thesis.external_discipline = data.get('external_discipline', '') + thesis.external_institution= data.get('external_institution', '') + thesis.status = 'supervisor_pending' + thesis.save() + + return JsonResponse(thesis_to_dict(thesis), status=201) + + +from reportlab.lib.pagesizes import A4 +from reportlab.lib import colors +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import mm +from reportlab.platypus import ( + SimpleDocTemplate, Paragraph, Spacer, + Table, TableStyle, Image +) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def student_download_pdf_api(request): + thesis = get_object_or_404(ThesisTopic, student__id=request.user.extrainfo) + + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + leftMargin=15 * mm, + rightMargin=15 * mm, + topMargin=20 * mm, + bottomMargin=15 * mm + ) + + styles = getSampleStyleSheet() + normal = styles['Normal'] + bold = ParagraphStyle('Bold', parent=normal, fontName='Helvetica-Bold') + elements = [] + + # Header + logo = Image('./media/logo2.jpg', width=25 * mm, height=25 * mm) + college_name = Paragraph( + 'Indian Institute of Information Technology, Design and Manufacturing, Jabalpur
', + ParagraphStyle('Header', parent=styles['Title'], alignment=1) + ) + header_tbl = Table([[logo, college_name]], colWidths=[30 * mm, 150 * mm]) + header_tbl.setStyle(TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('ALIGN', (1, 0), (1, 0), 'CENTER'), + ('LEFTPADDING', (0, 0), (-1, -1), 0), + ('RIGHTPADDING', (0, 0), (-1, -1), 0), + ])) + elements.extend([header_tbl, Spacer(1, 12)]) + elements.extend([Paragraph('Thesis Topic Submission Form', styles['Heading2']), Spacer(1, 20)]) + + # Form fields data + data = [ + [Paragraph('Roll Number:', bold), thesis.student.id.id], + [Paragraph('Student Name:', bold), thesis.student.id.user.get_full_name()], + [Paragraph('Discipline:', bold), thesis.student.specialization], + [Paragraph('Category:', bold), thesis.category], + [Paragraph('Broad Area:', bold), thesis.broad_area], + [Paragraph('Research Theme:', bold), + Paragraph(thesis.research_theme.replace('\n', '
'), normal)], + [Paragraph('Supervisor:', bold), thesis.supervisor.id.user.get_full_name()], + ] + if thesis.co_supervisor: + data.append([Paragraph('Co-Supervisor:', bold), thesis.co_supervisor.id.user.get_full_name()]) + if thesis.external_name: + data.extend([ + [Paragraph('External Supervisor:', bold), thesis.external_name], + [Paragraph('Email:', bold), thesis.external_email], + [Paragraph('Discipline:', bold), thesis.external_discipline], + [Paragraph('Institution:', bold), thesis.external_institution], + ]) + + # Create the form table with increased row heights + form_tbl = Table( + data, + colWidths=[55 * mm, 125 * mm], + rowHeights=[13 * mm] * len(data) # each row is 15 mm tall + ) + form_tbl.setStyle(TableStyle([ + ('GRID', (0, 0), (-1, -1), 0.4, colors.grey), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 8), + ('RIGHTPADDING', (0, 0), (-1, -1), 8), + ('TOPPADDING', (0, 0), (-1, -1), 10), # extra breathing room + ('BOTTOMPADDING', (0, 0), (-1, -1), 10), + ('BACKGROUND', (0, 0), (0, -1), colors.whitesmoke), + ])) + elements.extend([form_tbl, Spacer(1, 40)]) + + # Signatures: two per row + sig_line = '__________ Date: _______' + row1 = [ + Paragraph('Supervisor Sig.:', bold), sig_line, + Paragraph('Co-Supervisor Sig.:', bold) if thesis.co_supervisor else '', + sig_line if thesis.co_supervisor else '' + ] + sig_tbl = Table([row1], colWidths=[30 * mm, 60 * mm, 30 * mm, 60 * mm]) + sig_tbl.setStyle(TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'BOTTOM'), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + elements.append(sig_tbl) + + # Build and return PDF + doc.build(elements) + buffer.seek(0) + return HttpResponse(buffer, content_type='application/pdf') +# 2. Faculty list for dropdowns + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def faculty_list_api(request): + """ + GET /faculty/ → all faculty {id, name, discipline} + """ + qs = Faculty.objects.select_related('id__user', 'id__department') + data = [] + for f in qs: + user = f.id.user + dept = f.id.department + data.append({ + 'id': f.id.id, + 'name': f"{user.first_name} {user.last_name}", + 'discipline': dept.name if dept else '', + }) + return JsonResponse(data, safe=False) + + +# 3. Supervisor endpoints +from django.db import models + +from django.db.models import Q +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from django.http import JsonResponse + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_thesis_topic_dashboard(request): + """ + GET /supervisor/dashboard/ + → returns { pending, forwarded } + for any thesis where request.user is either supervisor OR co_supervisor. + """ + ex = request.user + + qs = ThesisTopic.objects.filter( + Q(supervisor__id=ex.username) | Q(co_supervisor__id=ex.username) + ) + print(qs) + + pending_statuses = ['supervisor_pending', 'hod_rejected'] + pending_qs = qs.filter(status__in=pending_statuses) + + forwarded_qs = qs.exclude(status__in=pending_statuses) + + return JsonResponse({ + 'pending': [thesis_to_dict(t) for t in pending_qs], + 'forwarded': [thesis_to_dict(t) for t in forwarded_qs], + }) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def supervisor_review_api(request, pk): + thesis = get_object_or_404(ThesisTopic, pk=pk) + user_ex = request.user.username + is_sup = (thesis.supervisor_id == user_ex) + is_co = (thesis.co_supervisor and thesis.co_supervisor_id == user_ex) + + if request.method == 'GET': + payload = thesis_to_dict(thesis) + payload.update({"is_supervisor": is_sup, "is_co_supervisor": is_co}) + return JsonResponse(payload, status=200) + + if thesis.status != 'supervisor_pending' and thesis.status != 'hod_rejected': + return JsonResponse({"error": "Cannot review at this stage."}, status=403) + + data = request.data + + if 'research_theme' in data: + thesis.research_theme = data['research_theme'] + + if is_co and not is_sup: + if thesis.co_supervisor_consented: + return JsonResponse({"error": "Already consented."}, status=400) + if data.get('co_supervisor_consented'): + thesis.co_supervisor_consented = True + thesis.save() + return JsonResponse({"message": "Co-Supervisor consent recorded."}, status=200) + return JsonResponse({"error": "Invalid consent payload."}, status=400) + + if is_sup: + + if not (thesis.supervisor_consented and + (not thesis.co_supervisor or thesis.co_supervisor_consented)): + + thesis.pg_single = data.get('pg_single', thesis.pg_single) + thesis.pg_shared = data.get('pg_shared', thesis.pg_shared) + thesis.phd_single = data.get('phd_single', thesis.phd_single) + thesis.phd_shared = data.get('phd_shared', thesis.phd_shared) + + CommitteeMember.objects.filter(thesis=thesis).delete() + print(data.get('committee', [])) + for member_id in data.get('committee', []): + CommitteeMember.objects.create(thesis=thesis, member_id=member_id) + + CommitteeMember.objects.get_or_create(thesis=thesis, member_id=thesis.supervisor_id) + if thesis.co_supervisor_id: + CommitteeMember.objects.get_or_create(thesis=thesis, member_id=thesis.co_supervisor_id) + + if not thesis.supervisor_consented and data.get('supervisor_consented'): + thesis.supervisor_consented = True + + sup_ok = thesis.supervisor_consented + co_ok = (not thesis.co_supervisor) or thesis.co_supervisor_consented + + if sup_ok and co_ok: + total_rpc = CommitteeMember.objects.filter(thesis=thesis).count() + if total_rpc < 3: + return JsonResponse( + {"error": "Need at least 3 RPC members (including supervisor/co-supervisor)."}, + status=400 + ) + thesis.status = 'hod_pending' + thesis.save() + return JsonResponse( + {"message": "Forwarded to HOD successfully.", "status": thesis.status}, + status=200 + ) + + thesis.save() + return JsonResponse( + {"message": "Supervisor changes saved; awaiting all consents and RPC ≥ 3."}, + status=200 + ) + + return JsonResponse({"error": "Not authorized."}, status=403) + +# 4. HOD endpoints + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def hod_dashboard(request): + """ + GET /hod/dashboard/ → { pending, approved, rejected } + filtered by HOD designation held by the user. + + - pending statuses: ['dean_rejected', 'hod_pending'] + - approved statuses: ['hod_approved', 'dean_approved'] + - rejected statuses: ['hod_rejected'] + """ + user = request.user + data = {'pending': [], 'approved': [], 'rejected': []} + + STATUS_PENDING = ['dean_rejected', 'hod_pending'] + STATUS_APPROVED = ['hod_approved', 'dean_approved'] + STATUS_REJECTED = ['hod_rejected'] + all_statuses = STATUS_PENDING + STATUS_APPROVED + STATUS_REJECTED + + # Get HOD designations for this user + hod_designations = HoldsDesignation.objects.filter( + working=user, + designation__name__icontains='HOD' + ).values_list('designation__name', flat=True) + + # Extract disciplines from HOD designations (e.g., "HOD (CSE)" -> "CSE") + hod_disciplines = [] + for des_name in hod_designations: + if '(' in des_name and ')' in des_name: + discipline = des_name[des_name.index('(')+1:des_name.index(')')].strip() + hod_disciplines.append(discipline) + + qs = ThesisTopic.objects.filter(status__in=all_statuses).select_related('student', 'student__batch_id', 'student__batch_id__discipline') + + for thesis in qs: + # Check if thesis student's discipline matches HOD's discipline + # Use discipline acronym (e.g., "CSE") to match with HOD designation (e.g., "HOD (CSE)") + student_discipline_acronym = None + if thesis.student.batch_id and thesis.student.batch_id.discipline: + student_discipline_acronym = thesis.student.batch_id.discipline.acronym + + if not student_discipline_acronym or student_discipline_acronym not in hod_disciplines: + continue + + dto = thesis_to_dict(thesis) + + if thesis.status in STATUS_PENDING: + data['pending'].append(dto) + elif thesis.status in STATUS_APPROVED: + data['approved'].append(dto) + else: # thesis.status in STATUS_REJECTED + data['rejected'].append(dto) + + return JsonResponse(data) + + +@api_view(['GET','POST']) +@permission_classes([IsAuthenticated]) +def hod_review_api(request, pk): + thesis = get_object_or_404(ThesisTopic, pk=pk) + user = request.user + + # Check if user is HOD for the student's discipline + student_discipline_acronym = None + if thesis.student.batch_id and thesis.student.batch_id.discipline: + student_discipline_acronym = thesis.student.batch_id.discipline.acronym + + is_hod = False + if student_discipline_acronym: + # Check if user has HOD designation for this discipline + hod_des_name = f"HOD ({student_discipline_acronym})" + hod_designations = HoldsDesignation.objects.filter( + working=user, + designation__name=hod_des_name + ).exists() + is_hod = hod_designations + + if request.method == 'GET': + data = thesis_to_dict(thesis) + return JsonResponse(data, status=200) + + # POST + if not is_hod or thesis.status not in ['hod_pending','hod_rejected','dean_pending']: + return JsonResponse({"error":"Forbidden or invalid stage"}, status=403) + + d = request.data + if d.get('approve'): + thesis.status = 'hod_approved' + thesis.hod_remarks = '' + else: + thesis.status = 'hod_rejected' + thesis.hod_remarks = d.get('remarks','') + thesis.supervisor_consented = False + thesis.co_supervisor_consented = False + thesis.dean_remarks = '' + + thesis.save() + return JsonResponse({"status":thesis.status}, status=200) + + +# 5. Dean endpoints + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def dean_dashboard(request): + """ + GET /dean/dashboard/ → {pending, approved} + for theses with status in dean_pending/dean_approved. + """ + data = {'pending': [], 'approved': [], 'rejected':[]} + qs = ThesisTopic.objects.filter(status__in=['dean_pending','dean_approved', 'hod_approved']) + for t in qs: + dto = thesis_to_dict(t) + bucket = 'pending' if t.status=='dean_pending' or t.status=='hod_approved' else \ + 'approved' if t.status=='dean_approved' else 'rejected' + data[bucket].append(dto) + return JsonResponse(data) + + +@api_view(['GET','POST']) +@permission_classes([IsAuthenticated]) +def dean_review_api(request, pk): + thesis = get_object_or_404(ThesisTopic, pk=pk) + # Assume user is Dean + if request.method == 'GET': + data = thesis_to_dict(thesis) + return JsonResponse(data, status=200) + + # POST + if thesis.status not in ['dean_pending','hod_approved']: + return JsonResponse({"error":"Forbidden or invalid stage"}, status=403) + + d = request.data + if d.get('approve'): + thesis.status = 'dean_approved' + thesis.dean_remarks = '' + else: + thesis.status = 'hod_pending' + thesis.dean_remarks = d.get('remarks','') + + thesis.save() + return JsonResponse({"status":thesis.status}, status=200) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def dean_generate_pdf_api(request, pk): + thesis = get_object_or_404(ThesisTopic, pk=pk) + if thesis.status != 'dean_approved': + return HttpResponse({"error": "Not fully approved"}, status=403) + + buffer = BytesIO() + student_roll = thesis.student.id.id.replace(' ', '_') + filename = f"approved_thesis_{student_roll}.pdf" + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + leftMargin=15 * mm, + rightMargin=15 * mm, + topMargin=15 * mm, + bottomMargin=10 * mm, + title=filename, + ) + + styles = getSampleStyleSheet() + normal = styles['Normal'] + normal.fontSize = 9 + normal.leading = 11 + bold = ParagraphStyle('Bold', parent=normal, fontName='Helvetica-Bold', fontSize=9) + title_center = ParagraphStyle('TitleCenter', parent=styles['Title'], alignment=1, fontSize=12) + heading2 = ParagraphStyle('H2', parent=styles['Heading2'], fontSize=11, spaceAfter=6) + heading3 = ParagraphStyle('H3', parent=styles['Heading3'], fontSize=10, spaceAfter=4) + + elements = [] + + # Header + logo = Image('./media/logo2.jpg', width=22*mm, height=22*mm) + institute = Paragraph( + 'Indian Institute of Information Technology, Design and Manufacturing, Jabalpur', + title_center + ) + header = Table([[logo, institute]], colWidths=[28*mm, 152*mm]) + header.setStyle(TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('ALIGN', (1, 0), (1, 0), 'CENTER'), + ('LEFTPADDING', (0, 0), (-1, -1), 0), + ('RIGHTPADDING',(0, 0), (-1, -1), 0), + ])) + elements += [header, Spacer(1, 8)] + elements += [Paragraph('Thesis Approval Summary', heading2), Spacer(1, 6)] + + # Form fields + form_data = [ + ['Roll Number', thesis.student.id.id], + ['Student Name', thesis.student.id.user.get_full_name()], + ['Discipline', thesis.student.specialization], + ['Category', thesis.category], + ['Broad Area', thesis.broad_area], + ] + # research theme as separate row with wrapping + form_data.append(['Research Theme', + Paragraph(thesis.research_theme.replace('\n', '
'), normal)]) + if thesis.co_supervisor: + form_data.append(['Co-Supervisor', thesis.co_supervisor.id.user.get_full_name()]) + if thesis.external_name: + form_data += [ + ['External Supervisor', thesis.external_name], + ['Email', thesis.external_email], + ['External Discipline', thesis.external_discipline], + ['Institution', thesis.external_institution], + ] + + # All tables same total width: use available width = 160mm + total_width = 160 * mm + col1 = 50 * mm + col2 = total_width - col1 + + # Form table with reduced row height + form_tbl = Table(form_data, colWidths=[col1, col2], rowHeights=[8*mm]*len(form_data)) + form_tbl.setStyle(TableStyle([ + ('GRID', (0, 0), (-1, -1), 0.4, colors.grey), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('BACKGROUND', (0, 0), (0, -1), colors.whitesmoke), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 3), + ('BOTTOMPADDING',(0, 0), (-1, -1), 3), + ('FONTSIZE', (0, 0), (-1, -1), 9), + ])) + elements += [form_tbl, Spacer(1, 8)] + + # Supervision Load + load_data = [ + ['Category', 'Single', 'Shared'], + ['PG', str(thesis.pg_single), str(thesis.pg_shared)], + ['PhD', str(thesis.phd_single), str(thesis.phd_shared)], + ] + load_tbl = Table(load_data, colWidths=[col1, (total_width-col1)/2, (total_width-col1)/2], rowHeights=[7*mm]*3) + load_tbl.setStyle(TableStyle([ + ('GRID', (0, 0), (-1, -1), 0.4, colors.grey), + ('BACKGROUND', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (1, 1), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('LEFTPADDING',(0,0),(-1,-1),3),('RIGHTPADDING',(0,0),(-1,-1),3), + ('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),2), + ('FONTSIZE', (0, 0), (-1, -1), 9), + ])) + elements += [Paragraph('Supervision Load', heading3), load_tbl, Spacer(1, 8)] + + # Committee + comm = [['Member', 'Discipline']] + for cm in thesis.committee.all(): + comm.append([cm.member.id.user.get_full_name(), cm.member.id.department.name or '']) + comm_tbl = Table(comm, colWidths=[col1, col2], rowHeights=[7*mm]*len(comm)) + comm_tbl.setStyle(TableStyle([ + ('GRID', (0, 0), (-1, -1), 0.4, colors.grey), + ('BACKGROUND',(0, 0), (-1, 0), colors.whitesmoke), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('LEFTPADDING',(0,0),(-1,-1),3),('RIGHTPADDING',(0,0),(-1,-1),3), + ('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),2), + ('FONTSIZE', (0, 0), (-1, -1), 9), + ])) + elements += [Paragraph('RPC Committee Members', heading3), comm_tbl, Spacer(1, 8)] + + # Signatures: one per line (compact but readable) + sig_labels = [ + ('Supervisor Signature:', 'Date:'), + ('Co-Supervisor Signature:', 'Date:') if thesis.co_supervisor else None, + ('HOD Signature:', 'Date:'), + ('Dean Signature:', 'Date:') + ] + # Ensure all signature rows have equal, large vertical spacing + # Use moderate spacing and group all signature rows to avoid page break + sig_row_space = 18 # mm, balanced for single page + sig_tables = [] + for label_pair in sig_labels: + if label_pair: + label = label_pair[0] + sig_tbl = Table( + [[ + Paragraph(f'{label}', bold), + '__________________________', + Paragraph(f'{label_pair[1]}', bold), + '_______________' + ]], + colWidths=[45*mm, 55*mm, 15*mm, 45*mm], + rowHeights=[12*mm] # force equal height for all signature rows + ) + sig_tbl.setStyle(TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'BOTTOM'), + ('LEFTPADDING',(0,0),(-1,-1),2), + ('RIGHTPADDING',(0,0),(-1,-1),2), + # Remove any background for all rows + ])) + sig_tables += [sig_tbl, Spacer(1, sig_row_space)] + # Use KeepTogether to prevent page break in signature block + from reportlab.platypus import KeepTogether + elements.append(KeepTogether(sig_tables)) + + doc.build(elements) + buffer.seek(0) + + # Set proper filename without spaces + buffer.seek(0) + response = HttpResponse(buffer.getvalue(), content_type='application/pdf') + response['Content-Disposition'] = ( + f"attachment; filename=\"{filename}\"; filename*=UTF-8''{filename}" + ) + return response + + +# Seminar Views +# 1. STUDENT + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def list_reports(request): + thesis = get_object_or_404(ThesisTopic, student_id=request.user.username) + data = [{ + "id": s.id, + "version": s.version, + "status": s.status, + "created_at": s.created_at.isoformat(), + } for s in thesis.seminars.order_by('version')] + + print(data) + print(thesis.status) + return JsonResponse(data, safe=False) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def create_report(request, thesis_pk): + thesis = get_object_or_404(ThesisTopic, pk=thesis_pk, student_id=request.user.username) + if thesis.status != 'dean_approved': + return JsonResponse({"error":"Thesis not Dean-approved."}, status=403) + + # versioning + last = thesis.seminars.order_by('-version').first() + version = (last.version + 1) if last else 1 + + seminar = SeminarEntry.objects.create( + thesis=thesis, + version=version, + status='rpc_pending', + seminar_date = request.data.get('date') or None, + seminar_time = request.data.get('time') or None, + seminar_venue = request.data.get('venue',''), + summary_prev = request.data.get('prev',''), + summary_curr = request.data.get('curr',''), + future_plan = request.data.get('future',''), + upload_doc = request.FILES.get('doc', None), + ) + + # parse publications payload + pubs = request.data.get('publications', []) + if isinstance(pubs, str): + try: + pubs = json.loads(pubs) + except json.JSONDecodeError: + pubs = [] + + # rebuild PublicationCount rows + PublicationCount.objects.filter(seminar=seminar).delete() + for p in pubs: + # guard against bad entries + cat = p.get('category') + if not cat: + continue + PublicationCount.objects.create( + seminar = seminar, + category = cat, + submitted = int(p.get('submitted', 0) or 0), + accepted = int(p.get('accepted', 0) or 0), + published = int(p.get('published', 0) or 0), + ) + + return JsonResponse({ + "id": seminar.id, + "message": "Seminar submitted; awaiting RPC consent." + }, status=201) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def detail_report(request, pk): + s = get_object_or_404(SeminarEntry, pk=pk, thesis__student_id=request.user.username) + return JsonResponse({ + "id": s.id, + "version": s.version, + "status": s.status, + "date": str(s.seminar_date or ""), + "time": str(s.seminar_time or ""), + "venue": s.seminar_venue, + "prev": s.summary_prev, + "curr": s.summary_curr, + "future": s.future_plan, + "doc_url": s.upload_doc.url if s.upload_doc else None, + "publications": [ + { + "category": pc.category, + "submitted": pc.submitted, + "accepted": pc.accepted, + "published": pc.published, + } + for pc in s.pub_counts.all() + ], + }) + +# 2. RPC + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def rpc_seminar_list(request): + faculty = get_object_or_404(Faculty, id__user=request.user) + all_entries = SeminarEntry.objects.filter( + thesis__committee__member=faculty + ).distinct() + + def serialize(qs): + return [ + { + "id": s.id, + "version": s.version, + "student": s.thesis.student.id.user.get_full_name(), + "thesis": str(s.thesis), + "status": s.status, + } + for s in qs + ] + + return Response({ + "pending": serialize(all_entries.filter(status='rpc_pending')), + "approved": serialize(all_entries.filter(status='rpc_approved')), + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def rpc_detail(request, pk): + faculty = get_object_or_404(Faculty, id__user=request.user) + seminar = get_object_or_404(SeminarEntry, pk=pk) + if not CommitteeMember.objects.filter(thesis=seminar.thesis, member=faculty).exists(): + return JsonResponse({"error": "Not on committee."}, status=403) + + student_extra = seminar.thesis.student.id + student_name = student_extra.user.first_name + student_extra.user.last_name + roll_number = student_extra.user.username + discipline = seminar.thesis.student.specialization + thesis_title = str(seminar.thesis) + + pubs = [ + { + "category": p.category, + "submitted": p.submitted, + "accepted": p.accepted, + "published": p.published + } + for p in seminar.pub_counts.all() + ] + + panel = { + f: getattr(seminar, f) for f in [ + 'quality', 'quantity', 'overall_grade', 'expected_period', + 'rec_assist', 'rec_enhance', 'rec_repeat', 'rec_open' + ] + } + + committee = [] + for cm in CommitteeMember.objects.filter(thesis=seminar.thesis).select_related('member__id__user', 'member__id__department'): + fac = cm.member + extra = fac.id + consented = SeminarConsent.objects.filter(seminar=seminar, member=fac, consented=True).exists() + committee.append({ + "id": extra.id, + "name": f"{extra.user.first_name} {extra.user.last_name}", + "discipline": extra.department.name if extra.department else "", + "consented": consented, + }) + + comments = [ + { + "member": c.member.id.user.first_name + c.member.id.user.last_name, + "text": c.text, + "timestamp": c.timestamp.isoformat() + } + for c in seminar.comments.all() + ] + + my_comment = SeminarComment.objects.filter(seminar=seminar, member=faculty).first() + is_consented = SeminarConsent.objects.filter(seminar=seminar, member=faculty, consented=True).exists() + + payload = { + "studentName": student_name, + "rollNumber": roll_number, + "discipline": discipline, + "thesisTitle": thesis_title, + "id": seminar.id, + "version": seminar.version, + "date": seminar.seminar_date.isoformat() if seminar.seminar_date else "", + "time": seminar.seminar_time.isoformat() if seminar.seminar_time else "", + "venue": seminar.seminar_venue, + "prev": seminar.summary_prev, + "curr": seminar.summary_curr, + "future": seminar.future_plan, + "doc_url": seminar.upload_doc.url if seminar.upload_doc else None, + "publications": pubs, + **panel, + "committee": committee, + "committeeSize": len(committee), + "consentedCount": sum(1 for m in committee if m["consented"]), + "comments": comments, + "myComment": my_comment.text if my_comment else "", + "isConsented": is_consented, + "status": seminar.status, + } + + return JsonResponse(payload) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def rpc_consent(request, pk): + faculty = get_object_or_404(Faculty, id__user=request.user) + seminar = get_object_or_404(SeminarEntry, pk=pk, status='rpc_pending') + if not CommitteeMember.objects.filter(thesis=seminar.thesis, member=faculty).exists(): + return JsonResponse({"error": "Not on committee."}, status=403) + + data = request.data + panel_fields = [ + 'quality', 'quantity', 'overall_grade', 'expected_period', + 'rec_assist', 'rec_enhance', 'rec_repeat', 'rec_open' + ] + + changed = any( + field in data and getattr(seminar, field) != data[field] + for field in panel_fields + ) + if changed: + SeminarConsent.objects.filter(seminar=seminar).update(consented=False) + + for field in panel_fields: + if field in data: + setattr(seminar, field, data[field]) + seminar.save() + + if 'comment' in data: + SeminarComment.objects.update_or_create( + seminar=seminar, + member=faculty, + defaults={'text': data['comment']} + ) + + consent_obj, _ = SeminarConsent.objects.get_or_create(seminar=seminar, member=faculty) + consent_obj.consented = True + consent_obj.save() + + return JsonResponse({"message": "Consent & data recorded."}) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def rpc_finalize(request, pk): + faculty = get_object_or_404(Faculty, id__user=request.user) + seminar = get_object_or_404(SeminarEntry, pk=pk, status='rpc_pending') + if not CommitteeMember.objects.filter(thesis=seminar.thesis, member=faculty).exists(): + return JsonResponse({"error": "Not on committee."}, status=403) + + total = CommitteeMember.objects.filter(thesis=seminar.thesis).count() + yes = SeminarConsent.objects.filter(seminar=seminar, consented=True).count() + + if yes < total: + return JsonResponse({"error": "Not all consents recorded."}, status=400) + + seminar.status = 'rpc_approved' + seminar.save() + return JsonResponse({"message": "Seminar approved."}) + + +from applications.academic_procedures.models import ThesisSubmission, ReviewInvitation, ThesisReview, ExaminerBankDetails +from applications.academic_procedures.utils import ( + send_invitation_email, + send_review_form_email, + send_thank_you_email, + advance_invitation, + INVITATION_TIMEOUT_DAYS, +) + +# 1. Student submits thesis +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@parser_classes([MultiPartParser, FormParser]) +def thesis_submit(request): + user = request.user + try: + user_details = user.extrainfo + student = Student.objects.get(id=user_details) + except Student.DoesNotExist: + return Response({'error': 'Student record not found.'}, 404) + thesis = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + if thesis is None: + return Response({'error': 'No thesis found for given submission.'}, 400) + syn = request.FILES.get('synopsis') + rpt = request.FILES.get('thesis_report') + if not all([syn, rpt]): + return Response({'error': 'Missing fields'}, 400) + if syn.size > 5*1024*1024 or rpt.size > 25*1024*1024: + return Response({'error': 'File too large'}, 400) + sub = ThesisSubmission.objects.create( + thesis = thesis, + synopsis=syn, + thesis_report=rpt, + status='submitted' + ) + return Response({'submission_id': sub.id}, status=201) + +def _serialize_invitations(sub): + """Return (indian_examiners, foreign_examiners) lists for a submission's panel.""" + invites = ReviewInvitation.objects.filter(submission=sub).order_by('examiner_type', 'priority') + indian, foreign = [], [] + for inv in invites: + data = { + 'token': str(inv.token), + 'name': inv.prof_name, + 'position': inv.prof_position, + 'address': inv.prof_address, + 'phone': inv.prof_phone, + 'fax': inv.prof_fax, + 'email': inv.prof_email, + 'priority': inv.priority, + 'status': inv.status, + } + if inv.examiner_type == 'foreign': + data['time_ranking'] = inv.prof_time_ranking + foreign.append(data) + else: + indian.append(data) + return indian, foreign + + +# 1) Supervisor dashboard: pending vs forwarded +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_dashboard(request): + ex = request.user + topics = ThesisTopic.objects.filter( + Q(supervisor__id=ex.username) | Q(co_supervisor__id=ex.username) + ) + pending = ThesisSubmission.objects.filter(status='submitted', thesis__in=topics) + forwarded = ThesisSubmission.objects.filter(thesis__in=topics).exclude(status='submitted') + + def serialize(sub): + return { + 'id': sub.id, + 'title': sub.thesis.research_theme, + 'submitted_at': sub.submitted_at, + 'supervisor_approved_at': sub.supervisor_approved_at, + 'status': sub.status + } + + return Response({ + 'pending': [serialize(s) for s in pending], + 'forwarded': [serialize(s) for s in forwarded], + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_submission_detail(request, submission_id): + """ + Returns the already‐assigned examiners so the panel can render + in read-only mode when status != 'submitted'. + """ + sub = get_object_or_404(ThesisSubmission, id=submission_id) + indian, foreign = _serialize_invitations(sub) + return Response({ + 'indian_examiners': indian, + 'foreign_examiners': foreign, + }) + + +# 3) Supervisor assign examiners +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_assign(request): + data = request.data + sub = get_object_or_404(ThesisSubmission, id=data.get('submission_id')) + + # Only the thesis's own supervisor/co-supervisor may assign its panel. + topic = sub.thesis + allowed_users = {topic.supervisor.id.user_id} + if topic.co_supervisor: + allowed_users.add(topic.co_supervisor.id.user_id) + if request.user.id not in allowed_users: + return Response( + {'error': 'You are not the supervisor or co-supervisor for this thesis.'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Prevent re-assignment + if sub.status != 'submitted': + return Response( + {'error': 'Examiners have already been assigned.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + indian = data.get('indian_examiners', []) + foreign = data.get('foreign_examiners', []) + + if not indian or not foreign: + return Response( + {'error': 'At least one Indian and one foreign examiner are required.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Save submission + invitations atomically. ReviewInvitation has a + # unique constraint on (submission, examiner_type, priority); each + # category gets its own independent 1..N rank. + with transaction.atomic(): + # Wipe old invites & create new ones + ReviewInvitation.objects.filter(submission=sub).delete() + + for idx, prof in enumerate(indian, start=1): + ReviewInvitation.objects.create( + submission=sub, + examiner_type='indian', + prof_name=prof.get('name', ''), + prof_position=prof.get('position', ''), + prof_address=prof.get('address', ''), + prof_phone=prof.get('phone', ''), + prof_fax=prof.get('fax', ''), + prof_email=prof.get('email', ''), + priority=idx, + ) + + for idx, prof in enumerate(foreign, start=1): + ReviewInvitation.objects.create( + submission=sub, + examiner_type='foreign', + prof_name=prof.get('name', ''), + prof_position=prof.get('position', ''), + prof_address=prof.get('address', ''), + prof_phone=prof.get('phone', ''), + prof_fax=prof.get('fax', ''), + prof_email=prof.get('email', ''), + prof_time_ranking=prof.get('time_ranking', 1), + priority=idx, + ) + + # Update submission only after invitations are created successfully. + sub.supervisor = request.user + sub.supervisor_approved_at = timezone.now() + sub.status = 'dean_panel_review' + sub.save() + + return Response({'detail': 'Examiners assigned successfully, forwarded to Dean for approval.'}, status=status.HTTP_200_OK) + + +# 4) Dean panel dashboard: a single "action required" queue covering both +# panel approval and invitation-sending, plus a read-only history. +ACTION_STATUSES = { + 'dean_panel_review': ('approve_panel', 'Approve Panel'), + 'dean_invite_pending': ('send_invitations', 'Send Invitations'), +} + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_panel_dashboard(request): + def serialize(sub, action=None, action_label=None, waiting_since=None): + indian, foreign = _serialize_invitations(sub) + return { + 'id': sub.id, + 'title': sub.thesis.research_theme, + 'status': sub.status, + 'action': action, + 'action_label': action_label, + 'waiting_since': waiting_since, + 'supervisor_approved_at': sub.supervisor_approved_at, + 'dean_approved_at': sub.dean_approved_at, + 'indian_examiners': indian, + 'foreign_examiners': foreign, + } + + action_required = [] + for sub in ThesisSubmission.objects.filter(status__in=ACTION_STATUSES.keys()): + action, action_label = ACTION_STATUSES[sub.status] + waiting_since = ( + sub.director_approved_at if sub.status == 'dean_invite_pending' + else sub.supervisor_approved_at + ) + action_required.append(serialize(sub, action, action_label, waiting_since)) + # Oldest-waiting first, so overdue items surface at the top. + action_required.sort(key=lambda s: s['waiting_since'] or timezone.now()) + + history = [ + serialize(s) for s in + ThesisSubmission.objects.exclude(status__in=['submitted', *ACTION_STATUSES.keys()]) + ] + + return Response({ + 'action_required': action_required, + 'history': history, + }) + + +# 5) Dean approves or rejects the proposed panel +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_panel_approve(request): + data = request.data + sub = get_object_or_404(ThesisSubmission, id=data.get('submission_id')) + action = data.get('action') + + if sub.status != 'dean_panel_review': + return Response({'error': 'This panel is not awaiting Dean approval.'}, status=status.HTTP_400_BAD_REQUEST) + + if action == 'approve': + sub.dean = request.user + sub.dean_approved_at = timezone.now() + sub.status = 'director_review' + sub.save() + return Response({'detail': 'Panel approved, forwarded to Director for prioritization.'}) + + if action == 'reject': + sub.status = 'submitted' + sub.save() + return Response({'detail': 'Panel rejected, sent back to Supervisor.'}) + + return Response({'error': 'Unknown action.'}, status=status.HTTP_400_BAD_REQUEST) + + +# 6) Director dashboard: pending prioritization vs already prioritized +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['Director']) +def director_dashboard(request): + def serialize(sub, action=None, action_label=None, waiting_since=None): + invs = ReviewInvitation.objects.filter(submission=sub).order_by('examiner_type', 'priority') + return { + 'id': sub.id, + 'title': sub.thesis.research_theme, + 'status': sub.status, + 'action': action, + 'action_label': action_label, + 'waiting_since': waiting_since, + 'supervisor_approved_at': sub.supervisor_approved_at, + 'director_approved_at': sub.director_approved_at, + 'invitations': [ + {'token': str(inv.token), 'prof_name': inv.prof_name, + 'prof_email': inv.prof_email, 'examiner_type': inv.examiner_type, + 'priority': inv.priority} + for inv in invs + ], + } + + action_required = [ + serialize(sub, 'prioritize', 'Set Priorities', sub.dean_approved_at) + for sub in ThesisSubmission.objects.filter(status='director_review') + ] + # Oldest-waiting first, so overdue items surface at the top. + action_required.sort(key=lambda s: s['waiting_since'] or timezone.now()) + + history = [ + serialize(s) for s in + ThesisSubmission.objects.exclude(status__in=['submitted', 'dean_panel_review', 'director_review']) + ] + + return Response({ + 'action_required': action_required, + 'history': history, + }) + + +# 7) Director sets the priority order within each examiner category, then +# hands the submission back to the Dean to send out invitations. +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Director']) +def director_approve(request): + data = request.data + sub = get_object_or_404(ThesisSubmission, id=data.get('submission_id')) + + if sub.status != 'director_review': + return Response( + {'error': 'This panel is not awaiting Director prioritization.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + priorities = data.get('priorities', []) + examiner_type_by_token = { + str(inv.token): inv.examiner_type + for inv in ReviewInvitation.objects.filter(submission=sub) + } + + # Guard against duplicate ranks within the same examiner category before + # writing anything, since (submission, examiner_type, priority) is unique. + seen = set() + for item in priorities: + key = (examiner_type_by_token.get(item.get('token')), item.get('priority')) + if key in seen: + return Response( + {'error': 'Duplicate priority within an examiner category.'}, + status=status.HTTP_400_BAD_REQUEST + ) + seen.add(key) + + with transaction.atomic(): + invites = list(ReviewInvitation.objects.filter(submission=sub)) + # Bump every row to a unique, out-of-range placeholder first. Ranks + # are frequently swapped (e.g. A:1<->B:2), and writing the new values + # one row at a time can collide with another row's not-yet-updated + # priority under the (submission, examiner_type, priority) unique + # constraint. Clearing to disjoint placeholders first avoids that. + for idx, inv in enumerate(invites, start=1): + inv.priority = 10000 + idx + inv.save(update_fields=['priority']) + + for item in priorities: + inv = get_object_or_404(ReviewInvitation, submission=sub, token=item['token']) + inv.priority = item['priority'] + inv.save(update_fields=['priority']) + + sub.director = request.user + sub.director_approved_at = timezone.now() + sub.status = 'dean_invite_pending' + sub.save() + + return Response({'detail': 'Priorities set, sent back to Dean to send invitations.'}) + + +# 7b) Dean sends the invitation to the Rank-1 Indian and Rank-1 Foreign examiners. +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_send_invitations(request): + data = request.data + sub = get_object_or_404(ThesisSubmission, id=data.get('submission_id')) + + if sub.status != 'dean_invite_pending': + return Response( + {'error': 'This submission is not ready for invitations.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + invited = [] + for examiner_type in ('indian', 'foreign'): + inv = ReviewInvitation.objects.filter( + submission=sub, examiner_type=examiner_type, priority=1 + ).first() + if inv is None: + continue + inv.last_sent = timezone.now() + inv.expires_at = timezone.now() + datetime.timedelta(days=INVITATION_TIMEOUT_DAYS) + inv.save(update_fields=['last_sent', 'expires_at']) + try: + send_invitation_email(inv) + invited.append(inv.prof_email) + except Exception: + logger.exception(f"Failed to send invitation to {inv.prof_email} for submission {sub.id}") + + sub.dean = request.user + sub.dean_invited_at = timezone.now() + sub.status = 'in_review' + sub.save() + + return Response({'detail': 'Invitations sent.', 'invited': invited}) + + +# 8. Invitation accept/reject (external examiners have no Fusion account — +# the secret UUID token in the emailed link is the auth mechanism here) +@api_view(['GET']) +@permission_classes([AllowAny]) +def invitation_action(request, token, action): + inv = get_object_or_404(ReviewInvitation, token=token) + if inv.is_expired() or inv.is_finalized(): + return Response({'error': 'Invalid/expired'}, 403) + if action == 'accept': + inv.status = 'accepted' + inv.save() + try: + send_review_form_email(inv) + inv.review_form_sent = timezone.now() + inv.save(update_fields=['review_form_sent']) + except Exception: + logger.exception(f"Failed to send review-form email for token {inv.token}") + return Response({'detail': 'Accepted'}, 200) + if action == 'reject': + inv.status = 'rejected' + inv.save() + # Fall through to the next-ranked examiner in the same category. + advance_invitation(inv.submission, inv.examiner_type) + return Response({'detail': 'Rejected'}, 200) + return Response({'error': 'Unknown action'}, 400) + +# 9. Review detail & submission (token-authenticated, same as invitation_action) +@api_view(['GET', 'POST']) +@permission_classes([AllowAny]) +def review_detail(request, token): + inv = get_object_or_404(ReviewInvitation, token=token) + if inv.is_expired() or inv.is_finalized(): + return Response({'error': 'Invalid/expired'}, status=403) + if inv.status != 'accepted': + return Response({'error': 'This invitation has not been accepted yet.'}, status=403) + + sub = inv.submission + topic = sub.thesis + + if request.method == 'GET': + base = getattr(settings, 'SITE_URL', 'http://localhost:8000') + settings.MEDIA_URL + return Response({ + 'student_name': topic.student.id.user.get_full_name(), + 'student_roll': topic.student.id.id, + 'student_discipline': topic.student.specialization, + 'thesis_title': topic.research_theme, + 'synopsis_url': base + sub.synopsis.name, + 'report_url': base + sub.thesis_report.name, + 'examiner_type': inv.examiner_type, + 'examiner': { + 'name': inv.prof_name, + 'email': inv.prof_email, + 'position': inv.prof_position, + 'address': inv.prof_address, + 'phone': inv.prof_phone, + 'fax': inv.prof_fax, + }, + }, status=200) + + # POST: record the formal evaluation and finalize this examiner's invitation. + # Note: this only closes out THIS examiner's invitation -- the other + # category's examiner (Indian/Foreign) is untouched and continues its own + # lifecycle independently. What happens once both examiners have reviewed + # (aggregating outcomes, a final decision, etc.) is not yet implemented. + data = request.data + if not data.get('recommendation'): + return Response({'error': 'A specific recommendation is required.'}, status=400) + + with transaction.atomic(): + ThesisReview.objects.update_or_create( + invitation=inv, + defaults={ + 'originality_presentation': data.get('originality_presentation', ''), + 'quality_comparable': data.get('quality_comparable'), + 'new_ideas_original': data.get('new_ideas_original'), + 'correction_severity': data.get('correction_severity', ''), + 'technical_content': data.get('technical_content', ''), + 'highlights': data.get('highlights', ''), + 'suggestions': data.get('suggestions', ''), + 'defense_questions': data.get('defense_questions', ''), + 'recommendation': data['recommendation'], + }, + ) + + bank = data.get('bank_details') or {} + if any(bank.values()): + ExaminerBankDetails.objects.update_or_create( + invitation=inv, + defaults={ + 'beneficiary_name': bank.get('beneficiary_name', ''), + 'bank_name': bank.get('bank_name', ''), + 'bank_address': bank.get('bank_address', ''), + 'account_no': bank.get('account_no', ''), + 'ifsc_code': bank.get('ifsc_code', ''), + 'pan_no': bank.get('pan_no', ''), + 'iban_no': bank.get('iban_no', ''), + 'swift_code': bank.get('swift_code', ''), + }, + ) + + inv.status = 'completed' + inv.save(update_fields=['status']) + + try: + send_thank_you_email(inv) + except Exception: + logger.exception(f"Failed to send thank-you email for token {inv.token}") + + return Response({'detail': 'Review submitted successfully.'}, status=200) + + +# =========================================================================== +# Thesis Slot Semester-Level Registration +# =========================================================================== +from applications.academic_procedures.models import ( + ThesisTopic, CommitteeMember, SeminarEntry, + SeminarConsent, SeminarComment, PublicationCount, + ThesisRegistration, ProgressSeminarRegistration, + ThesisEvaluation, ProgressSeminarEvaluation, +) +from applications.programme_curriculum.models import ThesisSlot, ProgressSeminarSlot +import datetime as _dt + + +def _thesis_reg_to_dict(reg): + """Serialize a ThesisRegistration instance to a plain dict.""" + if reg is None: + return None + slot = reg.thesis_slot + theses_list = [ + {'id': t.id, 'code': t.code, 'name': t.name, 'credit': t.credit} + for t in slot.theses.all() + ] + return { + 'id': reg.id, + 'status': reg.status, + 'remarks': reg.remarks, + 'credits': reg.credits, + 'registered_on': reg.registered_on.isoformat(), + 'verified_on': reg.verified_on.isoformat() if reg.verified_on else None, + 'academic_session': reg.academic_session, + 'thesis_slot': { + 'id': slot.id, + 'name': slot.name, + 'info': slot.thesis_slot_info or '', + 'duration': slot.duration, + 'theses': theses_list, + }, + 'student': { + 'id': reg.student.id.id, + 'name': reg.student.id.user.get_full_name(), + }, + 'semester_no': reg.semester.semester_no, + } + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def student_thesis_enrollment_api(request): + """ + GET /stu/thesis-enrollment/ + Returns the current semester's ThesisSlot, the student's + ThesisTopic approval status, and any existing registration. + + POST /stu/thesis-enrollment/ + Creates a new ThesisRegistration for the current semester. + Requires thesis_topic to be dean_approved. + """ + user = request.user + try: + user_details = user.extrainfo + student = Student.objects.get(id=user_details) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) + except Exception as e: + return JsonResponse({'error': f'User setup error: {type(e).__name__}: {e}'}, status=400) + + try: + # Resolve current semester + if not student.batch_id or not student.batch_id.curriculum: + return JsonResponse({'error': 'Student batch or curriculum is not configured'}, status=400) + try: + semester = Semester.objects.get( + curriculum=student.batch_id.curriculum, + semester_no=student.curr_semester_no, + ) + except Semester.DoesNotExist: + return JsonResponse({'error': 'Current semester not found in curriculum'}, status=400) + + # Thesis topic info + topic = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + topic_data = thesis_to_dict(topic) if topic else None + + # ThesisSlot for this semester + thesis_slots = ThesisSlot.objects.filter(semester=semester) + thesis_slot = thesis_slots.first() # typically one per semester + + # Existing registration + try: + reg = ThesisRegistration.objects.get(student=student, semester=semester) + reg_data = _thesis_reg_to_dict(reg) + except ThesisRegistration.DoesNotExist: + reg = None + reg_data = None + + if request.method == 'GET': + slot_data = None + if thesis_slot: + slot_data = { + 'id': thesis_slot.id, + 'name': thesis_slot.name, + 'info': thesis_slot.thesis_slot_info or '', + 'duration': thesis_slot.duration, + 'theses': [ + {'id': t.id, 'code': t.code, 'name': t.name, 'credit': t.credit} + for t in thesis_slot.theses.all() + ], + } + # Include announced evaluation blocks so student can see grades + eval_blocks = [] + if reg is not None: + for ev in reg.evaluations.filter(announced=True).order_by('block_number'): + eval_blocks.append({ + 'id': ev.id, + 'block_number': ev.block_number, + 'total_blocks': reg.credits // 3, + 'grade': ev.grade, + 'remarks': ev.remarks, + 'announced_at': ev.announced_at.isoformat() if ev.announced_at else None, + }) + return JsonResponse({ + 'thesis_topic': topic_data, + 'current_semester_no': student.curr_semester_no, + 'thesis_slot': slot_data, + 'registration': reg_data, + 'evaluations': eval_blocks, + }, status=200) + + except Exception as e: + return JsonResponse({'error': f'Internal error: {type(e).__name__}: {e}'}, status=500) + + # POST: create registration + if reg is not None: + return JsonResponse( + {'error': 'Already registered for this semester', 'registration': reg_data}, + status=400, + ) + + if topic is None or topic.status != 'dean_approved': + return JsonResponse( + {'error': 'Thesis topic must be dean-approved before registering for a thesis slot'}, + status=403, + ) + + if thesis_slot is None: + return JsonResponse( + {'error': 'No thesis slot configured for your current semester'}, + status=400, + ) + + # Validate chosen credits + ALLOWED_THESIS_CREDITS = [3, 6, 9, 12] + try: + chosen_credits = int(request.data.get('credits', 6)) + except (TypeError, ValueError): + chosen_credits = 6 + if chosen_credits not in ALLOWED_THESIS_CREDITS: + return JsonResponse( + {'error': f'Invalid credit value. Choose from {ALLOWED_THESIS_CREDITS}'}, + status=400, + ) + + # Check max registration limit + current_count = ThesisRegistration.objects.filter( + thesis_slot=thesis_slot, status__in=['pending', 'verified'] + ).count() + if current_count >= thesis_slot.max_registration_limit: + return JsonResponse( + {'error': 'Thesis slot has reached maximum capacity'}, + status=400, + ) + + now = _dt.datetime.now() + # Build academic session string e.g. "2025-26" + year = now.year + month = now.month + if month >= 7: + session = f"{year}-{str(year + 1)[2:]}" + else: + session = f"{year - 1}-{str(year)[2:]}" + + reg = ThesisRegistration.objects.create( + student=student, + thesis_slot=thesis_slot, + thesis_topic=topic, + semester=semester, + credits=chosen_credits, + working_year=year, + academic_session=session, + status='pending', + ) + return JsonResponse(_thesis_reg_to_dict(reg), status=201) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_thesis_enrollment_list(request): + """ + GET /acadadmin/thesis-enrollments/?semester=&status= + Lists all ThesisRegistration entries. Supports optional filters: + ?semester= filter by semester number + ?status=pending|verified|rejected + """ + qs = ThesisRegistration.objects.select_related( + 'student__id__user', 'thesis_slot', 'thesis_topic', 'semester' + ).all().order_by('-registered_on') + + sem_no = request.GET.get('semester') + if sem_no: + qs = qs.filter(semester__semester_no=sem_no) + + status_filter = request.GET.get('status') + if status_filter: + qs = qs.filter(status=status_filter) + + data = [] + for reg in qs: + entry = _thesis_reg_to_dict(reg) + # Also include thesis topic approval status for admin view + entry['topic_status'] = reg.thesis_topic.status if reg.thesis_topic else None + data.append(entry) + + return JsonResponse({'registrations': data}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_verify_enrollments(request): + """ + POST /acadadmin/thesis-enrollments/verify/ + Body: { "ids": [1, 2, 3] } + Marks the given ThesisRegistration records as 'verified'. + """ + ids = request.data.get('ids', []) + if not ids: + return JsonResponse({'error': 'No registration IDs provided'}, status=400) + + now = _dt.datetime.now(_dt.timezone.utc) + regs = ThesisRegistration.objects.filter(id__in=ids, status='pending') + count = 0 + for reg in regs: + reg.status = 'verified' + reg.verified_on = now + reg.save(update_fields=['status', 'verified_on']) + # Auto-create evaluation blocks: one per 3 credits + total_blocks = reg.credits // 3 + for blk in range(1, total_blocks + 1): + ThesisEvaluation.objects.get_or_create( + registration=reg, + block_number=blk, + ) + count += 1 + return JsonResponse({'verified_count': count}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_reject_enrollments(request): + """ + POST /acadadmin/thesis-enrollments/reject/ + Body: { "ids": [1, 2], "remarks": "Reason for rejection" } + Marks the given ThesisRegistration records as 'rejected'. + """ + ids = request.data.get('ids', []) + remarks = request.data.get('remarks', '') + if not ids: + return JsonResponse({'error': 'No registration IDs provided'}, status=400) + + updated = ThesisRegistration.objects.filter(id__in=ids, status='pending').update( + status='rejected', + remarks=remarks, + ) + return JsonResponse({'rejected_count': updated}, status=200) + + +# =========================================================================== +# Thesis Grade Evaluation +# =========================================================================== + +def _eval_to_dict(ev): + """Serialize a ThesisEvaluation block to a plain dict.""" + reg = ev.registration + return { + 'id': ev.id, + 'block_number': ev.block_number, + 'total_blocks': reg.credits // 3, + 'grade': ev.grade, + 'remarks': ev.remarks, + 'submitted_by': ev.submitted_by.id.user.get_full_name() if ev.submitted_by else None, + 'submitted_at': ev.submitted_at.isoformat() if ev.submitted_at else None, + 'verified': ev.verified, + 'verified_at': ev.verified_at.isoformat() if ev.verified_at else None, + 'announced': ev.announced, + 'announced_at': ev.announced_at.isoformat() if ev.announced_at else None, + 'registration': { + 'id': reg.id, + 'credits': reg.credits, + 'semester_no': reg.semester.semester_no, + 'academic_session': reg.academic_session, + 'thesis_slot': reg.thesis_slot.name, + 'student': { + 'id': reg.student.id.id, + 'name': reg.student.id.user.get_full_name(), + }, + }, + } + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_thesis_grades(request): + """ + GET /supervisor/thesis-grades/ + Returns all ThesisEvaluation blocks for registrations where the + student's thesis_topic.supervisor is the requesting faculty. + Ordered by semester, then student name. + Optional: ?semester= ?graded=true|false + """ + user = request.user + try: + faculty = Faculty.objects.get(id__user=user) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) + + # Thesis registrations where this faculty is the supervisor + qs = ThesisEvaluation.objects.select_related( + 'registration__student__id__user', + 'registration__semester', + 'registration__thesis_slot', + 'registration__thesis_topic', + 'submitted_by__id__user', + ).filter( + registration__status='verified', + registration__thesis_topic__supervisor=faculty, + ).order_by('registration__semester__semester_no', 'registration__student__id__user__last_name') + + # Filters + sem_no = request.GET.get('semester') + if sem_no: + qs = qs.filter(registration__semester__semester_no=sem_no) + + graded_param = request.GET.get('graded') + if graded_param == 'false': + qs = qs.filter(grade__isnull=True) + elif graded_param == 'true': + qs = qs.exclude(grade__isnull=True) + + return JsonResponse({'evaluations': [_eval_to_dict(ev) for ev in qs]}, status=200) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_thesis_grades_list(request): + """ + GET /acadadmin/thesis-grades/?semester=&status=pending|verified|announced + Lists all ThesisEvaluation blocks with optional filters. + status filter: pending = grade submitted but not verified + verified = verified but not announced + announced = announced + ungraded = no grade yet + """ + qs = ThesisEvaluation.objects.select_related( + 'registration__student__id__user', + 'registration__semester', + 'registration__thesis_slot', + 'submitted_by__id__user', + 'verified_by', + ).order_by('registration__semester__semester_no', 'registration__student__id__user__last_name', 'block_number') + + sem_no = request.GET.get('semester') + if sem_no: + qs = qs.filter(registration__semester__semester_no=sem_no) + + status_param = request.GET.get('status') + if status_param == 'ungraded': + qs = qs.filter(grade__isnull=True) + elif status_param == 'pending': + qs = qs.exclude(grade__isnull=True).filter(verified=False) + elif status_param == 'verified': + qs = qs.filter(verified=True, announced=False) + elif status_param == 'announced': + qs = qs.filter(announced=True) + + return JsonResponse({'evaluations': [_eval_to_dict(ev) for ev in qs]}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_verify_thesis_grades(request): + """ + POST /acadadmin/thesis-grades/verify/ + Body: { "ids": [1, 2, 3] } + Verifies submitted grades (grade must already be set by supervisor). + """ + ids = request.data.get('ids', []) + if not ids: + return JsonResponse({'error': 'No evaluation IDs provided'}, status=400) + + now = _dt.datetime.now(_dt.timezone.utc) + count = 0 + for ev in ThesisEvaluation.objects.filter(id__in=ids, verified=False).exclude(grade=None): + ev.verified = True + ev.verified_by = request.user + ev.verified_at = now + ev.save(update_fields=['verified', 'verified_by', 'verified_at']) + count += 1 + return JsonResponse({'verified_count': count}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_announce_thesis_grades(request): + """ + POST /acadadmin/thesis-grades/announce/ + Body: { "ids": [1, 2, 3] } + Announces grades — makes them visible to students. + Only verified grades can be announced. + """ + ids = request.data.get('ids', []) + if not ids: + return JsonResponse({'error': 'No evaluation IDs provided'}, status=400) + + now = _dt.datetime.now(_dt.timezone.utc) + count = 0 + for ev in ThesisEvaluation.objects.filter(id__in=ids, verified=True, announced=False): + ev.announced = True + ev.announced_at = now + ev.save(update_fields=['announced', 'announced_at']) + count += 1 + return JsonResponse({'announced_count': count}, status=200) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_download_all_thesis_grades_template(request): + """ + GET /supervisor/thesis-grades-all-template/ + Downloads Excel template with student name, roll number, and grade columns for ALL blocks. + Pre-fills with all students who have ungraded evaluations across any block. + """ + user = request.user + + try: + # Get faculty record + faculty = Faculty.objects.get(id__user=user) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) + + # Fetch all ungraded evaluations for this supervisor across all blocks + try: + evals = ThesisEvaluation.objects.select_related( + 'registration__student__id' + ).filter( + registration__thesis_topic__supervisor=faculty, + registration__status='verified', + grade__isnull=True + ).order_by('registration__student__id__id', 'block_number') + + if not evals.exists(): + return JsonResponse({'error': 'No ungraded evaluations found'}, status=400) + + # Group by student to get unique students and their blocks + from collections import defaultdict + student_blocks = defaultdict(lambda: {'name': '', 'blocks': {}}) + + for eval in evals: + student = eval.registration.student + roll_no = student.id.id + + if roll_no not in student_blocks: + student_blocks[roll_no]['name'] = student.id.user.get_full_name() + + student_blocks[roll_no]['blocks'][eval.block_number] = eval.id + + # Determine all blocks present + all_blocks = set() + for student_data in student_blocks.values(): + all_blocks.update(student_data['blocks'].keys()) + all_blocks = sorted(list(all_blocks)) + + # Generate Excel template + import openpyxl + output = BytesIO() + workbook = openpyxl.Workbook() + worksheet = workbook.active + worksheet.title = 'All Blocks' + + # Headers: Name, Roll Number, Block 1 Grade, Block 2 Grade, ..., Remarks + headers = ['Student Name', 'Roll Number'] + headers.extend([f'Block {b} Grade' for b in all_blocks]) + headers.append('Remarks') + + for col, header in enumerate(headers, 1): + worksheet.cell(row=1, column=col, value=header) + + # Add student data + for row, (roll_no, student_data) in enumerate(sorted(student_blocks.items()), 2): + try: + worksheet.cell(row=row, column=1, value=student_data['name']) + worksheet.cell(row=row, column=2, value=roll_no) + # Columns 3+ are grades for each block (leave empty for supervisor to fill) + # Last column is remarks (leave empty) + except Exception as e: + logger.error(f"Error writing row for {roll_no}: {str(e)}", exc_info=True) + + workbook.save(output) + output.seek(0) + + response = HttpResponse( + output.getvalue(), + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + response['Content-Disposition'] = f'attachment; filename="Thesis_Grades_All_Blocks_{_dt.datetime.now().strftime("%Y%m%d")}.xlsx"' + return response + + except Exception as e: + return JsonResponse({'error': f'Failed to generate template: {str(e)}'}, status=500) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@parser_classes([MultiPartParser, FormParser]) +def supervisor_upload_all_thesis_grades(request): + """ + POST /supervisor/thesis-grades-all/upload/ + Uploads and validates Excel file with grades for multiple blocks. + Expected columns: Name, Roll Number, Block 1 Grade, Block 2 Grade, ..., Remarks + Returns valid and invalid rows. + """ + user = request.user + uploaded_file = request.FILES.get('file') + + if not uploaded_file: + return JsonResponse({'error': 'file is required'}, status=400) + + try: + faculty = Faculty.objects.get(id__user=user) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) + + # Parse Excel file + try: + df = pd.read_excel(uploaded_file, engine='openpyxl') + except Exception: + try: + df = pd.read_excel(uploaded_file, engine='xlrd') + except Exception as e: + return JsonResponse({'error': f'Failed to read Excel file: {str(e)}'}, status=400) + + # Normalize column names + df.columns = [col.strip().lower() for col in df.columns] + + # Find roll number and remarks columns + roll_col = None + remarks_col = None + grade_cols = {} # {block_number: column_name} + + for col in df.columns: + if 'roll' in col and not roll_col: + roll_col = col + elif 'remark' in col and not remarks_col: + remarks_col = col + elif 'block' in col and 'grade' in col: + # Extract block number from "block X grade" or similar + match = re.search(r'block\s+(\d+)', col) + if match: + block_num = int(match.group(1)) + grade_cols[block_num] = col + + if not roll_col: + return JsonResponse({'error': 'Excel must contain "Roll Number" column'}, status=400) + if not grade_cols: + return JsonResponse({'error': 'Excel must contain at least one "Block X Grade" column'}, status=400) + + # Fetch all evaluations for this supervisor grouped by student and block + evals = ThesisEvaluation.objects.select_related( + 'registration__student__id' + ).filter( + registration__thesis_topic__supervisor=faculty, + registration__status='verified', + grade__isnull=True + ) + + # Create lookup: {roll_no: {block_num: eval_id}} + eval_lookup = defaultdict(dict) + for eval in evals: + roll_no = eval.registration.student.id.id + eval_lookup[roll_no][eval.block_number] = eval.id + + valid_rows = [] + invalid_rows = [] + + # Validate each row + for idx, row in df.iterrows(): + roll_no = str(row[roll_col]).strip() if pd.notna(row[roll_col]) else None + remarks = str(row[remarks_col]).strip() if remarks_col and pd.notna(row[remarks_col]) else '' + row_errors = [] + + if not roll_no: + row_errors.append('Roll number is required') + invalid_rows.append({ + 'row_num': idx + 2, + 'roll_no': 'N/A', + 'errors': row_errors + }) + continue + + if roll_no not in eval_lookup: + invalid_rows.append({ + 'row_num': idx + 2, + 'roll_no': roll_no, + 'errors': ['No student found with this roll number'] + }) + continue + + # Validate grades for each block + row_submissions = [] + for block_num, grade_col in grade_cols.items(): + grade = str(row[grade_col]).strip().upper() if pd.notna(row[grade_col]) else '' + + # Grade is optional if student doesn't have evaluation for that block + if not grade: + if block_num in eval_lookup[roll_no]: + row_errors.append(f'Block {block_num} grade is required for this student') + continue + + # If grade provided, validate it + if grade not in ('S', 'X'): + row_errors.append(f'Block {block_num} grade must be S or X, got {grade}') + continue + + # Check if evaluation exists for this student and block + if block_num not in eval_lookup[roll_no]: + row_errors.append(f'No evaluation found for Block {block_num}') + continue + + row_submissions.append({ + 'evaluation_id': eval_lookup[roll_no][block_num], + 'block_number': block_num, + 'grade': grade, + 'remarks': remarks + }) + + if row_errors: + invalid_rows.append({ + 'row_num': idx + 2, + 'roll_no': roll_no, + 'errors': row_errors + }) + elif row_submissions: + valid_rows.extend(row_submissions) + + return JsonResponse({ + 'valid_rows': valid_rows, + 'invalid_rows': invalid_rows + }, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_bulk_submit_all_thesis_grades(request): + """ + POST /supervisor/thesis-grades-all/bulk-submit/ + Submits multiple grades across multiple blocks in one request. + Body: { "submissions": [{"evaluation_id": 123, "grade": "S", "remarks": "..."}, ...] } + """ + user = request.user + submissions = request.data.get('submissions', []) + + if not submissions: + return JsonResponse({'error': 'submissions list is required'}, status=400) + + try: + faculty = Faculty.objects.get(id__user=user) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) + + now = _dt.datetime.now(_dt.timezone.utc) + + # Batch fetch all evaluations + eval_ids = [sub.get('evaluation_id') for sub in submissions if sub.get('evaluation_id')] + evaluations_dict = ThesisEvaluation.objects.select_related( + 'registration__thesis_topic' + ).filter(id__in=eval_ids).in_bulk(field_name='id') + + success_count = 0 + errors = [] + evaluations_to_update = [] + + # Process each submission + for idx, submission in enumerate(submissions): + eval_id = submission.get('evaluation_id') + grade = submission.get('grade', '').upper() + remarks = submission.get('remarks', '') + + try: + if not eval_id: + errors.append({'index': idx, 'error': 'evaluation_id is required'}) + continue + if grade not in ('S', 'X'): + errors.append({'index': idx, 'evaluation_id': eval_id, 'error': 'grade must be S or X'}) + continue + + if eval_id not in evaluations_dict: + errors.append({'index': idx, 'evaluation_id': eval_id, 'error': 'Evaluation not found'}) + continue + + evaluation = evaluations_dict[eval_id] + + # Verify ownership and permissions + if evaluation.registration.thesis_topic.supervisor != faculty: + errors.append({'index': idx, 'evaluation_id': eval_id, 'error': 'Not authorized for this evaluation'}) + continue + + # Update evaluation + evaluation.grade = grade + evaluation.remarks = remarks + evaluation.submitted_by = faculty + evaluation.submitted_at = now + + evaluations_to_update.append(evaluation) + success_count += 1 + + except Exception as e: + errors.append({'index': idx, 'evaluation_id': eval_id, 'error': str(e)}) + + # Batch update all at once + if evaluations_to_update: + ThesisEvaluation.objects.bulk_update( + evaluations_to_update, + fields=['grade', 'remarks', 'submitted_by', 'submitted_at'], + batch_size=500 + ) + + return JsonResponse({ + 'success_count': success_count, + 'error_count': len(errors), + 'errors': errors if errors else None + }, status=200) diff --git a/FusionIIIT/applications/academic_procedures/cron.py b/FusionIIIT/applications/academic_procedures/cron.py new file mode 100644 index 000000000..5afa61a8b --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/cron.py @@ -0,0 +1,19 @@ +from django_cron import CronJobBase, Schedule +from datetime import datetime + +class EmailPrinterCron(CronJobBase): + RUN_EVERY_MINS = 1000 # Runs every 2 minutes + + schedule = Schedule(run_every_mins=RUN_EVERY_MINS) + code = 'academic_procedures.email_printer_cron' # Unique code + + def do(self): + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + print(f"Email sent at {now}") + +# NOTE: the PhD thesis review-invitation lifecycle (send/expire/cascade/remind) +# used to live here as a django_cron job, but django_cron isn't an installed +# dependency in this project (only django-crontab/Celery are) so it never ran. +# It's now implemented as a real Celery beat task: +# applications/academic_procedures/tasks.py::process_review_invitations, +# registered in Fusion/settings/common.py CELERY_BEAT_SCHEDULE. \ No newline at end of file diff --git a/FusionIIIT/applications/academic_procedures/migrations/0019_committeemember_publicationcount_seminarcomment_seminarconsent_seminarentry_thesistopic.py b/FusionIIIT/applications/academic_procedures/migrations/0019_committeemember_publicationcount_seminarcomment_seminarconsent_seminarentry_thesistopic.py new file mode 100644 index 000000000..374e2b03e --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0019_committeemember_publicationcount_seminarcomment_seminarconsent_seminarentry_thesistopic.py @@ -0,0 +1,117 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0001_initial'), + ('globals', '0004_extrainfo_last_selected_role'), + ('academic_procedures', '0015_auto_20250709_1240'), + ] + + operations = [ + migrations.CreateModel( + name='ThesisTopic', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('supervisor_consented', models.BooleanField(default=False)), + ('co_supervisor_consented', models.BooleanField(default=False)), + ('category', models.CharField(choices=[('Regular', 'Regular'), ('Sponsored', 'Sponsored'), ('External', 'External')], max_length=20)), + ('broad_area', models.CharField(max_length=200)), + ('research_theme', models.TextField()), + ('external_name', models.CharField(blank=True, max_length=100)), + ('external_email', models.EmailField(blank=True, max_length=254)), + ('external_discipline', models.CharField(blank=True, max_length=100)), + ('external_institution', models.CharField(blank=True, max_length=200)), + ('pg_single', models.PositiveIntegerField(default=0)), + ('pg_shared', models.PositiveIntegerField(default=0)), + ('phd_single', models.PositiveIntegerField(default=0)), + ('phd_shared', models.PositiveIntegerField(default=0)), + ('status', models.CharField(choices=[('supervisor_pending', 'Pending with Supervisor'), ('hod_pending', 'Approved by Supervisor, Pending with HOD'), ('hod_rejected', 'Rejected by HOD, Returned to Supervisor'), ('dean_pending', 'Approved by HOD, Pending with Dean'), ('dean_rejected', 'Rejected by Dean, Returned to HOD'), ('dean_approved', 'Approved by Dean')], default='supervisor_pending', max_length=30)), + ('hod_remarks', models.TextField(blank=True)), + ('dean_remarks', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('co_supervisor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='theses_cosupervised', to='globals.faculty')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='academic_information.student')), + ('supervisor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='theses_supervised', to='globals.faculty')), + ], + ), + migrations.CreateModel( + name='SeminarEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.PositiveSmallIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('rpc_pending', 'Pending RPC Consent'), ('rpc_approved', 'Approved')], default='draft', max_length=20)), + ('seminar_date', models.DateField(blank=True, null=True)), + ('seminar_time', models.TimeField(blank=True, null=True)), + ('seminar_venue', models.CharField(blank=True, max_length=200)), + ('summary_prev', models.TextField(blank=True)), + ('summary_curr', models.TextField(blank=True)), + ('future_plan', models.TextField(blank=True)), + ('upload_doc', models.FileField(blank=True, null=True, upload_to='seminar_docs/')), + ('quality', models.CharField(blank=True, choices=[('Excellent', 'Excellent'), ('Good', 'Good'), ('Sat', 'Satisfactory'), ('Unsat', 'Unsatisfactory')], max_length=20)), + ('quantity', models.CharField(blank=True, choices=[('Enough', 'Enough'), ('Just', 'Just Sufficient'), ('Insuff', 'Insufficient')], max_length=20)), + ('overall_grade', models.CharField(blank=True, choices=[('S', 'S'), ('X', 'X')], max_length=2)), + ('expected_period', models.CharField(blank=True, choices=[('1', '1 year'), ('2', '2 years'), ('3', '3 years'), ('4', '4 years')], max_length=2)), + ('rec_assist', models.CharField(blank=True, choices=[('Yes', 'Yes'), ('No', 'No'), ('NA', 'Not Applicable')], max_length=3)), + ('rec_enhance', models.CharField(blank=True, choices=[('Yes', 'Yes'), ('No', 'No'), ('NA', 'Not Applicable')], max_length=3)), + ('rec_repeat', models.CharField(blank=True, choices=[('Yes', 'Yes'), ('NA', 'Not Applicable')], max_length=3)), + ('rec_open', models.CharField(blank=True, choices=[('Yes', 'Yes'), ('No', 'No')], max_length=3)), + ('thesis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seminars', to='academic_procedures.thesistopic')), + ], + ), + migrations.CreateModel( + name='SeminarConsent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('consented', models.BooleanField(default=False)), + ('timestamp', models.DateTimeField(auto_now=True)), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='globals.faculty')), + ('seminar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consents', to='academic_procedures.seminarentry')), + ], + options={ + 'unique_together': {('seminar', 'member')}, + }, + ), + migrations.CreateModel( + name='SeminarComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField()), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='globals.faculty')), + ('seminar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='academic_procedures.seminarentry')), + ], + options={ + 'ordering': ['-timestamp'], + 'unique_together': {('seminar', 'member')}, + }, + ), + migrations.CreateModel( + name='PublicationCount', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(choices=[('Journal', 'Journal'), ('Conference', 'Conference'), ('Submitted', 'Submitted')], max_length=50)), + ('submitted', models.PositiveIntegerField(default=0)), + ('accepted', models.PositiveIntegerField(default=0)), + ('published', models.PositiveIntegerField(default=0)), + ('seminar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pub_counts', to='academic_procedures.seminarentry')), + ], + options={ + 'unique_together': {('seminar', 'category')}, + }, + ), + migrations.CreateModel( + name='CommitteeMember', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='globals.faculty')), + ('thesis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='committee', to='academic_procedures.thesistopic')), + ], + options={ + 'unique_together': {('thesis', 'member')}, + }, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0020_reviewinvitation_thesissubmission.py b/FusionIIIT/applications/academic_procedures/migrations/0020_reviewinvitation_thesissubmission.py new file mode 100644 index 000000000..c6f400035 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0020_reviewinvitation_thesissubmission.py @@ -0,0 +1,74 @@ +import applications.academic_procedures.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('academic_procedures', '0018_auto_20260106_1355'), + ('academic_procedures', '0019_committeemember_publicationcount_seminarcomment_seminarconsent_seminarentry_thesistopic'), + ] + + operations = [ + migrations.CreateModel( + name='ThesisSubmission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file_token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, db_index=True)), + ('synopsis', models.FileField(upload_to=applications.academic_procedures.models.upload_synopsis)), + ('thesis_report', models.FileField(upload_to=applications.academic_procedures.models.upload_report)), + ('submitted_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('supervisor_approved_at', models.DateTimeField(blank=True, null=True)), + ('director_approved_at', models.DateTimeField(blank=True, null=True)), + ('status', models.CharField(choices=[('submitted', 'Submitted'), ('supervisor_review', 'Supervisor Review'), ('director_review', 'Director Review'), ('in_review', 'In External Review'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='submitted', max_length=30, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('director', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='directed_subs', to=settings.AUTH_USER_MODEL)), + ('supervisor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='supervised_subs', to=settings.AUTH_USER_MODEL)), + ('thesis', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='submission', to='academic_procedures.thesistopic')), + ], + options={ + 'ordering': ['-submitted_at'], + }, + ), + migrations.CreateModel( + name='ReviewInvitation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('prof_name', models.CharField(max_length=255, db_index=True)), + ('prof_position', models.CharField(max_length=255)), + ('prof_address', models.TextField()), + ('prof_phone', models.CharField(max_length=20)), + ('prof_email', models.EmailField(max_length=254, db_index=True)), + ('prof_time_ranking', models.PositiveSmallIntegerField(blank=True, null=True)), + ('priority', models.PositiveSmallIntegerField(default=0, db_index=True)), + ('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, db_index=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('completed', 'Completed'), ('expired', 'Expired')], default='pending', max_length=20, db_index=True)), + ('last_sent', models.DateTimeField(blank=True, null=True)), + ('review_form_sent', models.DateTimeField(blank=True, null=True)), + ('expires_at', models.DateTimeField(blank=True, null=True, db_index=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='academic_procedures.thesissubmission')), + ], + options={ + 'ordering': ['submission', 'priority'], + 'unique_together': {('submission', 'priority')}, + }, + ), + migrations.AddIndex( + model_name='reviewinvitation', + index=models.Index(fields=['submission', 'status'], name='academic_pr_submiss_858405_idx'), + ), + migrations.AddIndex( + model_name='reviewinvitation', + index=models.Index(fields=['status', 'last_sent'], name='academic_pr_status_7509a8_idx'), + ), + migrations.AddIndex( + model_name='thesissubmission', + index=models.Index(fields=['status', 'submitted_at'], name='academic_pr_status_953719_idx'), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0021_thesis_registration_models.py b/FusionIIIT/applications/academic_procedures/migrations/0021_thesis_registration_models.py new file mode 100644 index 000000000..6e0b92ecc --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0021_thesis_registration_models.py @@ -0,0 +1,53 @@ +# Generated by Django 3.1.5 on 2026-03-06 11:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0038_thesis_registration_models'), + ('academic_information', '0002_thesis_registration_models'), + ('academic_procedures', '0020_reviewinvitation_thesissubmission'), + ] + + operations = [ + migrations.CreateModel( + name='ThesisRegistration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('working_year', models.IntegerField(blank=True, null=True)), + ('academic_session', models.CharField(blank=True, max_length=9, null=True)), + ('status', models.CharField(choices=[('pending', 'Pending Verification'), ('verified', 'Verified'), ('rejected', 'Rejected')], default='pending', max_length=20)), + ('registered_on', models.DateTimeField(auto_now_add=True)), + ('verified_on', models.DateTimeField(blank=True, null=True)), + ('remarks', models.CharField(blank=True, max_length=500)), + ('semester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.semester')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='thesis_registrations', to='academic_information.student')), + ('thesis_slot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registrations', to='programme_curriculum.thesisslot')), + ('thesis_topic', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_registrations', to='academic_procedures.thesistopic')), + ], + options={ + 'db_table': 'ThesisRegistration', + 'unique_together': {('student', 'semester')}, + }, + ), + migrations.CreateModel( + name='ProgressSeminarRegistration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('working_year', models.IntegerField(blank=True, null=True)), + ('status', models.CharField(choices=[('pending', 'Pending Verification'), ('verified', 'Verified'), ('rejected', 'Rejected')], default='pending', max_length=20)), + ('registered_on', models.DateTimeField(auto_now_add=True)), + ('remarks', models.CharField(blank=True, max_length=500)), + ('progress_seminar_slot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registrations', to='programme_curriculum.progressseminarslot')), + ('semester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.semester')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress_seminar_registrations', to='academic_information.student')), + ], + options={ + 'db_table': 'ProgressSeminarRegistration', + 'unique_together': {('student', 'semester')}, + }, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0022_thesis_registration_add_credits.py b/FusionIIIT/applications/academic_procedures/migrations/0022_thesis_registration_add_credits.py new file mode 100644 index 000000000..5880362c1 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0022_thesis_registration_add_credits.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-07 18:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0021_thesis_registration_models'), + ] + + operations = [ + migrations.AddField( + model_name='thesisregistration', + name='credits', + field=models.PositiveSmallIntegerField(choices=[(3, '3 Credits'), (6, '6 Credits'), (9, '9 Credits'), (12, '12 Credits')], default=6, help_text='Credits the student is registering for this semester (3/6/9/12)'), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0023_thesis_evaluation_models.py b/FusionIIIT/applications/academic_procedures/migrations/0023_thesis_evaluation_models.py new file mode 100644 index 000000000..0f2dd2b7c --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0023_thesis_evaluation_models.py @@ -0,0 +1,102 @@ +# Manually authored migration — 0023_thesis_evaluation_models +# Creates ThesisEvaluation and ProgressSeminarEvaluation tables. +# Generated to match models added in academic_procedures/models.py. + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0022_thesis_registration_add_credits'), + ('globals', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # ------------------------------------------------------------------ # + # ThesisEvaluation # + # ------------------------------------------------------------------ # + migrations.CreateModel( + name='ThesisEvaluation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('block_number', models.PositiveSmallIntegerField( + help_text='Sequential block index starting at 1 (max = registration.credits ÷ 3)', + )), + ('grade', models.CharField( + blank=True, choices=[('S', 'Satisfactory'), ('X', 'Unsatisfactory')], + max_length=1, null=True, + )), + ('submitted_at', models.DateTimeField(blank=True, null=True)), + ('remarks', models.TextField(blank=True)), + ('verified', models.BooleanField(default=False)), + ('verified_at', models.DateTimeField(blank=True, null=True)), + ('announced', models.BooleanField(default=False)), + ('announced_at', models.DateTimeField(blank=True, null=True)), + ('registration', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='evaluations', + to='academic_procedures.ThesisRegistration', + )), + ('submitted_by', models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='thesis_grades_submitted', + to='globals.Faculty', + )), + ('verified_by', models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='thesis_grades_verified', + to=settings.AUTH_USER_MODEL, + )), + ], + options={ + 'db_table': 'ThesisEvaluation', + 'ordering': ['registration', 'block_number'], + 'unique_together': {('registration', 'block_number')}, + }, + ), + # ------------------------------------------------------------------ # + # ProgressSeminarEvaluation # + # ------------------------------------------------------------------ # + migrations.CreateModel( + name='ProgressSeminarEvaluation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('grade', models.CharField( + blank=True, choices=[('S', 'Satisfactory'), ('X', 'Unsatisfactory')], + max_length=1, null=True, + )), + ('submitted_at', models.DateTimeField(blank=True, null=True)), + ('remarks', models.TextField(blank=True)), + ('verified', models.BooleanField(default=False)), + ('verified_at', models.DateTimeField(blank=True, null=True)), + ('announced', models.BooleanField(default=False)), + ('announced_at', models.DateTimeField(blank=True, null=True)), + ('registration', models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='evaluation', + to='academic_procedures.ProgressSeminarRegistration', + )), + ('submitted_by', models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='seminar_grades_submitted', + to='globals.Faculty', + )), + ('verified_by', models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='seminar_grades_verified', + to=settings.AUTH_USER_MODEL, + )), + ], + options={ + 'db_table': 'ProgressSeminarEvaluation', + }, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0024_merge_20260314_1604.py b/FusionIIIT/applications/academic_procedures/migrations/0024_merge_20260314_1604.py new file mode 100644 index 000000000..590cf7423 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0024_merge_20260314_1604.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-03-14 16:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0019_swayamreplacementrequest'), + ('academic_procedures', '0023_thesis_evaluation_models'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0025_auto_20260705_1724.py b/FusionIIIT/applications/academic_procedures/migrations/0025_auto_20260705_1724.py new file mode 100644 index 000000000..dd8c22a6b --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0025_auto_20260705_1724.py @@ -0,0 +1,49 @@ +# Generated by Django 3.1.5 on 2026-07-05 17:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('academic_procedures', '0024_merge_20260314_1604'), + ] + + operations = [ + migrations.AlterModelOptions( + name='reviewinvitation', + options={'ordering': ['submission', 'examiner_type', 'priority']}, + ), + migrations.AddField( + model_name='reviewinvitation', + name='examiner_type', + field=models.CharField(choices=[('indian', 'Indian'), ('foreign', 'Foreign')], db_index=True, default='indian', max_length=10), + ), + migrations.AddField( + model_name='thesissubmission', + name='dean', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deaned_subs', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='thesissubmission', + name='dean_approved_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='thesissubmission', + name='dean_invited_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='thesissubmission', + name='status', + field=models.CharField(choices=[('submitted', 'Submitted'), ('dean_panel_review', 'Pending Dean Panel Approval'), ('director_review', 'Pending Director Prioritization'), ('dean_invite_pending', 'Pending Dean Invitation'), ('in_review', 'In External Review'), ('completed', 'Review Completed'), ('approved', 'Approved'), ('rejected', 'Rejected')], db_index=True, default='submitted', max_length=30), + ), + migrations.AlterUniqueTogether( + name='reviewinvitation', + unique_together={('submission', 'examiner_type', 'priority')}, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0026_merge_20260705_1854.py b/FusionIIIT/applications/academic_procedures/migrations/0026_merge_20260705_1854.py new file mode 100644 index 000000000..031d5eb00 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0026_merge_20260705_1854.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-07-05 18:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0020_backfill_course_registration_session'), + ('academic_procedures', '0025_auto_20260705_1724'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0027_reviewinvitation_prof_fax.py b/FusionIIIT/applications/academic_procedures/migrations/0027_reviewinvitation_prof_fax.py new file mode 100644 index 000000000..af1b39d7a --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0027_reviewinvitation_prof_fax.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-07-09 17:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0026_merge_20260705_1854'), + ] + + operations = [ + migrations.AddField( + model_name='reviewinvitation', + name='prof_fax', + field=models.CharField(blank=True, default='', max_length=20), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0028_examinerbankdetails_thesisreview.py b/FusionIIIT/applications/academic_procedures/migrations/0028_examinerbankdetails_thesisreview.py new file mode 100644 index 000000000..eec0c48cc --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0028_examinerbankdetails_thesisreview.py @@ -0,0 +1,47 @@ +# Generated by Django 3.1.5 on 2026-07-09 19:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0027_reviewinvitation_prof_fax'), + ] + + operations = [ + migrations.CreateModel( + name='ThesisReview', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('originality_presentation', models.TextField(blank=True, default='')), + ('quality_comparable', models.BooleanField(blank=True, null=True)), + ('new_ideas_original', models.BooleanField(blank=True, null=True)), + ('correction_severity', models.CharField(blank=True, choices=[('none', 'None'), ('minor', 'Minor'), ('major', 'Major')], default='', max_length=10)), + ('technical_content', models.TextField(blank=True, default='')), + ('highlights', models.TextField(blank=True, default='')), + ('suggestions', models.TextField(blank=True, default='')), + ('defense_questions', models.TextField(blank=True, default='')), + ('recommendation', models.CharField(choices=[('accept', 'Acceptable in present form for award of the PhD degree'), ('accept_with_corrections', 'Acceptable; suggested corrections/modifications to be incorporated'), ('needs_improvement', "Needs technical improvement to the examiner's satisfaction"), ('reject', 'Rejected -- thesis does not contain novel work')], max_length=30)), + ('submitted_at', models.DateTimeField(auto_now_add=True)), + ('invitation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='review', to='academic_procedures.reviewinvitation')), + ], + ), + migrations.CreateModel( + name='ExaminerBankDetails', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('beneficiary_name', models.CharField(blank=True, default='', max_length=255)), + ('bank_name', models.CharField(blank=True, default='', max_length=255)), + ('bank_address', models.TextField(blank=True, default='')), + ('account_no', models.CharField(blank=True, default='', max_length=50)), + ('ifsc_code', models.CharField(blank=True, default='', max_length=20)), + ('pan_no', models.CharField(blank=True, default='', max_length=20)), + ('iban_no', models.CharField(blank=True, default='', max_length=40)), + ('swift_code', models.CharField(blank=True, default='', max_length=20)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('invitation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='bank_details', to='academic_procedures.reviewinvitation')), + ], + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/models.py b/FusionIIIT/applications/academic_procedures/models.py index ff095c117..b24a77030 100644 --- a/FusionIIIT/applications/academic_procedures/models.py +++ b/FusionIIIT/applications/academic_procedures/models.py @@ -4,7 +4,7 @@ from django.contrib.postgres.fields import ArrayField from django.contrib.auth import get_user_model from applications.academic_information.models import Course, Student, Curriculum -from applications.programme_curriculum.models import Course as Courses, Semester, CourseSlot, Batch +from applications.programme_curriculum.models import Course as Courses, Semester, CourseSlot, Batch, ThesisSlot, ProgressSeminarSlot from applications.globals.models import DepartmentInfo, ExtraInfo, Faculty from django.utils import timezone @@ -1096,4 +1096,540 @@ class FeedbackFilled(models.Model): filled_at = models.DateTimeField(auto_now_add=True) class Meta: - unique_together = ("student", "semester_no") \ No newline at end of file + unique_together = ("student", "semester_no") + + +# ============================================================================ +# PhD-SPECIFIC MODELS (Added for PhD student management) +# ============================================================================ + +class ThesisTopic(models.Model): + """Central thesis record with student submission fields and approval status.""" + STATUS_CHOICES = [ + ('supervisor_pending', 'Pending with Supervisor'), + ('hod_pending', 'Approved by Supervisor, Pending with HOD'), + ('hod_rejected', 'Rejected by HOD, Returned to Supervisor'), + ('dean_pending', 'Approved by HOD, Pending with Dean'), + ('dean_rejected', 'Rejected by Dean, Returned to HOD'), + ('dean_approved', 'Approved by Dean'), + ] + + student = models.ForeignKey(Student, on_delete=models.CASCADE) + supervisor = models.ForeignKey(Faculty, related_name='theses_supervised', on_delete=models.CASCADE) + co_supervisor = models.ForeignKey(Faculty, related_name='theses_cosupervised', on_delete=models.CASCADE, null=True, blank=True) + supervisor_consented = models.BooleanField(default=False) + co_supervisor_consented = models.BooleanField(default=False) + + category = models.CharField(max_length=20, choices=[ + ('Regular', 'Regular'), + ('Sponsored', 'Sponsored'), + ('External', 'External') + ]) + broad_area = models.CharField(max_length=200) + research_theme = models.TextField() + + external_name = models.CharField(max_length=100, blank=True) + external_email = models.EmailField(blank=True) + external_discipline = models.CharField(max_length=100, blank=True) + external_institution = models.CharField(max_length=200, blank=True) + + pg_single = models.PositiveIntegerField(default=0) + pg_shared = models.PositiveIntegerField(default=0) + phd_single = models.PositiveIntegerField(default=0) + phd_shared = models.PositiveIntegerField(default=0) + + status = models.CharField(max_length=30, choices=STATUS_CHOICES, default='supervisor_pending') + hod_remarks = models.TextField(blank=True) + dean_remarks = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + name = self.student.id.user.get_full_name() + theme = self.research_theme[:30] + return f"{name} — {theme}" + + +class CommitteeMember(models.Model): + """RPC committee member for each thesis.""" + thesis = models.ForeignKey(ThesisTopic, related_name='committee', on_delete=models.CASCADE) + member = models.ForeignKey(Faculty, on_delete=models.CASCADE) + + class Meta: + unique_together = ('thesis', 'member') + + def __str__(self): + return f"{self.member} on {self.thesis}" + + +class SeminarEntry(models.Model): + """PhD Seminar reports with versioning and RPC approval.""" + thesis = models.ForeignKey(ThesisTopic, on_delete=models.CASCADE, related_name='seminars') + version = models.PositiveSmallIntegerField() + created_at = models.DateTimeField(auto_now_add=True) + + STATUS_CHOICES = [ + ('draft', 'Draft'), + ('rpc_pending', 'Pending RPC Consent'), + ('rpc_approved','Approved'), + ] + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') + + # Logistics + seminar_date = models.DateField(null=True, blank=True) + seminar_time = models.TimeField(null=True, blank=True) + seminar_venue = models.CharField(max_length=200, blank=True) + + # Summaries + summary_prev = models.TextField(blank=True) + summary_curr = models.TextField(blank=True) + future_plan = models.TextField(blank=True) + upload_doc = models.FileField(upload_to='seminar_docs/', null=True, blank=True) + + # RPC Evaluation fields + quality = models.CharField( + max_length=20, + choices=[('Excellent','Excellent'), + ('Good','Good'), + ('Sat','Satisfactory'), + ('Unsat','Unsatisfactory')], + blank=True + ) + quantity = models.CharField( + max_length=20, + choices=[('Enough','Enough'), + ('Just','Just Sufficient'), + ('Insuff','Insufficient')], + blank=True + ) + overall_grade = models.CharField( + max_length=2, + choices=[('S','S'), ('X','X')], + blank=True + ) + expected_period = models.CharField( + max_length=2, + choices=[('1','1 year'), + ('2','2 years'), + ('3','3 years'), + ('4','4 years')], + blank=True + ) + rec_assist = models.CharField( + max_length=3, + choices=[('Yes','Yes'), + ('No','No'), + ('NA','Not Applicable')], + blank=True + ) + rec_enhance = models.CharField( + max_length=3, + choices=[('Yes','Yes'), + ('No','No'), + ('NA','Not Applicable')], + blank=True + ) + rec_repeat = models.CharField( + max_length=3, + choices=[('Yes','Yes'), + ('NA','Not Applicable')], + blank=True + ) + rec_open = models.CharField( + max_length=3, + choices=[('Yes','Yes'), + ('No','No')], + blank=True + ) + + def __str__(self): + return f"Seminar {self.version} for {self.thesis}" + + +class PublicationCount(models.Model): + """Publication tracking for PhD seminar reports.""" + seminar = models.ForeignKey(SeminarEntry, related_name='pub_counts', on_delete=models.CASCADE) + category = models.CharField(max_length=50, choices=[ + ('Journal','Journal'), + ('Conference','Conference'), + ('Submitted','Submitted'), + ]) + submitted = models.PositiveIntegerField(default=0) + accepted = models.PositiveIntegerField(default=0) + published = models.PositiveIntegerField(default=0) + + class Meta: + unique_together = ('seminar','category') + + +class SeminarConsent(models.Model): + """RPC member consent for seminar.""" + seminar = models.ForeignKey(SeminarEntry, related_name='consents', on_delete=models.CASCADE) + member = models.ForeignKey(Faculty, on_delete=models.CASCADE) + consented = models.BooleanField(default=False) + timestamp = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('seminar','member') + + +class SeminarComment(models.Model): + """RPC member comments on seminar.""" + seminar = models.ForeignKey(SeminarEntry, related_name='comments', on_delete=models.CASCADE) + member = models.ForeignKey(Faculty, on_delete=models.CASCADE) + text = models.TextField() + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('seminar','member') + ordering = ['-timestamp'] + + +import uuid + +def upload_synopsis(instance, filename): + """Upload path for thesis synopsis.""" + ext = filename.split('.')[-1] + return f"synopsis/{instance.file_token}.{ext}" + +def upload_report(instance, filename): + """Upload path for thesis report.""" + ext = filename.split('.')[-1] + return f"reports/{instance.file_token}.{ext}" + + +class ThesisSubmission(models.Model): + """PhD Thesis submission with file uploads.""" + STATUS_CHOICES = [ + ('submitted', 'Submitted'), + ('dean_panel_review', 'Pending Dean Panel Approval'), + ('director_review', 'Pending Director Prioritization'), + ('dean_invite_pending', 'Pending Dean Invitation'), + ('in_review', 'In External Review'), + ('completed', 'Review Completed'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ] + + thesis = models.OneToOneField(ThesisTopic, on_delete=models.CASCADE, related_name='submission') + file_token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + synopsis = models.FileField(upload_to=upload_synopsis) + thesis_report = models.FileField(upload_to=upload_report) + submitted_at = models.DateTimeField(auto_now_add=True, db_index=True) + supervisor = models.ForeignKey('auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='supervised_subs') + supervisor_approved_at = models.DateTimeField(null=True, blank=True) + dean = models.ForeignKey('auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='deaned_subs') + dean_approved_at = models.DateTimeField(null=True, blank=True) + dean_invited_at = models.DateTimeField(null=True, blank=True) + director = models.ForeignKey('auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='directed_subs') + director_approved_at = models.DateTimeField(null=True, blank=True) + status = models.CharField(max_length=30, choices=STATUS_CHOICES, default='submitted', db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-submitted_at'] + indexes = [ + models.Index(fields=['status', 'submitted_at']), + ] + + def __str__(self): + return f"Submission for {self.thesis.research_theme}" + + +class ReviewInvitation(models.Model): + """External reviewer invitation for thesis.""" + EXAMINER_TYPE_CHOICES = [ + ('indian', 'Indian'), + ('foreign', 'Foreign'), + ] + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('accepted', 'Accepted'), + ('rejected', 'Rejected'), + ('completed', 'Completed'), + ('expired', 'Expired'), + ] + submission = models.ForeignKey(ThesisSubmission, on_delete=models.CASCADE, related_name='invitations') + examiner_type = models.CharField(max_length=10, choices=EXAMINER_TYPE_CHOICES, default='indian', db_index=True) + prof_name = models.CharField(max_length=255, db_index=True) + prof_position = models.CharField(max_length=255) + prof_address = models.TextField() + prof_phone = models.CharField(max_length=20) + prof_fax = models.CharField(max_length=20, blank=True, default='') + prof_email = models.EmailField(db_index=True) + prof_time_ranking = models.PositiveSmallIntegerField(null=True, blank=True) + priority = models.PositiveSmallIntegerField(default=0, db_index=True) + token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', db_index=True) + last_sent = models.DateTimeField(null=True, blank=True) + review_form_sent= models.DateTimeField(null=True, blank=True) + expires_at = models.DateTimeField(null=True, blank=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [('submission', 'examiner_type', 'priority')] + ordering = ['submission', 'examiner_type', 'priority'] + indexes = [ + models.Index(fields=['submission', 'status']), + models.Index(fields=['status', 'last_sent']), + ] + + def is_expired(self): + """Check if the invitation has expired.""" + return self.expires_at and timezone.now() >= self.expires_at + + def is_finalized(self): + """Check if the invitation is in a final state.""" + return self.status in ['completed', 'expired', 'rejected'] + + +class ThesisReview(models.Model): + """An examiner's formal evaluation, mirroring the institute's official + 'Examination Report of PhD Student' form.""" + CORRECTION_CHOICES = [ + ('none', 'None'), + ('minor', 'Minor'), + ('major', 'Major'), + ] + RECOMMENDATION_CHOICES = [ + ('accept', 'Acceptable in present form for award of the PhD degree'), + ('accept_with_corrections', 'Acceptable; suggested corrections/modifications to be incorporated'), + ('needs_improvement', "Needs technical improvement to the examiner's satisfaction"), + ('reject', 'Rejected -- thesis does not contain novel work'), + ] + + invitation = models.OneToOneField(ReviewInvitation, on_delete=models.CASCADE, related_name='review') + + # A. General features of thesis + originality_presentation = models.TextField(blank=True, default='') + quality_comparable = models.BooleanField(null=True, blank=True) + new_ideas_original = models.BooleanField(null=True, blank=True) + + # B. Comments + correction_severity = models.CharField(max_length=10, choices=CORRECTION_CHOICES, blank=True, default='') + technical_content = models.TextField(blank=True, default='') + highlights = models.TextField(blank=True, default='') + + # C, D + suggestions = models.TextField(blank=True, default='') + defense_questions = models.TextField(blank=True, default='') + + # E. Specific recommendation + recommendation = models.CharField(max_length=30, choices=RECOMMENDATION_CHOICES) + + submitted_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Review by {self.invitation.prof_name} ({self.recommendation})" + + +class ExaminerBankDetails(models.Model): + """Honorarium payment details for an external examiner. + + Kept as its own model, deliberately isolated from ThesisReview / + ReviewInvitation serialization -- never include this in dashboard or + listing API responses. + """ + invitation = models.OneToOneField(ReviewInvitation, on_delete=models.CASCADE, related_name='bank_details') + + beneficiary_name = models.CharField(max_length=255, blank=True, default='') + bank_name = models.CharField(max_length=255, blank=True, default='') + bank_address = models.TextField(blank=True, default='') + account_no = models.CharField(max_length=50, blank=True, default='') + # Indian examiners provide IFSC + PAN; foreign examiners provide IBAN + SWIFT. + ifsc_code = models.CharField(max_length=20, blank=True, default='') + pan_no = models.CharField(max_length=20, blank=True, default='') + iban_no = models.CharField(max_length=40, blank=True, default='') + swift_code = models.CharField(max_length=20, blank=True, default='') + + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Bank details for {self.invitation.prof_name}" + + def __str__(self): + return f"{self.prof_name} - {self.submission.thesis.research_theme} ({self.status})" + + +# =========================================================================== +# Thesis Slot & Progress Seminar Semester-Level Registration +# =========================================================================== + +class ThesisRegistration(models.Model): + """Records a PhD student's semester-level thesis slot enrollment. + + Analogous to course_registration / FinalRegistration for courses. + One record per student per semester; admin verifies after submission. + """ + STATUS_CHOICES = [ + ('pending', 'Pending Verification'), + ('verified', 'Verified'), + ('rejected', 'Rejected'), + ] + + THESIS_CREDIT_CHOICES = [(3, '3 Credits'), (6, '6 Credits'), (9, '9 Credits'), (12, '12 Credits')] + + student = models.ForeignKey(Student, on_delete=models.CASCADE, + related_name='thesis_registrations') + thesis_slot = models.ForeignKey(ThesisSlot, on_delete=models.CASCADE, + related_name='registrations') + thesis_topic = models.ForeignKey('ThesisTopic', on_delete=models.SET_NULL, + null=True, blank=True, + related_name='thesis_registrations') + semester = models.ForeignKey(Semester, on_delete=models.CASCADE) + credits = models.PositiveSmallIntegerField( + choices=THESIS_CREDIT_CHOICES, + default=6, + help_text='Credits the student is registering for this semester (3/6/9/12)', + ) + working_year = models.IntegerField(null=True, blank=True) + academic_session = models.CharField(max_length=9, null=True, blank=True) # e.g. "2025-26" + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + registered_on = models.DateTimeField(auto_now_add=True) + verified_on = models.DateTimeField(null=True, blank=True) + remarks = models.CharField(max_length=500, blank=True) + + class Meta: + unique_together = ('student', 'semester') + db_table = 'ThesisRegistration' + + def __str__(self): + return f"{self.student} — {self.thesis_slot.name} ({self.semester})" + + +class ProgressSeminarRegistration(models.Model): + """Records a PhD student's semester-level progress seminar enrollment. + + Analogous to ThesisRegistration; one record per student per semester. + """ + STATUS_CHOICES = [ + ('pending', 'Pending Verification'), + ('verified', 'Verified'), + ('rejected', 'Rejected'), + ] + + student = models.ForeignKey(Student, on_delete=models.CASCADE, + related_name='progress_seminar_registrations') + progress_seminar_slot = models.ForeignKey(ProgressSeminarSlot, on_delete=models.CASCADE, + related_name='registrations') + semester = models.ForeignKey(Semester, on_delete=models.CASCADE) + working_year = models.IntegerField(null=True, blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + registered_on = models.DateTimeField(auto_now_add=True) + remarks = models.CharField(max_length=500, blank=True) + + class Meta: + unique_together = ('student', 'semester') + db_table = 'ProgressSeminarRegistration' + + def __str__(self): + return f"{self.student} — {self.progress_seminar_slot.name} ({self.semester})" + + +# =========================================================================== +# Thesis & Progress Seminar Grade Evaluation +# =========================================================================== + +class ThesisEvaluation(models.Model): + """Grade record for one evaluation block within a ThesisRegistration. + + A student who registers for N credits gets N÷3 blocks (1 block per 3 credits). + e.g. 12 credits → 4 blocks, each graded S or X independently. + Blocks are auto-created when the admin verifies the ThesisRegistration. + """ + GRADE_CHOICES = [('S', 'Satisfactory'), ('X', 'Unsatisfactory')] + + registration = models.ForeignKey( + ThesisRegistration, + on_delete=models.CASCADE, + related_name='evaluations', + ) + block_number = models.PositiveSmallIntegerField( + help_text='Sequential block index starting at 1 (max = registration.credits ÷ 3)', + ) + + # Grade — null until supervisor submits + grade = models.CharField( + max_length=1, choices=GRADE_CHOICES, null=True, blank=True, + ) + submitted_by = models.ForeignKey( + Faculty, null=True, blank=True, + on_delete=models.SET_NULL, related_name='thesis_grades_submitted', + ) + submitted_at = models.DateTimeField(null=True, blank=True) + remarks = models.TextField(blank=True) + + # Admin lifecycle + verified = models.BooleanField(default=False) + verified_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='thesis_grades_verified', + ) + verified_at = models.DateTimeField(null=True, blank=True) + + announced = models.BooleanField(default=False) + announced_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = ('registration', 'block_number') + ordering = ['registration', 'block_number'] + db_table = 'ThesisEvaluation' + + def __str__(self): + g = self.grade or '—' + return ( + f"Block {self.block_number}/{self.registration.credits // 3} " + f"| {self.registration.student} | Sem {self.registration.semester.semester_no} " + f"| Grade: {g}" + ) + + @property + def total_blocks(self): + return self.registration.credits // 3 + + +class ProgressSeminarEvaluation(models.Model): + """Grade record for a ProgressSeminarRegistration. + + Progress seminars are fixed at 3 credits → always exactly 1 evaluation block. + """ + GRADE_CHOICES = [('S', 'Satisfactory'), ('X', 'Unsatisfactory')] + + registration = models.OneToOneField( + ProgressSeminarRegistration, + on_delete=models.CASCADE, + related_name='evaluation', + ) + + grade = models.CharField( + max_length=1, choices=GRADE_CHOICES, null=True, blank=True, + ) + submitted_by = models.ForeignKey( + Faculty, null=True, blank=True, + on_delete=models.SET_NULL, related_name='seminar_grades_submitted', + ) + submitted_at = models.DateTimeField(null=True, blank=True) + remarks = models.TextField(blank=True) + + verified = models.BooleanField(default=False) + verified_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='seminar_grades_verified', + ) + verified_at = models.DateTimeField(null=True, blank=True) + + announced = models.BooleanField(default=False) + announced_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = 'ProgressSeminarEvaluation' + + def __str__(self): + g = self.grade or '—' + return ( + f"{self.registration.student} | Sem {self.registration.semester.semester_no} " + f"| Grade: {g}" + ) \ No newline at end of file diff --git a/FusionIIIT/applications/academic_procedures/tasks.py b/FusionIIIT/applications/academic_procedures/tasks.py new file mode 100644 index 000000000..bbd13d356 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/tasks.py @@ -0,0 +1,84 @@ +from __future__ import absolute_import, unicode_literals + +import logging +from datetime import timedelta + +from celery import shared_task +from django.utils import timezone + +from .models import ThesisSubmission, ReviewInvitation +from .utils import send_invitation_email, send_review_form_email, advance_invitation, INVITATION_TIMEOUT_DAYS + +logger = logging.getLogger(__name__) + +REMINDER_INTERVAL_DAYS = 3 + + +@shared_task() +def process_review_invitations(): + """ + Runs daily. For each in-review submission, and each examiner category + (Indian / Foreign) independently: + 1) If the lowest-priority invite in that category was never sent -> send it. + 2) If it has been pending past the timeout -> expire it and advance to + the next-ranked examiner in that category. + 3) If still pending -> resend a reminder every few days. + 4) If accepted -> send the daily review-form link. + """ + now = timezone.now() + submissions = ThesisSubmission.objects.filter(status='in_review') + logger.info(f"process_review_invitations starting for {submissions.count()} submissions") + + for sub in submissions: + for examiner_type in ('indian', 'foreign'): + invites = ( + ReviewInvitation.objects + .filter(submission=sub, examiner_type=examiner_type) + .order_by('priority') + ) + # This category already has a completed review -- nothing left + # to send/expire/remind for it. The other category (if still + # pending) continues independently below. + if invites.filter(status='completed').exists(): + continue + + # Exactly one examiner per category should be "live" at a time: + # the lowest-priority one who hasn't declined/expired. Acting on + # any invite beyond this one would mean contacting a lower-rank + # examiner while a higher-rank one is still legitimately pending. + inv = invites.exclude(status__in=['expired', 'rejected']).first() + if inv is None: + continue + + try: + if inv.last_sent is None: + inv.last_sent = now + inv.expires_at = now + timedelta(days=INVITATION_TIMEOUT_DAYS) + inv.save(update_fields=['last_sent', 'expires_at']) + send_invitation_email(inv) + logger.info(f"Sent initial invitation for token {inv.token}") + + elif inv.is_expired(): + inv.status = 'expired' + inv.save(update_fields=['status']) + logger.info(f"Expired invitation {inv.token} ({INVITATION_TIMEOUT_DAYS}-day timeout)") + advance_invitation(sub, examiner_type) + + elif inv.status == 'pending' and now >= inv.last_sent + timedelta(days=REMINDER_INTERVAL_DAYS): + send_invitation_email(inv) + inv.last_sent = now + inv.save(update_fields=['last_sent']) + logger.info(f"Sent reminder for token {inv.token}") + + elif inv.status == 'accepted' and ( + inv.review_form_sent is None or now >= inv.review_form_sent + timedelta(days=1) + ): + send_review_form_email(inv) + inv.review_form_sent = now + inv.save(update_fields=['review_form_sent']) + logger.info(f"Sent review-form link for token {inv.token}") + except Exception as e: + logger.exception(f"Error processing invitation {inv.token} for submission {sub.id}: {e}") + continue + + logger.info("process_review_invitations completed") diff --git a/FusionIIIT/applications/academic_procedures/utils.py b/FusionIIIT/applications/academic_procedures/utils.py new file mode 100644 index 000000000..8936386d9 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/utils.py @@ -0,0 +1,141 @@ +from datetime import timedelta + +from django.core.mail import EmailMultiAlternatives +from django.conf import settings +from django.template.loader import render_to_string +from django.utils import timezone +from django.utils.html import strip_tags +import logging + +logger = logging.getLogger(__name__) + +INVITATION_TIMEOUT_DAYS = 15 + +def send_invitation_email(inv): + """ + Send the initial invitation email to the professor with template rendering. + """ + try: + thesis_title = inv.submission.thesis.research_theme + frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:5173') + accept_url = f"{frontend_url}/thesis-invitation/{inv.token}/accept" + reject_url = f"{frontend_url}/thesis-invitation/{inv.token}/reject" + expires_at = inv.expires_at.strftime('%Y-%m-%d') if inv.expires_at else 'N/A' + + + context = { + 'prof_name': inv.prof_name, + 'thesis_title': thesis_title, + 'accept_url': accept_url, + 'reject_url': reject_url, + 'expires_at': expires_at, + } + + subject = f"Invitation to review: {thesis_title}" + html_content = render_to_string('email/invitation.html', context) + text_content = render_to_string('email/invitation.txt', context) + + msg = EmailMultiAlternatives( + subject, + text_content, + settings.DEFAULT_FROM_EMAIL, + [inv.prof_email], + ) + msg.attach_alternative(html_content, 'text/html') + msg.send() + logger.info(f"Invitation email sent to {inv.prof_email} for token {inv.token}") + except Exception as e: + logger.exception(f"Failed to send invitation email for token {inv.token}: {e}") + raise + + +def send_review_form_email(inv): + """ + Send the review form link after the professor has accepted the invitation. + """ + try: + thesis_title = inv.submission.thesis.research_theme + frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:5173') + review_url = f"{frontend_url}/thesis-evaluation/{inv.token}" + + context = { + 'prof_name': inv.prof_name, + 'thesis_title': thesis_title, + 'review_url': review_url, + } + + subject = f"Review form: {thesis_title}" + html_content = render_to_string('email/review_form.html', context) + text_content = render_to_string('email/review_form.txt', context) + + msg = EmailMultiAlternatives( + subject, + text_content, + settings.DEFAULT_FROM_EMAIL, + [inv.prof_email], + ) + msg.attach_alternative(html_content, 'text/html') + msg.send() + logger.info(f"Review form email sent to {inv.prof_email} for token {inv.token}") + except Exception as e: + logger.exception(f"Failed to send review form email for token {inv.token}: {e}") + raise + + +def send_thank_you_email(inv): + """ + Send a thank-you note once the professor submits their review. + """ + try: + thesis_title = inv.submission.thesis.research_theme + + context = { + 'prof_name': inv.prof_name, + 'thesis_title': thesis_title, + } + + subject = f"Thank you for reviewing: {thesis_title}" + html_content = render_to_string('email/thank_you.html', context) + text_content = render_to_string('email/thank_you.txt', context) + + msg = EmailMultiAlternatives( + subject, + text_content, + settings.DEFAULT_FROM_EMAIL, + [inv.prof_email], + ) + msg.attach_alternative(html_content, 'text/html') + msg.send() + logger.info(f"Thank you email sent to {inv.prof_email} for token {inv.token}") + except Exception as e: + logger.exception(f"Failed to send thank you email for token {inv.token}: {e}") + raise + + +def advance_invitation(submission, examiner_type): + """ + Send the invitation to the next-ranked, not-yet-sent examiner of the + given type (indian/foreign) for this submission. Used both when an + examiner declines and by the daily timeout job, so a decline/timeout + always falls through to the next professor in the Director's priority + order for that category. + """ + from .models import ReviewInvitation + + next_inv = ( + ReviewInvitation.objects + .filter(submission=submission, examiner_type=examiner_type, last_sent__isnull=True) + .order_by('priority') + .first() + ) + if next_inv is None: + logger.warning( + f"No more {examiner_type} examiners left to invite for submission {submission.id}" + ) + return None + + next_inv.last_sent = timezone.now() + next_inv.expires_at = timezone.now() + timedelta(days=INVITATION_TIMEOUT_DAYS) + next_inv.save(update_fields=['last_sent', 'expires_at']) + send_invitation_email(next_inv) + return next_inv diff --git a/FusionIIIT/applications/academic_procedures/views.py b/FusionIIIT/applications/academic_procedures/views.py index a1f6f2a77..9d763beff 100644 --- a/FusionIIIT/applications/academic_procedures/views.py +++ b/FusionIIIT/applications/academic_procedures/views.py @@ -13,6 +13,7 @@ from django.contrib import messages from django.db.models import Q from django.contrib.auth.decorators import login_required +from applications.globals.access import require_designation from django.contrib.auth.models import User from django.db.models import Max,Value,IntegerField,CharField,F,Sum from django.http import HttpResponse, HttpResponseRedirect @@ -119,7 +120,7 @@ def academic_procedures(request): # # # -@login_required(login_url='/accounts/login') +@require_designation("Professor", "Associate Professor", "Assistant Professor") def academic_procedures_faculty(request): current_user = get_object_or_404(User, username=request.user.username) @@ -273,7 +274,7 @@ def account(request): }) -@login_required(login_url='/accounts/login') +@require_designation("student") def academic_procedures_student(request): current_user = get_object_or_404(User, username=request.user.username) @@ -659,7 +660,7 @@ def academic_procedures_student(request): return HttpResponse('user not found') -@login_required(login_url='/accounts/login') +@require_designation("student", "Professor", "Associate Professor", "Assistant Professor", "acadadmin", "Dean Academic") def dues_pdf(request): template = get_template('academic_procedures/dues_pdf.html') current_user = get_object_or_404(User, username=request.user.username) @@ -687,7 +688,7 @@ def dues_pdf(request): return HttpResponse("PDF could not be generated") -@login_required(login_url='/accounts/login') +@require_designation("Professor", "Associate Professor", "Assistant Professor") def facultyData(request): current_value = request.POST['current_value'] try: @@ -715,7 +716,6 @@ def facultyData(request): -@login_required(login_url='/accounts/login') def get_course_to_show_pg(initial_courses, final_register): ''' This function fetches the PG courses from the database and store them into list x. @@ -739,7 +739,6 @@ def get_course_to_show_pg(initial_courses, final_register): -@login_required(login_url='/accounts/login') def get_pg_course(usersem, specialization): ''' This function fetches the PG Spcialization courses from the database and store them into list result. @@ -771,7 +770,6 @@ def get_pg_course(usersem, specialization): -@login_required(login_url='/accounts/login') def get_add_course(branch, final): ''' This function shows the courses that were added after pre-registration. @@ -843,7 +841,7 @@ def apply_branch_change(request): return context -@login_required(login_url='/accounts/login') +@require_designation("student") def branch_change_request(request): ''' This function is used to apply the branch change request. @@ -875,7 +873,7 @@ def branch_change_request(request): -@login_required(login_url='/acounts/login') +@require_designation("acadadmin", "Dean Academic") def approve_branch_change(request): ''' This function is used to approve the branch change requests from acad admin's frame. @@ -956,7 +954,6 @@ def approve_branch_change(request): return HttpResponseRedirect('/academic-procedures/main') # Function returning Branch , Banch data which was required many times -@login_required(login_url='/accounts/login') def get_batch_query_detail(month, year): ''' This function is used to get the batch's detail simply return branch which is required often. @@ -1026,7 +1023,7 @@ def dropcourseadmin(request): response_data = {} return HttpResponse(json.dumps(response_data), content_type="application/json") -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def gen_course_list(request): if(request.POST): try: @@ -1208,7 +1205,7 @@ def acad_add_course(request): -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def acad_branch_change(request): ''' This function is used to approve the branch changes requested by the students. @@ -1422,11 +1419,9 @@ def phd_details(request): # # # -@login_required(login_url='/accounts/login') def get_student_register(id): return Register.objects.all().select_related('curr_id','student_id','curr_id__course_id','student_id__id','student_id__id__user','student_id__id__department').filter(student_id = id) -@login_required(login_url='/accounts/login') def get_pre_registration_eligibility(current_date, user_sem, year): ''' This function is used to extract the elgibility of pre-registration for a given semester @@ -1461,7 +1456,6 @@ def get_pre_registration_eligibility(current_date, user_sem, year): except Exception as e: return False, None -@login_required(login_url='/accounts/login') def get_final_registration_eligibility(current_date): try: frd = Calendar.objects.all().filter(description="Physical Reporting at the Institute").first() @@ -1474,7 +1468,6 @@ def get_final_registration_eligibility(current_date): except Exception as e: return False -@login_required(login_url='/accounts/login') def get_add_or_drop_course_date_eligibility(current_date): try: add_drop_course_date = Calendar.objects.all().filter(description="Last Date for Adding/Dropping of course").first() @@ -1487,7 +1480,6 @@ def get_add_or_drop_course_date_eligibility(current_date): except Exception as e: return False -@login_required(login_url='/accounts/login') def get_course_verification_date_eligibilty(current_date): try: course_verification_date = Calendar.objects.all().filter(description="course verification date").first() @@ -1500,7 +1492,6 @@ def get_course_verification_date_eligibilty(current_date): except Exception as e: return False -@login_required(login_url='/accounts/login') def get_swayam_registration_eligibility(current_date, user_sem, year): try: swayam_registration_date = Calendar.objects.all().filter(description=f"Swayam Registration {user_sem} {year}").first() @@ -1513,7 +1504,6 @@ def get_swayam_registration_eligibility(current_date, user_sem, year): except Exception as e: return False -@login_required(login_url='/accounts/login') def get_drop_course_date_eligibility(current_date, user_sem, year): try: drop_course_date = Calendar.objects.all().filter(description=f"Drop course {user_sem} {year}").first() @@ -1526,11 +1516,9 @@ def get_drop_course_date_eligibility(current_date, user_sem, year): except Exception as e: return False -@login_required(login_url='/accounts/login') def get_user_branch(user_details): return user_details.department.name -@login_required(login_url='/accounts/login') def get_acad_year(user_sem, year): if user_sem%2 == 1: acad_year = str(year) + "-" + str(year+1) @@ -1538,7 +1526,7 @@ def get_acad_year(user_sem, year): acad_year = str(year-1) + "-" + str(year) return acad_year -@login_required(login_url='/accounts/login') +@require_designation("student") @transaction.atomic def pre_registration(request): if request.method == 'POST': @@ -1606,7 +1594,7 @@ def pre_registration(request): else: return HttpResponseRedirect('/academic-procedures/main') -@login_required(login_url='/accounts/login') +@require_designation("student") @transaction.atomic def auto_pre_registration(request): if request.method == 'POST': @@ -1679,7 +1667,6 @@ def auto_pre_registration(request): else: return HttpResponseRedirect('/academic-procedures/main') -@login_required(login_url='/accounts/login') def get_student_registrtion_check(obj, sem): return StudentRegistrationChecks.objects.all().filter(student_id = obj, semester_id = sem).first() @@ -1816,7 +1803,7 @@ def allot_courses(request): # return HttpResponse("Fail") -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def allot_courses_after_add_and_drop(request): if user_check(request): return HttpResponseRedirect('/academic-procedures/main') @@ -1919,12 +1906,11 @@ def user_check(request): else: return False -@login_required(login_url='/accounts/login') def get_cpi(id): obj = Student.objects.select_related('id','id__user','id__department').get(id = id) return obj.cpi -@login_required(login_url='/accounts/login') +@require_designation("student") def register(request): if request.method == 'POST': try: @@ -1966,7 +1952,7 @@ def register(request): return HttpResponseRedirect('/academic-procedures/main') -@login_required(login_url='/accounts/login') +@require_designation("student") def add_courses(request): """ This function is used to add courses for currernt semester @@ -2047,7 +2033,7 @@ def drop_course(request): else: return HttpResponseRedirect('/academic-procedures/main') -@login_required(login_url='/accounts/login') +@require_designation("student") def replace_courses(request): """ This function is used to replace elective courses which have been registered @@ -2097,7 +2083,7 @@ def replace_courses(request): -@login_required(login_url='/accounts/login') +@require_designation("Professor", "Associate Professor", "Assistant Professor") def add_thesis(request): if request.method == 'POST': try: @@ -2187,7 +2173,6 @@ def add_thesis(request): return HttpResponseRedirect('/academic-procedures/main/') -@login_required(login_url='/accounts/login') def get_final_registration_choices(branch_courses,batch): course_option = [] unavailable_courses = [] @@ -2202,7 +2187,6 @@ def get_final_registration_choices(branch_courses,batch): course_option.append((courseslot, lis)) return course_option, unavailable_courses -@login_required(login_url='/accounts/login') def get_add_course_options(branch_courses, current_register, batch): course_option = [] @@ -2223,7 +2207,6 @@ def get_add_course_options(branch_courses, current_register, batch): course_option.append((courseslot, lis)) return course_option -@login_required(login_url='/accounts/login') def get_drop_course_options(current_register): courses = [] for item in current_register: @@ -2231,7 +2214,6 @@ def get_drop_course_options(current_register): courses.append(item[1]) return courses -@login_required(login_url='/accounts/login') def get_replace_course_options( current_register, batch): replace_options = [] @@ -2253,7 +2235,6 @@ def get_replace_course_options( current_register, batch): -@login_required(login_url='/accounts/login') def get_user_semester(roll_no, ug_flag, masters_flag, phd_flag): roll = str(roll_no) now = demo_date @@ -2280,7 +2261,6 @@ def get_user_semester(roll_no, ug_flag, masters_flag, phd_flag): -@login_required(login_url='/accounts/login') def get_branch_courses(roll_no, user_sem, branch): roll = str(roll_no) try: @@ -2300,7 +2280,6 @@ def get_branch_courses(roll_no, user_sem, branch): return course_list -@login_required(login_url='/accounts/login') def get_sem_courses(sem_id, batch): courses = [] course_slots = CourseSlot.objects.all().filter(semester_id = sem_id) @@ -2309,7 +2288,6 @@ def get_sem_courses(sem_id, batch): return courses -@login_required(login_url='/accounts/login') def get_currently_registered_courses(id, user_sem): obj = Register.objects.all().select_related('curr_id','student_id','curr_id__course_id','student_id__id','student_id__id__user','student_id__id__department').filter(student_id=id, semester=user_sem) ans = [] @@ -2318,7 +2296,6 @@ def get_currently_registered_courses(id, user_sem): ans.append(course) return ans -@login_required(login_url='/accounts/login') def get_currently_registered_course(id, sem_id, courseregobj=False): if (type(sem_id) == int): obj = course_registration.objects.all().filter(student_id = id, semester_id__semester_no=sem_id) @@ -2333,7 +2310,6 @@ def get_currently_registered_course(id, sem_id, courseregobj=False): return courses -@login_required(login_url='/accounts/login') def get_current_credits(obj): credits = 0 for i in obj: @@ -2342,7 +2318,6 @@ def get_current_credits(obj): -@login_required(login_url='/accounts/login') def get_faculty_list(): f1 = HoldsDesignation.objects.select_related().filter(designation=Designation.objects.get(name = "Assistant Professor")) f2 = HoldsDesignation.objects.select_related().filter(designation=Designation.objects.get(name = "Professor")) @@ -2355,7 +2330,6 @@ def get_faculty_list(): return faculty_list -@login_required(login_url='/accounts/login') def get_thesis_flag(student): obj = ThesisTopicProcess.objects.all().select_related().filter(student_id = student) if(obj): @@ -2365,7 +2339,7 @@ def get_thesis_flag(student): -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def acad_person(request): current_user = get_object_or_404(User, username=request.user.username) @@ -2453,7 +2427,6 @@ def acad_person(request): return HttpResponse('user not found') -@login_required(login_url='/accounts/login') def acad_proced_global_context(): year = demo_date.year month = demo_date.month @@ -2533,7 +2506,6 @@ def acad_proced_global_context(): -@login_required(login_url='/accounts/login') def get_batch_all(): result_year = [] if demo_date.month >=7: @@ -2543,7 +2515,7 @@ def get_batch_all(): result_year = [demo_date.year-1,demo_date.year-2, demo_date.year-3, demo_date.year-4] return result_year -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def announce_results(request): i = int(request.POST.get('id')) year = get_batch_all() @@ -2570,7 +2542,6 @@ def announce_results(request): -@login_required(login_url='/accounts/login') def get_batch_grade_verification_data(list): semester_marks = [] @@ -2705,7 +2676,6 @@ def get_batch_grade_verification_data(list): return batch_grade_data_set -@login_required(login_url='/accounts/login') def get_batch_branch_data(result_year): batches = [] @@ -2838,14 +2808,14 @@ def course_list(request): return HttpResponse(json.dumps({'html': html}),content_type="application/json") -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def process_verification_request(request): if request.is_ajax(): return verify_registration(request) return JsonResponse({'status': 'Failed'}, status=400) -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def auto_process_verification_request(request): if request.is_ajax(): return auto_verify_registration(request) @@ -2964,7 +2934,6 @@ def auto_verify_registration(request): academics_module_notif(academicadmin, student_id.id.user, 'Registration Declined - '+reject_reason) return JsonResponse({'status': 'success', 'message': 'Successfully Rejected'}) -@login_required(login_url='/accounts/login') def get_registration_courses(courses): x = [[]] @@ -2987,7 +2956,7 @@ def get_registration_courses(courses): return x -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def teaching_credit_register(request) : if request.method == 'POST': try: @@ -3025,7 +2994,7 @@ def teaching_credit_register(request) : -@login_required(login_url='/accounts/login') +@require_designation("Professor", "Associate Professor", "Assistant Professor") def course_marks_data(request): try: course_id = request.POST.get('course_id') @@ -3096,7 +3065,7 @@ def course_marks_data(request): -@login_required(login_url='/accounts/login') +@require_designation("Professor", "Associate Professor", "Assistant Professor") def submit_marks(request): try: print(request.POST) @@ -3188,7 +3157,7 @@ def submit_marks(request): -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def verify_course_marks_data(request): try: curriculum_id = request.POST.get('curriculum_id') @@ -3226,7 +3195,7 @@ def verify_course_marks_data(request): ##########GLOBAL VARIABLE############### ######################################## -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def verify_marks(request): try: global verified_marks_students @@ -3268,7 +3237,6 @@ def verify_marks(request): -@login_required(login_url='/accounts/login') def render_to_pdf(template_src, context_dict): template = get_template(template_src) html = template.render(context_dict) @@ -3278,7 +3246,7 @@ def render_to_pdf(template_src, context_dict): return HttpResponse(result.getvalue(), content_type='application/pdf') return None -@login_required(login_url='/accounts/login') +@require_designation("Professor", "Associate Professor", "Assistant Professor", "acadadmin", "Dean Academic") def generate_grade_pdf(request): instructor = Curriculum_Instructor.objects.all().select_related('curriculum_id','instructor_id','curriculum_id__course_id','instructor_id__department','instructor_id__user').filter(curriculum_id = verified_marks_students_curr).first() context = {'verified_marks_students' : verified_marks_students, @@ -3292,7 +3260,7 @@ def generate_grade_pdf(request): return HttpResponse("PDF could not be generated") -@login_required(login_url='/accounts/login') +@require_designation("Professor", "Associate Professor", "Assistant Professor", "acadadmin", "Dean Academic") def generate_result_pdf(request): batch = request.POST.get('batch') branch = request.POST.get('branch') @@ -3367,7 +3335,7 @@ def generate_result_pdf(request): return HttpResponse("PDF could not be generated") -@login_required(login_url='/accounts/login') +@require_designation("Professor", "Associate Professor", "Assistant Professor", "acadadmin", "Dean Academic") def generate_grade_sheet_pdf(request): batch = request.POST.get('batch') branch = request.POST.get('branch') @@ -3475,7 +3443,6 @@ def generate_course_registration_receipt(request): except Exception as e: return JsonResponse({'error': 'Unable to retrieve data', 'details': str(e)}) -@login_required(login_url='/accounts/login') def get_spi(course_list,grade_list): spi = 0.0 credits = 0 @@ -3553,7 +3520,7 @@ def get_spi(course_list,grade_list): -@login_required(login_url='/accounts/login') +@require_designation("Professor", "Associate Professor", "Assistant Professor") def manual_grade_submission(request): if request.method == 'POST' and request.FILES: @@ -3685,7 +3652,7 @@ def manual_grade_submission(request): -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def test(request): br_up = [] st_list = Student.objects.select_related('id','id__user','id__department').all() @@ -3703,7 +3670,7 @@ def test(request): return render(request,'../templates/academic_procedures/test.html',{}) -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def test_ret(request): try: data = render_to_string('academic_procedures/test_render.html', @@ -3714,7 +3681,7 @@ def test_ret(request): return HttpResponseRedirect('/academic-procedures/main') -@login_required(login_url='/accounts/login') +@require_designation("student", "Professor", "Associate Professor", "Assistant Professor", "acadadmin", "Dean Academic") def Bonafide_form(request): template = get_template('academic_procedures/bonafide_pdf.html') current_user = get_object_or_404(User, username=request.user.username) @@ -3760,7 +3727,7 @@ def Bonafide_form(request): # return render(request, 'bonafide.html', {'form': form}) -@login_required +@require_designation("student", "Professor", "Associate Professor", "Assistant Professor", "acadadmin", "Dean Academic") def ACF(request): stu = Student.objects.get(id=request.user.username) month = request.POST.get('month') @@ -3811,7 +3778,7 @@ def ACF(request): return HttpResponse(content) -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def update_assistantship(request): if request.method == 'POST': r = request.POST.get('remark') @@ -3835,7 +3802,7 @@ def update_assistantship(request): return HttpResponseRedirect('/academic-procedures/main/') -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def update_hod_assistantship(request): if request.method == 'POST': d = request.POST.get('dict') @@ -3850,7 +3817,7 @@ def update_hod_assistantship(request): -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def update_acad_assis(request): if request.method == 'POST': d = request.POST.get('dict') @@ -3866,7 +3833,7 @@ def update_acad_assis(request): return HttpResponse('success') -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def update_account_assistantship(request): if request.method == 'POST': di = request.POST.get('dict') @@ -3882,7 +3849,7 @@ def update_account_assistantship(request): return HttpResponse('success') -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def assis_stat(request): if request.method == 'POST': flag= request.POST.get('flag') @@ -3904,7 +3871,7 @@ def assis_stat(request): return HttpResponse('success') -@login_required +@require_designation("student", "Professor", "Associate Professor", "Assistant Professor", "acadadmin", "Dean Academic") def MTSGF(request): if request.method == 'POST': stu= Student.objects.get(id=request.user.username) @@ -3936,7 +3903,7 @@ def MTSGF(request): return HttpResponse(content) -@login_required +@require_designation("student", "Professor", "Associate Professor", "Assistant Professor", "acadadmin", "Dean Academic") def PHDPE(request): if request.method == 'POST': stu= Student.objects.get(id=request.user.username) @@ -3967,7 +3934,7 @@ def PHDPE(request): return HttpResponse(content) -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def update_mtechsg(request): if request.method == 'POST': i = request.POST.get('obj_id') @@ -3989,7 +3956,7 @@ def update_mtechsg(request): -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def update_phdform(request): if request.method == 'POST': i = request.POST.get('obj_id') @@ -4020,7 +3987,7 @@ def update_phdform(request): return HttpResponse(content) -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def update_dues(request): if request.method == "POST": i = request.POST.get('obj_id') @@ -4074,7 +4041,7 @@ def update_dues(request): return HttpResponse(content) -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def mdue(request): if request.method == 'POST': rollno = request.POST.get('rollno') @@ -4110,7 +4077,6 @@ def mdue(request): -@login_required(login_url='/accounts/login') def get_detailed_sem_courses(sem_id): course_slots = CourseSlot.objects.filter(semester_id=sem_id) # Serialize queryset of course slots into JSON @@ -4152,7 +4118,7 @@ def get_next_sem_courses(request): return JsonResponse({'error': 'Invalid request'}) -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def add_course_to_slot(request): if request.method == 'POST': data = json.loads(request.body) @@ -4173,7 +4139,7 @@ def add_course_to_slot(request): return JsonResponse({'error': 'Invalid request method.'}, status=405) -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def remove_course_from_slot(request): if request.method == 'POST': data = json.loads(request.body) @@ -4193,7 +4159,7 @@ def remove_course_from_slot(request): return JsonResponse({'error': 'Invalid request method.'}, status=405) -@login_required(login_url='/accounts/login') +@require_designation("student") def add_one_course(request): if request.method == 'POST': try: @@ -4239,7 +4205,7 @@ def add_one_course(request): else: return JsonResponse({'message': 'Invalid request method'}, status=405) -@login_required(login_url='/accounts/login') +@require_designation("student") def replace_one_course(request): if request.method == 'POST' : try: @@ -4267,7 +4233,6 @@ def replace_one_course(request): else : return JsonResponse({'message': 'Invalid request method'}, status=405) -@login_required(login_url='/accounts/login') def get_sem_swayam(sem_id, batch): courses = [] course_slots = CourseSlot.objects.all().filter(type='Swayam') @@ -4277,7 +4242,7 @@ def get_sem_swayam(sem_id, batch): return courses -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def replaceSwayam(request): if(request.POST): # print(f"++++++++++++++++++++++++++++++++++++++++++++++++{request.POST}") @@ -4367,7 +4332,6 @@ def replaceSwayam(request): obj = json.dumps(maindict) return HttpResponse(obj, content_type='application/json') -@login_required(login_url='/accounts/login') def get_currently_registered_elective(student_id, semester_id): registrations = course_registration.objects.filter(student_id=student_id, semester_id=semester_id) courses = [] @@ -4378,7 +4342,7 @@ def get_currently_registered_elective(student_id, semester_id): -@login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def swayam_replace(request): if request.method == 'POST': csrf_token = request.POST.get('csrfmiddlewaretoken', None) @@ -4460,7 +4424,7 @@ def swayam_replace(request): else: return HttpResponseRedirect('/academic-procedures/main') -@login_required(login_url='/accounts/login') +@require_designation("student") def register_backlog_course(request): if request.method == 'POST': try: @@ -4497,7 +4461,6 @@ def register_backlog_course(request): return JsonResponse({'message': 'Adding Backlog Failed ' +str(e)}, status=500) -@login_required(login_url='/accounts/login') def get_current_semester_swayam_course_slots(curr_sem_id): courseslot_list = CourseSlot.objects.filter(semester = curr_sem_id, name__startswith='SW') return courseslot_list diff --git a/FusionIIIT/applications/examination/api/urls.py b/FusionIIIT/applications/examination/api/urls.py index 8061400e2..f0499996a 100644 --- a/FusionIIIT/applications/examination/api/urls.py +++ b/FusionIIIT/applications/examination/api/urls.py @@ -32,6 +32,8 @@ url(r'result-announcements/', views.ResultAnnouncementListAPI.as_view(), name="result-announcements"), url(r'update-announcement/', views.UpdateAnnouncementAPI.as_view(), name="update-announcement"), url(r'create-announcement/', views.CreateAnnouncementAPI.as_view(), name="create-announcement"), + url(r'^announcement-students/$', views.AnnouncementStudentsAPI.as_view(), name="announcement-students"), + url(r'^publish-result-selected/$', views.PublishResultSelectedAPI.as_view(), name="publish-result-selected"), url(r'unique-course-reg-years/', views.UniqueRegistrationYearsView.as_view(), name="unique-course-reg-years"), url(r'unique-stu-grades-years/', views.UniqueStudentGradeYearsView.as_view(), name="unique-stu-grades-years"), url(r'^student/result_semesters/$', views.StudentSemesterListView.as_view(), name='get_student_semesters'), diff --git a/FusionIIIT/applications/examination/api/views.py b/FusionIIIT/applications/examination/api/views.py index 35b302eed..4e6f0a5ed 100644 --- a/FusionIIIT/applications/examination/api/views.py +++ b/FusionIIIT/applications/examination/api/views.py @@ -5,7 +5,8 @@ from decimal import Decimal, ROUND_HALF_UP from applications.academic_procedures.models import(course_registration, course_replacement) from applications.programme_curriculum.models import Course as Courses , Batch, CourseInstructor -from applications.examination.models import(hidden_grades , ResultAnnouncement, authentication) +from applications.examination.models import(hidden_grades , ResultAnnouncement, authentication, PublishedResultStudent) +from applications.globals.access import user_holds_role, user_holds_any_role from applications.academic_information.models import(Student) from applications.online_cms.models import(Student_grades) from rest_framework import status @@ -286,11 +287,11 @@ def exam_view(request): if not role: return Response({"error": "Role parameter is required."}, status=status.HTTP_400_BAD_REQUEST) - if role in ["Associate Professor", "Professor", "Assistant Professor"]: + if user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): return Response({"redirect_url": "/examination/submitGradesProf/"}) - elif role == "acadadmin": + elif user_holds_role(request.user, "acadadmin"): return Response({"redirect_url": "/examination/updateGrades/"}) - elif role == "Dean Academic": + elif user_holds_role(request.user, "Dean Academic"): return Response({"redirect_url": "/examination/verifyGradesDean/"}) else: return Response({"redirect_url": "/dashboard/"}) @@ -390,7 +391,7 @@ def download_template(request): "acadadmin", "Associate Professor", "Professor", "Assistant Professor", "Dean Academic" ] - if role not in allowed_roles: + if not user_holds_any_role(request.user, allowed_roles): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) User = get_user_model() @@ -514,7 +515,7 @@ def check_course_students(request): "acadadmin", "Associate Professor", "Professor", "Assistant Professor", "Dean Academic" ] - if role not in allowed_roles: + if not user_holds_any_role(request.user, allowed_roles): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) course_info_query = course_registration.objects.filter( @@ -576,7 +577,7 @@ def post(self, request): semester_type = request.data.get("semester_type") # Only allow access to 'acadadmin' - if designation != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN @@ -614,7 +615,7 @@ class UploadGradesAPI(APIView): def post(self, request): # Validate the role (only allow "acadadmin" in this example). des = request.data.get("Role") - if des != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -691,7 +692,7 @@ def post(self, request): with transaction.atomic(): for index, row in enumerate(reader, start=1): roll_no = row.get("roll_no") - grade = row.get("grade") + grade = (row.get("grade") or "").strip() remarks = row.get("remarks", "") semester = row.get("semester", None) @@ -804,7 +805,7 @@ def post(self, request): academic_year = request.data.get("academic_year") semester_type = request.data.get("semester_type") - if role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"success": False, "error": "Access denied."}, status=403, @@ -890,7 +891,7 @@ def post(self, request): des = request.data.get("Role") - if des != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -974,7 +975,7 @@ class ModerateStudentGradesAPI(APIView): def post(self, request): des = request.data.get("Role") - if des not in ["acadadmin", "Dean Academic"]: + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -1022,6 +1023,7 @@ def post(self, request): for student_id, semester_id, course_id, grade, remark in zip( student_ids, semester_ids, course_ids, grades, remarks ): + grade = (grade or "").strip() try: grade_of_student = Student_grades.objects.get( course_id=course_id, roll_no=student_id, semester=semester_id @@ -1097,7 +1099,7 @@ def post(self, request): semester_number = semester.get('no') semester_type = semester.get('type') - if des != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) if not student_id or not semester: @@ -1127,7 +1129,7 @@ def post(self, request): "course_code": course.code, "credit": course.credit, "grade": reg.grade, - "points": Decimal(str(grade_conversion.get(reg.grade, 0) * 10)).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP), + "points": Decimal(str(grade_conversion.get((reg.grade or "").strip(), 0) * 10)).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP), } # Add complete student information like CheckResultView @@ -1205,7 +1207,7 @@ class GenerateTranscriptForm(APIView): def get(self, request): role = request.GET.get("role") - if not role or role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"error": "Access denied. Invalid or missing role."}, status=status.HTTP_403_FORBIDDEN @@ -1244,7 +1246,7 @@ def get(self, request): def post(self, request): role = request.data.get("Role") - if not role or role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"error": "Access denied. Invalid or missing role."}, status=status.HTTP_403_FORBIDDEN @@ -1294,7 +1296,7 @@ class GenerateResultAPI(APIView): def post(self, request): try: role = request.data.get("Role") - if role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response({"error": "Access denied."}, status=403) semester = request.data.get("semester") @@ -1531,7 +1533,7 @@ def post(self, request): if len(attempts) >= 1: scored = sorted( attempts, - key=lambda x: grade_conversion.get(x[1], -1), + key=lambda x: grade_conversion.get((x[1] or "").strip(), -1), reverse=True ) first_code, first_grade = scored[0] @@ -1622,7 +1624,7 @@ class SubmitAPI(APIView): def post(self, request): role = request.data.get("Role") - if role not in ["acadadmin", "Dean Academic"]: + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response( {"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -1724,7 +1726,7 @@ def post(self, request): semester_type = request.data.get("semester_type") programme_type = request.data.get("programme_type") - if role not in ["Associate Professor", "Professor", "Assistant Professor"]: + if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -1821,7 +1823,7 @@ def post(self, request): try: # 1) ROLE CHECK role = request.data.get("Role") - if role not in ["Associate Professor", "Professor", "Assistant Professor"]: + if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) @@ -2114,7 +2116,7 @@ def post(self, request): semester_type = request.data.get("semester_type") programme_type = request.data.get("programme_type") - if role not in ["Associate Professor", "Professor", "Assistant Professor"]: + if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) if not academic_year or not semester_type: @@ -2182,7 +2184,7 @@ def post(self, request): return self.generate_student_result_pdf(request) # Faculty role check for course grade sheets - if role not in ["Associate Professor", "Professor", "Assistant Professor", "acadadmin"]: + if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor", "acadadmin"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) # Existing faculty course grade sheet logic @@ -2219,7 +2221,7 @@ def post(self, request): grades = grades.order_by("roll_no") - if role == "acadadmin": + if user_holds_role(request.user, "acadadmin"): ci = CourseInstructor.objects.filter( course_id_id=course_id, year=working_year, @@ -2236,7 +2238,7 @@ def post(self, request): return Response({"success": False, "error": "Course not found."}, status=404) # semester = ci.first().semester_no - if role == "acadadmin": + if user_holds_role(request.user, "acadadmin"): _User = get_user_model() ci_obj = ci.first() instr_user = _User.objects.filter(username=ci_obj.instructor_id_id).first() @@ -2637,7 +2639,7 @@ def post(self, request): academic_year = request.data.get("academic_year") semester_type = request.data.get("semester_type") - if role != "Dean Academic": + if not user_holds_role(request.user, "Dean Academic"): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) if not academic_year or not semester_type: return Response({"error": "Both academic_year and semester_type are required."}, @@ -2709,7 +2711,7 @@ def post(self, request): year = request.data.get("year") semester_type = request.data.get("semester_type") - if role != "Dean Academic": + if not user_holds_role(request.user, "Dean Academic"): return Response({"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) qs = Student_grades.objects.filter(course_id=course_id, academic_year=year, semester_type = semester_type) @@ -2767,7 +2769,7 @@ class ValidateDeanView(APIView): def post(self, request): role = request.data.get("Role") - if role != "Dean Academic": + if not user_holds_role(request.user, "Dean Academic"): return Response( {"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN @@ -2810,7 +2812,7 @@ class ValidateDeanSubmitView(APIView): def post(self, request): role = request.data.get("Role") - if role != "Dean Academic": + if not user_holds_role(request.user, "Dean Academic"): return Response( {"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN @@ -2869,7 +2871,7 @@ def post(self, request): for row in reader: roll_no = row["roll_no"] - grade = row["grade"] + grade = (row["grade"] or "").strip() remarks = row["remarks"] try: @@ -2884,7 +2886,7 @@ def post(self, request): batch=batch ) - if student_grade.grade != grade: + if (student_grade.grade or "").strip() != grade: mismatches.append({ "roll_no": roll_no, "csv_grade": grade, @@ -2981,7 +2983,7 @@ def post(self, request, *args, **kwargs): semester_type=semester_type, ).first() - if not ann or not ann.announced: + if not ann or not ann.announced or not _is_result_published_for(ann, roll_number): return JsonResponse( {"success": False, "message": "Results not announced yet."}, status=200, @@ -3028,7 +3030,7 @@ def post(self, request, *args, **kwargs): "coursename": grade.course_id.name, "credits": grade.course_id.credit, "grade":grade.grade, - "points": Decimal(str(grade_conversion.get(grade.grade, 0) * 10)).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP), + "points": Decimal(str(grade_conversion.get((grade.grade or "").strip(), 0) * 10)).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP), } for grade in grades_info ], @@ -3047,7 +3049,7 @@ class PreviewGradesAPI(APIView): def post(self, request): # Validate user role user_role = request.data.get("Role") - if user_role != "acadadmin" and user_role!='Assistant Professor' and user_role != 'Professor' and user_role!='Associate Professor': + if not user_holds_any_role(request.user, ["acadadmin", "Assistant Professor", "Professor", "Associate Professor"]): return Response( {"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -3171,11 +3173,16 @@ class ResultAnnouncementListAPI(APIView): def get(self, request): role = request.query_params.get("role") - if role != "acadadmin" and role != "Dean Academic": + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) - # Get announcements sorted by creation date (most recent first) - announcements = ResultAnnouncement.objects.all().order_by("-created_at") + # Get announcements sorted by creation date (most recent first). + # select_related pulls batch + discipline in one query (avoids N+1). + announcements = ( + ResultAnnouncement.objects + .select_related("batch__discipline") + .order_by("-created_at") + ) ann_data = [] for ann in announcements: # Compute the batch label. @@ -3199,11 +3206,12 @@ def get(self, request): "semester_type": sem_type, "semester_label": sem_label, "announced": ann.announced, + "per_student_selection": ann.per_student_selection, "created_at": ann.created_at, }) batch_objs = sorted( - Batch.objects.filter(running_batch=True), + Batch.objects.filter(running_batch=True).select_related("discipline"), key=lambda b: (b.name, -b.year, b.discipline.acronym), ) batch_options = [ @@ -3226,7 +3234,7 @@ class UpdateAnnouncementAPI(APIView): def post(self, request): role = request.data.get("Role") - if role != "acadadmin" and role != "Dean Academic": + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) announcement_id = request.data.get("id") announced = request.data.get("announced") @@ -3255,7 +3263,7 @@ class CreateAnnouncementAPI(APIView): def post(self, request): try: role = request.data.get("Role") - if role != "acadadmin" and role != "Dean Academic": + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) batch_id = request.data.get("batch") @@ -3303,6 +3311,141 @@ def post(self, request): traceback.print_exc() return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) +def _is_result_published_for(ann, roll_number): + """Whether this announcement's result is published for ``roll_number``. + + Legacy / whole-batch publish (``per_student_selection`` False) is published + for everyone. Per-student publish shows the result only to the students + explicitly selected (rows in ``PublishedResultStudent``). + """ + if not getattr(ann, "per_student_selection", False): + return True + return ann.published_students.filter(roll_no=roll_number).exists() + + +def _user_has_exam_admin_role(user, allowed=("acadadmin", "Dean Academic")): + """Authorize off the user's actual held designation (server-side). + + The rest of this module trusts a client-supplied ``Role`` field, which is + spoofable; these result-publishing endpoints verify the real designation + instead so a non-admin cannot publish/hide results or read the roster. + """ + from applications.globals.models import HoldsDesignation + + return HoldsDesignation.objects.filter( + Q(working=user) | Q(user=user), + designation__name__in=allowed, + ).exists() + + +class AnnouncementStudentsAPI(APIView): + """GET /api/announcement-students/?id=&role=acadadmin + + Returns the students of the announcement's batch with their current publish + selection. Before any per-student publish, every student defaults to + selected (checked). + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + if not _user_has_exam_admin_role(request.user): + return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) + + try: + ann = ResultAnnouncement.objects.select_related("batch__discipline").get( + id=request.query_params.get("id") + ) + except (ResultAnnouncement.DoesNotExist, ValueError): + return Response({"error": "Announcement not found."}, status=status.HTTP_404_NOT_FOUND) + + students = ( + Student.objects.filter(batch_id=ann.batch) + .select_related("id__user") + .order_by("id__user__username") + ) + published_set = set(ann.published_students.values_list("roll_no", flat=True)) + # Reflect the saved per-student selection only while it is actively + # published; otherwise (fresh, or fully reverted) default to all checked. + has_selection = ann.announced and ann.per_student_selection + discipline = ann.batch.discipline.acronym if ann.batch.discipline else "" + + rows = [] + for idx, stu in enumerate(students, start=1): + user = stu.id.user + roll = user.username + full_name = "{} {}".format(user.first_name, user.last_name).strip() or roll + rows.append({ + "s_no": idx, + "roll_no": roll, + "name": full_name, + "discipline": discipline, + # Default everyone to checked until a per-student publish happens. + "published": (roll in published_set) if has_selection else True, + }) + + batch = ann.batch + sem_label = ( + "Summer {}".format(ann.semester // 2) + if ann.semester_type == "Summer Semester" + else "Semester {}".format(ann.semester) + ) + return Response({ + "id": ann.id, + "batch_label": "{} - {} {}".format(batch.name, discipline, batch.year), + "semester_label": sem_label, + "announced": ann.announced, + "students": rows, + }, status=status.HTTP_200_OK) + + +class PublishResultSelectedAPI(APIView): + """POST /api/publish-result-selected/ + + Body: ``{ "id": , "roll_numbers": [...], "Role": "acadadmin" }`` + + Publishes the announcement for exactly the selected students. Unselected + students of the batch will not see their result. + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + if not _user_has_exam_admin_role(request.user): + return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) + + roll_numbers = request.data.get("roll_numbers", []) + if not isinstance(roll_numbers, list): + return Response({"error": "roll_numbers must be a list."}, status=status.HTTP_400_BAD_REQUEST) + + try: + ann = ResultAnnouncement.objects.get(id=request.data.get("id")) + except (ResultAnnouncement.DoesNotExist, ValueError): + return Response({"error": "Announcement not found."}, status=status.HTTP_404_NOT_FOUND) + + # Keep only roll numbers that actually belong to the batch. + valid_rolls = set( + Student.objects.filter(batch_id=ann.batch) + .values_list("id__user__username", flat=True) + ) + selected = [r for r in roll_numbers if r in valid_rolls] + + with transaction.atomic(): + ann.published_students.all().delete() + PublishedResultStudent.objects.bulk_create( + [PublishedResultStudent(announcement=ann, roll_no=r) for r in selected] + ) + ann.per_student_selection = True + # Publishing zero students reverts the announcement (nobody sees it). + ann.announced = bool(selected) + ann.save(update_fields=["per_student_selection", "announced"]) + + return Response({ + "success": True, + "announced": ann.announced, + "published_count": len(selected), + "total": len(valid_rolls), + }, status=status.HTTP_200_OK) + + from collections import OrderedDict @@ -3370,7 +3513,7 @@ def post(self, request): semester_type = request.data.get("semester_type") # Role-based access control - if role not in ["acadadmin", "Dean Academic"]: + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN @@ -3539,7 +3682,7 @@ def post(self, request): semester_type=semester_type, ).first() - if not ann or not ann.announced: + if not ann or not ann.announced or not _is_result_published_for(ann, roll_number): return JsonResponse( {"success": False, "message": "Results not announced yet."}, status=200, @@ -3582,7 +3725,7 @@ def post(self, request): "coursename": grade.course_id.name, "credits": grade.course_id.credit, "grade": grade.grade, - "points": Decimal(str(grade_conversion.get(grade.grade, 0) * 10)).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP), + "points": Decimal(str(grade_conversion.get((grade.grade or "").strip(), 0) * 10)).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP), } for grade in grades_info ] @@ -3828,7 +3971,7 @@ def post(self, request): student_id = request.data.get("student") raw_semester = request.data.get("semester") - if des != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) if not student_id or not raw_semester: @@ -3903,7 +4046,7 @@ def post(self, request): "course_code": course.code, "credit": course.credit, "grade": reg.grade, - "points": Decimal(str(grade_conversion.get(reg.grade, 0) * 10)).quantize( + "points": Decimal(str(grade_conversion.get((reg.grade or "").strip(), 0) * 10)).quantize( Decimal('0.1'), rounding=ROUND_HALF_UP), "special_symbol": course_reg_map.get(course.id, ''), } @@ -4034,7 +4177,7 @@ class GenerateGradeSheetForm(APIView): def get(self, request): role = request.GET.get("role") - if not role or role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"error": "Access denied. Invalid or missing role."}, status=status.HTTP_403_FORBIDDEN) @@ -4060,7 +4203,7 @@ def get(self, request): def post(self, request): role = request.data.get("Role") - if not role or role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"error": "Access denied. Invalid or missing role."}, status=status.HTTP_403_FORBIDDEN) @@ -4098,7 +4241,7 @@ def post(self, request): academic_year = request.data.get("academic_year") semester_type = request.data.get("semester_type") - if role not in ["acadadmin", "Dean Academic"]: + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN @@ -4184,7 +4327,7 @@ class GradeValidationView(APIView): def get(self, request): """Return batch list for the dropdown (same format as GenerateGradeSheetForm).""" role = request.GET.get("role") - if role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) batches_qs = Batch.objects.select_related("discipline").order_by("-year", "name") @@ -4196,7 +4339,7 @@ def get(self, request): def post(self, request): role = request.data.get("Role") - if role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) action = request.data.get("action") @@ -4290,7 +4433,7 @@ def _sem_sort_key(key): # Track first appearance of each course to classify remark FAILING_GRADES = {"F", "I", "X", "AU", "CD"} - NON_CREDIT_GRADES = {"F", "I", "X", "AU", "CD", "S"} + NON_CREDIT_GRADES = {"F", "I", "X", "AU", "CD"} course_first_grade: dict = {} # course_id -> first grade string semesters_data = [] @@ -4483,7 +4626,7 @@ def _sem_sort_key(key): "M.Des": "Master of Design", "PhD": "Doctor of Philosophy", } - NON_CREDIT_GRADES2 = {"F", "I", "X", "AU", "CD", "S"} + NON_CREDIT_GRADES2 = {"F", "I", "X", "AU", "CD"} FAILING_GRADES2 = {"F", "I", "X", "AU", "CD"} def _get_student_data(stu): @@ -4758,7 +4901,8 @@ def _build_pdf_bytes(stu_info, semesters): story.append(st2) # ── Credits Details table ──────────────────────────────── - NON_EARN = {"F", "I", "X", "AU", "CD", "S", "—", ""} + # S earns credit (counted in total). X and failing grades do not. + NON_EARN = {"F", "I", "X", "AU", "CD", "—", ""} graded_sems = [s for s in semesters if not s.get("is_registered_only")] cd_rows = [] diff --git a/FusionIIIT/applications/examination/forms.py b/FusionIIIT/applications/examination/forms.py deleted file mode 100644 index 30314d400..000000000 --- a/FusionIIIT/applications/examination/forms.py +++ /dev/null @@ -1,4 +0,0 @@ -from django import forms - -class StudentGradeForm(forms.Form): - grades = forms.CharField(widget=forms.MultipleHiddenInput) diff --git a/FusionIIIT/applications/examination/migrations/0004_auto_20260625_1637.py b/FusionIIIT/applications/examination/migrations/0004_auto_20260625_1637.py new file mode 100644 index 000000000..1282fd4a3 --- /dev/null +++ b/FusionIIIT/applications/examination/migrations/0004_auto_20260625_1637.py @@ -0,0 +1,29 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('examination', '0003_resultannouncement_semester_type'), + ] + + operations = [ + migrations.AddField( + model_name='resultannouncement', + name='per_student_selection', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='PublishedResultStudent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('roll_no', models.CharField(max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('announcement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='published_students', to='examination.resultannouncement')), + ], + options={ + 'unique_together': {('announcement', 'roll_no')}, + }, + ), + ] diff --git a/FusionIIIT/applications/examination/models.py b/FusionIIIT/applications/examination/models.py index 88e59a756..59a28f3c3 100644 --- a/FusionIIIT/applications/examination/models.py +++ b/FusionIIIT/applications/examination/models.py @@ -53,6 +53,10 @@ class ResultAnnouncement(models.Model): blank=True, ) announced = models.BooleanField(default=False) + # True once the result is published via per-student selection. When True, + # only students listed in PublishedResultStudent see their result; when + # False the announcement is published for the whole batch (legacy). + per_student_selection = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) class Meta: @@ -65,3 +69,27 @@ def __str__(self): else: sem_label = f"Sem {self.semester}" return f"{self.batch.name} - {sem_label} - {status}" + + +class PublishedResultStudent(models.Model): + """Per-student selection for a result announcement. + + When the academic admin publishes a result they can pick exactly which + students of the batch are included. A row here means "this student's result + for this announcement is published". If an announcement has no rows at all, + it is treated as published for the whole batch (legacy behaviour). + """ + + announcement = models.ForeignKey( + ResultAnnouncement, + on_delete=models.CASCADE, + related_name="published_students", + ) + roll_no = models.CharField(max_length=20) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = [("announcement", "roll_no")] + + def __str__(self): + return f"{self.announcement_id} - {self.roll_no}" diff --git a/FusionIIIT/applications/examination/urls.py b/FusionIIIT/applications/examination/urls.py index dffe4ba57..5b74e05bd 100644 --- a/FusionIIIT/applications/examination/urls.py +++ b/FusionIIIT/applications/examination/urls.py @@ -1,69 +1,7 @@ +from django.urls import include, path -from django.conf.urls import url -from django.urls import path, include -from . import views -from django.contrib import admin -from .views import update_authentication -from .views import DownloadExcelView, updateGrades app_name = 'examination' urlpatterns = [ - url(r'^api/', include('applications.examination.api.urls')), - url(r'^$', views.exam, name='exam'), - - - url(r'submit/', views.submit, name='submit'),#old - # url(r'verify/', views.verify, name='verify'),#old -# url(r'publish/', views.publish, name='publish'),#old -# url(r'notReady_publish/', views.notReady_publish, name='notReady_publish'),#old -# url(r'timetable/', views.timetable, name='timetable'),#old - # entering and updataing grade -# path('entergrades/', views.entergrades, name='entergrades'),#old -# path('update_hidden_grades_multiple/', views.Updatehidden_gradesMultipleView.as_view(), - # name='update_hidden_grades_multiple'),#old - # path('verifygrades/', views.verifygrades, name='verifygrades'),#old -# path('update_hidden_grades_multiple/', views.Updatehidden_gradesMultipleView.as_view(), -# name='update_hidden_grades_multiple'),#old -# path('submit_hidden_grades_multiple/', views.Submithidden_gradesMultipleView.as_view(), -# name='submit_hidden_grades_multiple'),#old - path('download_excel/', DownloadExcelView.as_view(), name='download_excel'),#old - - #new - url(r'submitGrades/', views.submitGrades.as_view(), name='submitGrades'),#new -# url(r'submitEntergrades/', views.submitEntergrades, name='submitEntergrades'),#new -# path('submitEntergradesStoring/', views.submitEntergradesStoring.as_view(),#new -# name='submitEntergradesStoring'), - #new - url(r'updateGrades/', views.updateGrades, name='updateGrades'),#new - path('updateEntergrades/', views.updateEntergrades, name='updateEntergrades'),#new - path('moderate_student_grades/', views.moderate_student_grades.as_view(),#new - name='moderate_student_grades'), - # authenticate new -# path('authenticate/', views.authenticate, name='authenticate'), #new -# path('authenticategrades/', views.authenticategrades, -# name='authenticategrades'),#new -# path('update_authentication/', update_authentication.as_view(), -# name='update_authentication'),#new - # generate transcript new - path('generate_transcript/', views.generate_transcript, - name='generate_transcript'), #new - path('generate_transcript_form/', views.generate_transcript_form, - name='generate_transcript_form'),#new - # Announcement - url(r'announcement/', views.announcement, name='announcement'),#new - path('upload_grades/',views.upload_grades,name='upload_grades'), - path('message/',views.show_message,name='message'), - path('submitGradesProf/',views.submitGradesProf,name='submitGradesProf'), - path('download_template/',views.download_template,name='download_template'), - path('verifyGradesDean/',views.verifyGradesDean,name='verifyGradesDean'), - path('updateEntergradesDean/',views.updateEntergradesDean,name='updateEnterGradesDean'), - path('upload_grades_prof/',views.upload_grades_prof,name='upload_grades_prof'), - path('validateDean/',views.validateDean,name='validateDean'), - path('validateDeanSubmit/',views.validateDeanSubmit,name='validateDeanSubmit'), - path('downloadGrades/',views.downloadGrades,name='downloadGrades'), - path('generate_pdf/',views.generate_pdf,name='generate_pdf'), - path('generate-result/',views.generate_result,name='generate_pdf'), -# path('get_courses/',views.get_courses,name='get_courses'), -# path('checkresult/',views.checkresult,name='checkresult'), -# path('grades_report/',views.grades_report,name='grades_report'), + path('api/', include('applications.examination.api.urls')), ] diff --git a/FusionIIIT/applications/examination/views.py b/FusionIIIT/applications/examination/views.py deleted file mode 100644 index c56366400..000000000 --- a/FusionIIIT/applications/examination/views.py +++ /dev/null @@ -1,1960 +0,0 @@ -from notifications.signals import notify -from django.views import View -from django.views.generic import View -import traceback -from django.http import HttpResponse -from django.conf import settings -from django.contrib.auth import get_user_model -import csv -import json -from openpyxl import Workbook -from openpyxl.styles import Alignment, Font -from openpyxl.utils import get_column_letter -from io import BytesIO,StringIO -from django.db.models import IntegerField -from django.db.models.functions import Cast -from django.db.models.query_utils import Q -from django.http import request, HttpResponse -from django.shortcuts import get_object_or_404, render, HttpResponse, redirect -from django.http import HttpResponse, HttpResponseRedirect -import itertools -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.contrib.auth.models import User -from datetime import date -import requests -from django.db.models import Q -from django.shortcuts import get_object_or_404, render, redirect -from django.contrib.auth.models import User -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from applications.academic_information.models import Spi, Student, Curriculum -from applications.globals.models import ( - Designation, - ExtraInfo, - HoldsDesignation, - Faculty, -) -from applications.eis.models import faculty_about, emp_research_projects -from applications.academic_information.models import Course -from applications.academic_procedures.models import course_registration, Register,Semester -from applications.programme_curriculum.filters import CourseFilter -from notification.views import examination_notif -from applications.department.models import SpecialRequest, Announcements -from applications.globals.models import ( - DepartmentInfo, - Designation, - ExtraInfo, - Faculty, - HoldsDesignation, -) -from jsonschema import validate -from jsonschema.exceptions import ValidationError -from django.shortcuts import render, redirect, HttpResponse -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework import status -from .models import hidden_grades, grade -from .forms import StudentGradeForm -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework import status -from .models import hidden_grades, authentication -from rest_framework.permissions import AllowAny -from applications.online_cms.models import Student_grades -from django.http import JsonResponse -import csv -from applications.programme_curriculum.models import Course as Courses, CourseInstructor,Discipline,Batch, CourseSlot -from django.urls import reverse -from reportlab.pdfgen import canvas -from reportlab.lib.pagesizes import letter -from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle -from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer -from reportlab.lib import colors -from reportlab.lib.colors import HexColor -from reportlab.lib.units import inch -@login_required(login_url="/accounts/login") -def exam(request): - """ - This function is used to Differenciate acadadmin and all other user. - - @param: - request - contains metadata about the requested page - - @variables: - user_details - Gets the information about the logged in user. - des - Gets the designation about the looged in user. - #""" - user_details = ExtraInfo.objects.get(user=request.user) - des = request.session.get("currentDesignationSelected") - if ( - str(des) == "Associate Professor" - or str(des) == "Professor" - or str(des) == "Assistant Professor" - ): - return HttpResponseRedirect("/examination/submitGradesProf/") - elif request.session.get("currentDesignationSelected") == "acadadmin": - return HttpResponseRedirect("/examination/updateGrades/") - elif request.session.get("currentDesignationSelected") == "Dean Academic": - return HttpResponseRedirect("/examination/verifyGradesDean/") - # elif request.session.get("currentDesignationSelected") == "student": - # return HttpResponseRedirect("/examination/checkresult/") - return HttpResponseRedirect("/dashboard/") - - -@login_required(login_url='/accounts/login') -def submit(request): - des = request.session.get("currentDesignationSelected") - if des == "acadadmin" or des=="Dean Academic" : - pass - else: - return HttpResponseRedirect('/dashboard/') - unique_course_ids = course_registration.objects.values( - 'course_id').distinct() - - # Cast the course IDs to integers - unique_course_ids = unique_course_ids.annotate( - course_id_int=Cast('course_id', IntegerField())) - - # Retrieve course names and course codes based on unique course IDs - courses_info = Course.objects.filter( - id__in=unique_course_ids.values_list('course_id_int', flat=True)) - - return render(request, '../templates/examination/submit.html', {'courses_info': courses_info}) - - -@login_required(login_url='/accounts/login') -def verify(request): - unique_course_ids = hidden_grades.objects.values('course_id').distinct() - - unique_course_ids = unique_course_ids.annotate( - course_id_int=Cast('course_id', IntegerField())) - - courses_info = Course.objects.filter( - id__in=unique_course_ids.values_list('course_id_int', flat=True)) - - return render(request, '../templates/examination/verify.html', {'courses_info': courses_info}) - - -@login_required(login_url='/accounts/login') -def publish(request): - return render(request, '../templates/examination/publish.html', {}) - - -@login_required(login_url='/accounts/login') -def notReady_publish(request): - return render(request, '../templates/examination/notReady_publish.html', {}) - - -@login_required(login_url='/accounts/login') -def timetable(request): - return render(request, '../templates/examination/timetable.html', {}) - - -@login_required(login_url='/accounts/login') -def browse_announcements(): - """ - This function is used to browse Announcements Department-Wise - made by different faculties and admin. - - @variables: - cse_ann - Stores CSE Department Announcements - ece_ann - Stores ECE Department Announcements - me_ann - Stores ME Department Announcements - sm_ann - Stores SM Department Announcements - all_ann - Stores Announcements intended for all Departments - context - Dictionary for storing all above data - - """ - cse_ann = Announcements.objects.filter(department="CSE") - ece_ann = Announcements.objects.filter(department="ECE") - me_ann = Announcements.objects.filter(department="ME") - sm_ann = Announcements.objects.filter(department="SM") - all_ann = Announcements.objects.filter(department="ALL") - print(cse_ann) - context = { - "cse": cse_ann, - "ece": ece_ann, - "me": me_ann, - "sm": sm_ann, - "all": all_ann - } - - return context - - -@login_required(login_url='/accounts/login') -def get_to_request(username): - """ - This function is used to get requests for the receiver - - @variables: - req - Contains request queryset - - """ - req = SpecialRequest.objects.filter(request_receiver=username) - return req - - -@login_required(login_url='/accounts/login') -def entergrades(request): - course_id = request.GET.get('course') - semester_id = request.GET.get('semester') - - course_present = hidden_grades.objects.filter( - course_id=course_id, semester_id=semester_id) - - if (course_present): - return render(request, 'examination/all_course_grade_filled.html', {}) - - registrations = course_registration.objects.filter( - course_id__id=course_id, semester_id=semester_id) - - context = { - 'registrations': registrations - } - - return render(request, 'examination/entergrades.html', context) - - -@login_required(login_url='/accounts/login') -def verifygrades(request): - course_id = request.GET.get('course') - semester_id = request.GET.get('semester') - - registrations = hidden_grades.objects.filter( - course_id=course_id, semester_id=semester_id) - - context = { - 'registrations': registrations - } - - return render(request, 'examination/verifygrades.html', context) - - -@login_required(login_url='/accounts/login') -def authenticate(request): # new - # Retrieve unique course IDs from hidden_grades - unique_course_ids = Student_grades.objects.values('course_id').distinct() - - # Cast the course IDs to integers - unique_course_ids = unique_course_ids.annotate( - course_id_int=Cast('course_id', IntegerField())) - - # Retrieve course names and course codes based on unique course IDs - courses_info = Courses.objects.filter( - id__in=unique_course_ids.values_list('course_id_int', flat=True)) - working_years = Student_grades.objects.values( - 'year').distinct() - context = { - 'courses_info': courses_info, - 'working_years':working_years - - } - print(working_years) - return render(request, '../templates/examination/authenticate.html', context) - - -@login_required(login_url='/accounts/login') -def authenticategrades(request): # new - course_id = request.GET.get('course') - year = request.GET.get('year') - - print(course_id) - print(year) - - course_instance = Courses.objects.get(id=course_id) - registrations = authentication.objects.filter( - course_id=course_instance, course_year=year) - - if registrations: - # Registrations exist, pass them to the template context - context = { - 'registrations': registrations, - 'year': year - } - else: - course_instance = Courses.objects.get(id=course_id) - course_present = Student_grades.objects.filter( - course_id=course_id, year=year) - if (course_present): - authentication_object = authentication.objects.create( - course_id=course_instance, course_year=year) - registrations = authentication.objects.filter( - course_id=course_instance, course_year=year) - - context = { - 'registrations': registrations, - 'course_year': year, - } - - context = { - 'registrations': registrations, - 'year': year - } - - return render(request, 'examination/authenticategrades.html', context) - - -# def examination_notif(sender, recipient, type): -# try: -# url = 'examination:examination' -# module = 'examination' -# verb = type -# flag = "examination" - -# notify.send(sender=sender, -# recipient=recipient, -# url=url, -# module=module, -# verb=verb, -# flag=flag) - -# except Exception as e: -# print("Error sending notification:", e) - - -@login_required(login_url='/accounts/login') -def announcement(request): - des = request.session.get("currentDesignationSelected") - if des == "acadadmin" : - pass - else: - return HttpResponseRedirect('/dashboard/') - """ - This function is contains data for Requests and Announcement Related methods. - Data is added to Announcement Table using this function. - - @param: - request - contains metadata about the requested page - - @variables: - usrnm, user_info, ann_maker_id - Stores data needed for maker - batch, programme, message, upload_announcement, - department, ann_date, user_info - Gets and store data from FORM used for Announcements for Students. - - """ - try: - usrnm = get_object_or_404(User, username=request.user.username) - user_info = usrnm.extrainfo - ann_maker_id = user_info.id - requests_received = get_to_request(usrnm) - - if request.method == 'POST': - batch = request.POST.get('batch', '') - programme = request.POST.get('programme', '') - message = request.POST.get('announcement', '') - upload_announcement = request.FILES.get('upload_announcement') - department = request.POST.get('department') - ann_date = date.today() - - obj1 = Announcements.objects.get_or_create( - maker_id=user_info, - batch=batch, - programme=programme, - message=message, - upload_announcement=upload_announcement, - department=department, - ann_date=ann_date - ) - - recipients = User.objects.all() # Modify this query as per your requirements - examination_notif(sender=usrnm, recipient=recipients, type=message) - return render(request,'department/browse_announcements_staff.html') - print(user_info.user_type) - context = browse_announcements() - return render(request, 'examination/announcement_req.html', { - "user_designation": user_info.user_type, - "announcements": context, - "request_to": requests_received - }) - except Exception as e: - - return render(request, 'examination/announcement_req.html', {"error_message": "An error occurred. Please try again later."}) - - -class Updatehidden_gradesMultipleView(APIView): - permission_classes = [AllowAny] - - def post(self, request): - student_ids = request.POST.getlist('student_ids[]') - semester_ids = request.POST.getlist('semester_ids[]') - course_ids = request.POST.getlist('course_ids[]') - grades = request.POST.getlist('grades[]') - - if len(student_ids) != len(semester_ids) != len(course_ids) != len(grades): - return Response({'error': 'Invalid grade data provided'}, status=status.HTTP_400_BAD_REQUEST) - - for student_id, semester_id, course_id, grade in zip(student_ids, semester_ids, course_ids, grades): - # Create an instance of hidden_grades model and save the data - - try: - hidden_grade = hidden_grades.objects.get( - course_id=course_id, student_id=student_id, semester_id=semester_id) - hidden_grade.grade = grade - hidden_grade.save() - except hidden_grades.DoesNotExist: - # If the grade doesn't exist, create a new one - hidden_grade = hidden_grades.objects.create( - course_id=course_id, student_id=student_id, semester_id=semester_id, grade=grade) - hidden_grade.save() - - hidden_grade.save() - - response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = 'attachment; filename="grades.csv"' - - # Write data to CSV - writer = csv.writer(response) - writer.writerow(['Student ID', 'Semester ID', 'Course ID', 'Grade']) - for student_id, semester_id, course_id, grade in zip(student_ids, semester_ids, course_ids, grades): - writer.writerow([student_id, semester_id, course_id, grade]) - - return response - return render(request, '../templates/examination/grades_updated.html', {}) - - -class Submithidden_gradesMultipleView(APIView): - permission_classes = [AllowAny] - - def post(self, request): - student_ids = request.POST.getlist('student_ids[]') - semester_ids = request.POST.getlist('semester_ids[]') - course_ids = request.POST.getlist('course_ids[]') - grades = request.POST.getlist('grades[]') - - if len(student_ids) != len(semester_ids) != len(course_ids) != len(grades): - return Response({'error': 'Invalid grade data provided'}, status=status.HTTP_400_BAD_REQUEST) - - for student_id, semester_id, course_id, grade in zip(student_ids, semester_ids, course_ids, grades): - # Create an instance of hidden_grades model and save the data - - try: - hidden_grade = hidden_grades.objects.get( - course_id=course_id, student_id=student_id, semester_id=semester_id) - hidden_grade.grade = grade - hidden_grade.save() - except hidden_grades.DoesNotExist: - # If the grade doesn't exist, create a new one - hidden_grade = hidden_grades.objects.create( - course_id=course_id, student_id=student_id, semester_id=semester_id, grade=grade) - hidden_grade.save() - - hidden_grade.save() - - return render(request, '../templates/examination/grades_updated.html', {}) - - -class update_authentication(View): - def post(self, request, *args, **kwargs): - # Extract data from the POST request - course = request.POST.get('course') - course_year = request.POST.get('course_year') - authenticator1 = request.POST.get('authenticator1') - authenticator2 = request.POST.get('authenticator2') - authenticator3 = request.POST.get('authenticator3') - - # Retrieve the registration object - try: - - course_instance = Courses.objects.get(id=course) - registration = authentication.objects.get( - course_id=course_instance, course_year=course_year) - except authentication.DoesNotExist: - # Redirect if registration does not exist - return redirect('examination:submitGrades') - - # Update authenticators if the values have changed - if authenticator1 is not None: - registration.authenticator_1 = (authenticator1 == '1') - else: - registration.authenticator_1 = 0 - if authenticator2 is not None: - registration.authenticator_2 = (authenticator2 == '1') - else: - registration.authenticator_2 = 0 - if authenticator3 is not None: - registration.authenticator_3 = (authenticator3 == '1') - else: - registration.authenticator_3 = 0 - - # Save the changes - registration.save() - - # Redirect to the appropriate page - return redirect('examination:authenticate') - - -class DownloadExcelView(View): - def post(self, request, *args, **kwargs): - # Retrieve form data - student_ids = request.POST.getlist('student_ids[]') - semester_ids = request.POST.getlist('semester_ids[]') - course_ids = request.POST.getlist('course_ids[]') - grades = request.POST.getlist('grades[]') - - # Create a CSV response - response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = 'attachment; filename="grades.csv"' - - # Write data to CSV - writer = csv.writer(response) - writer.writerow(['Student ID', 'Semester ID', 'Course ID', 'Grade']) - for student_id, semester_id, course_id, grade in zip(student_ids, semester_ids, course_ids, grades): - writer.writerow([student_id, semester_id, course_id, grade]) - - return response - -@login_required(login_url="/accounts/login") -def generate_transcript(request): - des = request.session.get("currentDesignationSelected") - if des == "acadadmin" : - pass - else: - return HttpResponseRedirect('/dashboard/') - - - student_id = request.GET.get('student') - semester = request.GET.get('semester') - courses_registered = Student_grades.objects.filter( - roll_no=student_id, semester=semester) - - # Initialize a dictionary to store course grades - course_grades = {} - - total_course_registered = Student_grades.objects.filter( - roll_no=student_id, semester__lte=semester) - # for each_course in total_course_registered: - # course_name = Curriculum.objects.filter( - # curriculum_id=each_course.curr_id_id) - - - - for course in courses_registered: - try: - # Attempt to fetch the grade for the course from Student_grades - grade = Student_grades.objects.get( - roll_no=student_id, course_id=course.course_id) - - # course_detail = Curriculum.objects.get( - # course_id=course.course_id, batch=grade.batch) - course_instance = Courses.objects.get(id=course.course_id_id) - # check_authentication_object = authentication.objects.filter( - # course_id=course_instance, course_year=grade.year) - # all_authenticators_true = True - - # if check_authentication_object: - # # Iterate over each authentication object - # for auth_obj in check_authentication_object: - # # Check if all authenticators are true - # if not (auth_obj.authenticator_1 and auth_obj.authenticator_2 and auth_obj.authenticator_3): - # all_authenticators_true = False - # break # No need to check further if any authenticator is False - # else: - # Create authentication object if it doesn't exist - # authentication_object = authentication.objects.create( - # course_id=course_instance, course_year=grade.year) - # Get all registrations for the course and year - # registrations = authentication.objects.filter( - # course_id=course_instance, course_year=grade.year) - # all_authenticators_true = False - - course_grades[course_instance] = { - 'grade': grade, - # 'all_authenticators_true': all_authenticators_true - } # Store the grade - except Student_grades.DoesNotExist: - # Grade not available - course_grades[course] = "Grading not done yet" - - context = { - 'courses_grades': course_grades, - 'total_course_registered':total_course_registered - } - # print(context) - - return render(request, 'examination/generate_transcript.html', context) - -# new - -@login_required(login_url="/accounts/login") -def generate_transcript_form(request): - des = request.session.get("currentDesignationSelected") - if des == "acadadmin" : - pass - else: - return HttpResponseRedirect('/dashboard/') - if request.method == 'POST': - programme = request.POST.get('programme') - batch = request.POST.get('batch') - specialization = request.POST.get('specialization') - semester = request.POST.get('semester') - - if specialization == None: - students = Student.objects.filter( - programme=programme, batch=batch) - else: - students = Student.objects.filter( - programme=programme, batch=batch, specialization=specialization) - - # Pass the filtered students to the template - context = { - 'students': students, - 'semester': semester - } - return render(request, 'examination/generate_transcript_students.html', context) - else: - programmes = Student.objects.values_list( - 'programme', flat=True).distinct() - specializations = Student.objects.exclude( - specialization__isnull=True).values_list('specialization', flat=True).distinct() - batches = Student.objects.values_list('batch', flat=True).distinct() - context = { - 'programmes': programmes, - 'batches': batches, - 'specializations': specializations, - } - - return render(request, 'examination/generate_transcript_form.html', context) - - -@login_required(login_url="/accounts/login") -def updateGrades(request): - des = request.session.get("currentDesignationSelected") - if des == "acadadmin": - pass - else: - if request.is_ajax(): - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - unique_course_ids = Student_grades.objects.filter(verified=False).values("course_id").distinct() - - # Cast the course IDs to integers - unique_course_ids = unique_course_ids.annotate( - course_id_int=Cast("course_id", IntegerField()) - ) - - # Retrieve course names and course codes based on unique course IDs - - # print(unique_course_ids) - courses_info = Courses.objects.filter( - id__in=unique_course_ids.values_list("course_id_int", flat=True) - ) - - unique_year_ids = Student_grades.objects.values('year').distinct() - # print(unique_year_ids) - context = { - "courses_info": courses_info, - "unique_year_ids": unique_year_ids, - } - - return render(request, "../templates/examination/submitGrade.html", context) - -@login_required(login_url="/accounts/login") -def updateEntergrades(request): - des = request.session.get("currentDesignationSelected") - if des == "acadadmin": - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - course_id = request.GET.get("course") - - year = request.GET.get("year") - # print(course_id,semester_id ,year) - course_present = Student_grades.objects.filter( - course_id=course_id, year=year - ) - - if not course_present: - context = {"message": "THIS COURSE IS NOT SUBMITTED BY THE INSTRUCTOR"} - return render(request, "../templates/examination/message.html", context) - - verification = course_present.first().verified - print(verification) - if verification: - context = {"message": "THIS COURSE IS VERIFIED"} - return render(request, "../templates/examination/message.html", context) - - context = {"registrations": course_present} - - return render(request, "../templates/examination/updateEntergrades.html", context) - -class moderate_student_grades(APIView): - permission_classes = [AllowAny] - - def post(self, request): - des = request.session.get("currentDesignationSelected") - if des == "acadadmin" or des=="Dean Academic": - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - student_ids = request.POST.getlist('student_ids[]') - semester_ids = request.POST.getlist('semester_ids[]') - course_ids = request.POST.getlist('course_ids[]') - grades = request.POST.getlist('grades[]') - allow_resubmission = request.POST.get('allow_resubmission', 'NO') - - if len(student_ids) != len(semester_ids) != len(course_ids) != len(grades): - return Response({'error': 'Invalid grade data provided'}, status=status.HTTP_400_BAD_REQUEST) - - for student_id, semester_id, course_id, grade in zip(student_ids, semester_ids, course_ids, grades): - - try: - grade_of_student = Student_grades.objects.get( - course_id=course_id, roll_no=student_id, semester=semester_id) - grade_of_student.grade = grade - grade_of_student.verified = True - if allow_resubmission == 'YES': - grade_of_student.reSubmit = True - grade_of_student.save() - except Student_grades.DoesNotExist: - # If the grade doesn't exist, create a new one - hidden_grade = hidden_grades.objects.create( - course_id=course_id, student_id=student_id, semester_id=semester_id, grade=grade) - hidden_grade.save() - - response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = 'attachment; filename="grades.csv"' - - # Write data to CSV - writer = csv.writer(response) - writer.writerow(['Student ID', 'Semester ID', 'Course ID', 'Grade']) - for student_id, semester_id, course_id, grade in zip(student_ids, semester_ids, course_ids, grades): - writer.writerow([student_id, semester_id, course_id, grade]) - print("HELLO") - return response - return render(request, '../templates/examination/grades_updated.html', {}) - - -class submitGrades(APIView): - login_url = "/accounts/login" - - def get(self, request): - des = request.session.get("currentDesignationSelected") - if des == "acadadmin": - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - academic_year = request.GET.get('academic_year') - - if academic_year: - if academic_year is None or not academic_year.isdigit(): - return JsonResponse({}) - # Filter course registration based on the academic year and get unique course IDs - unique_course_ids = course_registration.objects.filter( - working_year=academic_year - ).values("course_id").distinct() - - # Cast the course IDs to integers - unique_course_ids = unique_course_ids.annotate( - course_id_int=Cast("course_id", IntegerField()) - ) - - # Retrieve course information based on the unique course IDs - courses_info = Courses.objects.filter( - id__in=unique_course_ids.values_list("course_id_int", flat=True) - ).order_by('code') - - # Return the course information as JSON response - return JsonResponse({"courses": list(courses_info.values())}) - - # If no academic year is provided, return the working years for the dropdown - working_years = course_registration.objects.values("working_year").distinct() - - context = {"working_years": working_years} - - return render(request, "../templates/examination/gradeSubmission.html", context) - -@login_required(login_url="/accounts/login") -def submitEntergrades(request): - - course_id = request.GET.get("course") - year = request.GET.get("year") - if year is None or not year.isdigit(): - message = "YEAR SHOULD NOT BE NONE" - context = {"message": message} - - return render(request, "../templates/examination/message.html", context) - return HttpResponse("Invalid year parameter") - # Handle invalid year parameter - # You can return an error response or redirect the user to an error page - courses_info = Courses.objects.get(id=course_id) - - courses = Student_grades.objects.filter(course_id=courses_info.id, year=year) - - if courses: - message = "THIS Course was Already Submitted" - context = {"message": message} - - return render(request, "../templates/examination/message.html", context) - - students = course_registration.objects.filter( - course_id_id=course_id, working_year=year - ) - - # print(students) - - context = {"registrations": students, "curr_id": course_id, "year": year} - - return render(request, "../templates/examination/gradeSubmissionForm.html", context) - - -class submitEntergradesStoring(APIView): - permission_classes = [AllowAny] - - def post(self, request): - student_ids = request.POST.getlist("student_ids[]") - batch_ids = request.POST.getlist("batch_ids[]") - course_ids = request.POST.getlist("course_ids[]") - semester_ids = request.POST.getlist("semester_ids[]") - year_ids = request.POST.getlist("year_ids[]") - marks = request.POST.getlist("marks[]") - Student_grades = request.POST.getlist("Student_grades[]") - - if ( - len(student_ids) - != len(batch_ids) - != len(course_ids) - != len(semester_ids) - != len(year_ids) - != len(marks) - != len(Student_grades) - ): - return Response( - {"error": "Invalid Student_grades data provided"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - for ( - student_id, - batch_id, - course_id, - semester_id, - year_id, - mark, - Student_grades, - ) in zip( - student_ids, - batch_ids, - course_ids, - semester_ids, - year_ids, - marks, - Student_grades, - ): - # Create an instance of hidden_grades model and save the data - - try: - grade_of_student = Student_grades.objects.get( - course_id=course_id, roll_no=student_id, semester=semester_id - ) - except Student_grades.DoesNotExist: - # If the Student_grades doesn't exist, create a new one - course_instance = Courses.objects.get(id=course_id) - student_grade = Student_grades.objects.create( - course_id=course_instance, - roll_no=student_id, - semester=semester_id, - Student_grades=Student_grades, - batch=batch_id, - year=year_id, - total_marks=mark, - ) - student_grade.save() - - response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = 'attachment; filename="Student_grades.csv"' - - # Write data to CSV - writer = csv.writer(response) - writer.writerow( - [ - "student_id", - "batch_ids", - "course_id", - "semester_id", - "year_ids", - "marks", - "Student_grades", - ] - ) - for ( - student_id, - batch_id, - course_id, - semester_id, - year_id, - mark, - Student_grades, - ) in zip( - student_ids, - batch_ids, - course_ids, - semester_ids, - year_ids, - marks, - Student_grades, - ): - writer.writerow( - [ - student_id, - batch_id, - course_id, - semester_id, - year_id, - mark, - Student_grades, - ] - ) - - return response - return render(request, "../templates/examination/grades_updated.html", {}) - -@login_required(login_url="/accounts/login") -def upload_grades(request): - if request.method == "POST" and request.FILES.get("csv_file"): - des = request.session.get("currentDesignationSelected") - if des == "acadadmin": - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - csv_file = request.FILES["csv_file"] - - if not csv_file.name.endswith(".csv"): - return JsonResponse( - {"error": "Invalid file format. Please upload a CSV file."}, status=400 - ) - - course_id = request.POST.get("course_id") - academic_year = request.POST.get("academic_year") - # semester = request.POST.get('semester') - - if academic_year == "None" or not academic_year.isdigit(): - return JsonResponse( - {"error": "Academic year must be a valid number."}, status=400 - ) - - if not course_id or not academic_year: - return JsonResponse( - {"error": "Course ID and Academic Year are required."}, status=400 - ) - - courses_info = Courses.objects.get(id=course_id) - - courses = Student_grades.objects.filter( - course_id=courses_info.id, year=academic_year - ) - students = course_registration.objects.filter( - course_id_id=course_id, working_year=academic_year - ) - - if not students: - message = "NO STUDENTS REGISTERED IN THIS COURSE THIS SEMESTER" - redirect_url = reverse("examination:message") + f"?message={message}" - return JsonResponse( - {"error": message, "redirect_url": redirect_url}, status=400 - ) - - if courses and not courses.first().reSubmit: - - message = "THIS Course was Already Submitted" - redirect_url = reverse("examination:message") + f"?message={message}" - return JsonResponse( - {"error": message, "redirect_url": redirect_url}, status=400 - ) - - - - try: - # Parse the CSV file - decoded_file = csv_file.read().decode("utf-8").splitlines() - reader = csv.DictReader(decoded_file) - - required_columns = ["roll_no", "grade", "remarks"] - if not all(column in reader.fieldnames for column in required_columns): - return JsonResponse( - { - "error": "CSV file must contain the following columns: roll_no, grade, remarks." - }, - status=400, - ) - - for row in reader: - roll_no = row["roll_no"] - grade = row["grade"] - remarks = row["remarks"] - semester = row["semester"] if "semester" in row and row["semester"] else None - stud = Student.objects.get(id_id=roll_no) - semester = semester or stud.curr_semester_no - batch=stud.batch - reSubmit=False - - Student_grades.objects.update_or_create( - roll_no=roll_no, - course_id_id=course_id, - year=academic_year, - semester=semester, - batch=batch, - # Fields that will be updated if a match is found - defaults={ - 'grade': grade, - 'remarks': remarks, - 'reSubmit': reSubmit, - } - ) - des = request.session.get("currentDesignationSelected") - if ( - str(des) == "Associate Professor" - or str(des) == "Professor" - or str(des) == "Assistant Professor" - ): - return JsonResponse( - { - "message": "Grades uploaded successfully.", - "redirect_url": "/examination/submitGradesProf", - } - ) - return JsonResponse( - { - "message": "Grades uploaded successfully.", - "redirect_url": "/examination/submitGrades", - } - ) - - except Courses.DoesNotExist: - return JsonResponse({"error": "Invalid course ID."}, status=400) - - except Exception as e: - return JsonResponse({"error": f"An error occurred: {e}"}, status=500) - - return JsonResponse( - {"error": "Invalid request. Please upload a CSV file."}, status=400 - ) - -@login_required(login_url="/accounts/login") -def show_message(request): - des = request.session.get("currentDesignationSelected") - if des == "acadadmin" or str(des) == "Associate Professor" or str(des) == "Professor" or str(des) == "Assistant Professor" : - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - message = request.GET.get("message", "Default message if none provided.") - des = request.session.get("currentDesignationSelected") - if ( - str(des) == "Associate Professor" - or str(des) == "Professor" - or str(des) == "Assistant Professor" - ): - return render(request, "examination/messageProf.html", {"message": message}) - return render(request, "examination/message.html", {"message": message}) - - -@login_required(login_url="/accounts/login") -def submitGradesProf(request): - des = request.session.get("currentDesignationSelected") - if str(des) == "Associate Professor" or str(des) == "Professor" or str(des) == "Assistant Professor" : - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - # print(request.user,1) - unique_course_ids = ( - CourseInstructor.objects.filter(instructor_id_id=request.user.username) - .values("course_id_id") - .distinct() - ) - # unique_course_ids = course_registration.objects.values( - # 'course_id').distinct() - working_years = course_registration.objects.values("working_year").distinct() - course_ids_final=course_registration.objects.filter() - # Cast the course IDs to integers - unique_course_ids = unique_course_ids.annotate( - course_id_int=Cast("course_id", IntegerField()) - ) - - # Retrieve course names and course codes based on unique course IDs - - # print(unique_course_ids) - courses_info = Courses.objects.filter( - id__in=unique_course_ids.values_list("course_id_int", flat=True) - ) - - context = {"courses_info": courses_info, "working_years": working_years} - - print(working_years) - - return render(request, "../templates/examination/submitGradesProf.html", context) - -@login_required(login_url="/accounts/login") -def download_template(request): - des = request.session.get("currentDesignationSelected") - if des not in ["acadadmin", "Associate Professor", "Professor", "Assistant Professor", "Dean Academic"]: - if request.is_ajax(): - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: - return HttpResponseRedirect('/dashboard/') - - course = request.GET.get('course') - year = request.GET.get('year') - - if not course or not year: - return JsonResponse({'error': 'Course and year are required'}, status=400) - - try: - # Fetching the custom user model - User = get_user_model() - - course_info = course_registration.objects.filter( - course_id_id=course, - working_year=year - ) - - if not course_info.exists(): - return JsonResponse({'error': 'No registration data found for the provided course and year'}, status=404) - - course_obj = course_info.first().course_id - response = HttpResponse(content_type="text/csv") - filename = f"{course_obj.code}_template_{year}.csv" - response['Content-Disposition'] = f'attachment; filename="{filename}"' - - # Create CSV writer - writer = csv.writer(response) - - # Write header - writer.writerow(["roll_no", "name", "grade", "remarks"]) - - # Write student roll numbers and names - for entry in course_info: - student_entry = entry.student_id - # Fetching the user instance dynamically - student_user = User.objects.get(username=student_entry.id_id) - writer.writerow([student_entry.id_id, student_user.first_name+" "+student_user.last_name, "", ""]) - - return response - - except Exception as e: - print(f"Error in download_template: {str(e)}") - return JsonResponse({'error': 'An unexpected error occurred'}, status=500) - - -@login_required(login_url="/accounts/login") -def verifyGradesDean(request): - des = request.session.get("currentDesignationSelected") - if des=="Dean Academic" : - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - unique_course_ids = Student_grades.objects.filter(verified=True).values("course_id").distinct() - - # Cast the course IDs to integers - unique_course_ids = unique_course_ids.annotate( - course_id_int=Cast("course_id", IntegerField()) - ) - - # Retrieve course names and course codes based on unique course IDs - - # print(unique_course_ids) - courses_info = Courses.objects.filter( - id__in=unique_course_ids.values_list("course_id_int", flat=True) - ).order_by("code") - - unique_year_ids = Student_grades.objects.values("year").distinct() - - context = { - "courses_info": courses_info, - "unique_year_ids": unique_year_ids, - } - - return render(request, "../templates/examination/submitGradeDean.html", context) - -@login_required(login_url="/accounts/login") -def updateEntergradesDean(request): - des = request.session.get("currentDesignationSelected") - if des=="Dean Academic" : - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - course_id = request.GET.get("course") - year = request.GET.get("year") - course_present = Student_grades.objects.filter( - course_id=course_id, year=year - ) - - if not course_present: - context = {"message": "THIS COURSE IS NOT SUBMITTED BY THE INSTRUCTOR"} - return render(request, "../templates/examination/messageDean.html", context) - - context = {"registrations": course_present} - - return render(request, "../templates/examination/updateEntergradesDean.html", context) - - -@login_required(login_url="/accounts/login") -def upload_grades_prof(request): - des = request.session.get("currentDesignationSelected") - if str(des) == "Associate Professor" or str(des) == "Professor" or str(des) == "Assistant Professor" : - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - if request.method == "POST" and request.FILES.get("csv_file"): - csv_file = request.FILES["csv_file"] - - if not csv_file.name.endswith(".csv"): - return JsonResponse( - {"error": "Invalid file format. Please upload a CSV file."}, status=400 - ) - - course_id = request.POST.get("course_id") - academic_year = request.POST.get("academic_year") - # semester = request.POST.get('semester') - - if academic_year == "None" or not academic_year.isdigit(): - return JsonResponse( - {"error": "Academic year must be a valid number."}, status=400 - ) - - if not course_id or not academic_year: - return JsonResponse( - {"error": "Course ID and Academic Year are required."}, status=400 - ) - - courses_info = Courses.objects.get(id=course_id) - - courses = Student_grades.objects.filter( - course_id=courses_info.id, year=academic_year - ) - students = course_registration.objects.filter( - course_id_id=course_id, working_year=academic_year - ) - - if not students: - message = "NO STUDENTS REGISTERED IN THIS COURSE THIS SEMESTER" - redirect_url = reverse("examination:message") + f"?message={message}" - return JsonResponse( - {"error": message, "redirect_url": redirect_url}, status=400 - ) - - if courses and not courses.first().reSubmit: - - message = "THIS Course was Already Submitted" - redirect_url = reverse("examination:message") + f"?message={message}" - return JsonResponse( - {"error": message, "redirect_url": redirect_url}, status=400 - ) - - - - try: - # Parse the CSV file - decoded_file = csv_file.read().decode("utf-8").splitlines() - reader = csv.DictReader(decoded_file) - - required_columns = ["roll_no", "grade", "remarks"] - if not all(column in reader.fieldnames for column in required_columns): - return JsonResponse( - { - "error": "CSV file must contain the following columns: roll_no, grade, remarks." - }, - status=400, - ) - - for row in reader: - roll_no = row["roll_no"] - grade = row["grade"] - remarks = row["remarks"] - semester = row["semester"] if "semester" in row and row["semester"] else None - stud = Student.objects.get(id_id=roll_no) - semester = semester or stud.curr_semester_no - batch=stud.batch - reSubmit=False - - Student_grades.objects.update_or_create( - roll_no=roll_no, - course_id_id=course_id, - year=academic_year, - semester=semester, - batch=batch, - # Fields that will be updated if a match is found - defaults={ - 'grade': grade, - 'remarks': remarks, - 'reSubmit': reSubmit, - } - ) - des = request.session.get("currentDesignationSelected") - if ( - str(des) == "Associate Professor" - or str(des) == "Professor" - or str(des) == "Assistant Professor" - ): - return JsonResponse( - { - "message": "Grades uploaded successfully.", - "redirect_url": "/examination/submitGradesProf", - } - ) - return JsonResponse( - { - "message": "Grades uploaded successfully.", - "redirect_url": "/examination/submitGradesProf", - } - ) - - except Courses.DoesNotExist: - return JsonResponse({"error": "Invalid course ID."}, status=400) - - except Exception as e: - return JsonResponse({"error": f"An error occurred: {e}"}, status=500) - - return JsonResponse( - {"error": "Invalid request. Please upload a CSV file."}, status=400 - ) - - -@login_required(login_url="/accounts/login") -def validateDean(request): - des = request.session.get("currentDesignationSelected") - if des=="Dean Academic" : - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - unique_course_ids = Student_grades.objects.filter(verified=True).values("course_id").distinct() - - # Cast the course IDs to integers - unique_course_ids = unique_course_ids.annotate( - course_id_int=Cast("course_id", IntegerField()) - ) - - # Retrieve course names and course codes based on unique course IDs - - # print(unique_course_ids) - courses_info = Courses.objects.filter( - id__in=unique_course_ids.values_list("course_id_int", flat=True) - ) - working_years = course_registration.objects.values("working_year").distinct() - - unique_batch_ids = Student_grades.objects.values("batch").distinct() - - context = {"courses_info": courses_info, "working_years": working_years} - - return render(request, "../templates/examination/validation.html", context) - -@login_required(login_url="/accounts/login") -def validateDeanSubmit(request): - des = request.session.get("currentDesignationSelected") - if des=="Dean Academic" : - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - if request.method == "POST" and request.FILES.get("csv_file"): - csv_file = request.FILES["csv_file"] - - if not csv_file.name.endswith(".csv"): - message = "Please Submit a csv file " - context = { - "message":message, - } - return render(request, "../templates/examination/messageDean.html", context) - - course_id = request.POST.get("course") - academic_year = request.POST.get("year") - # semester = request.POST.get('semester') - print(academic_year) - if academic_year is None or not academic_year.isdigit(): - message = "Academic Year must be a number" - context = { - "message":message, - } - return render(request, "../templates/examination/messageDean.html", context) - - if not course_id or not academic_year: - message = "Course and Academic year are required" - context = { - "message":message, - } - return render(request, "../templates/examination/messageDean.html", context) - - # courses_info = Courses.objects.get(id=course_id) - - # courses = Student_grades.objects.filter( - # course_id=courses_info.id, year=academic_year - # ) - students = course_registration.objects.filter( - course_id_id=course_id, working_year=academic_year - ) - - - - try: - # Parse the CSV file - decoded_file = csv_file.read().decode("utf-8").splitlines() - reader = csv.DictReader(decoded_file) - - required_columns = ["roll_no", "grade", "remarks"] - if not all(column in reader.fieldnames for column in required_columns): - message = "CSV file must contain the following columns: roll_no, grade, remarks." - context = { - "message":message, - } - return render(request, "../templates/examination/messageDean.html", context) - - - mismatch=[] - for row in reader: - roll_no = row["roll_no"] - grade = row["grade"] - remarks = row["remarks"] - stud=Student.objects.get(id_id=roll_no) - semester=stud.curr_semester_no - batch=stud.batch - Student_grades.objects.filter( - roll_no=roll_no, - course_id_id=course_id, - year=academic_year, - batch=batch, - ) - student_grade = Student_grades.objects.get( - roll_no=roll_no, - course_id_id=course_id, - year=academic_year, - batch=batch - ) - if student_grade.grade != grade: - mismatch.append({ - "roll_no": roll_no, - "csv_grade": grade, - "db_grade": student_grade.grade, - "remarks": remarks, - "batch": batch, - "semester": semester, - "course_id": course_id, - }) - if not mismatch: - message = "There Are no Mismatches" - context = { - "message":message, - } - return render(request, "../templates/examination/messageDean.html", context) - context = { - "mismatch": mismatch, - } - return render(request, "../templates/examination/validationSubmit.html", context) - - except Exception as e: - - error_message = f"An error occurred while processing the file: {str(e)}" - context = { - "message": error_message, - } - return render(request, "../templates/examination/messageDean.html", context) - -@login_required(login_url="/accounts/login") -def downloadGrades(request): - des = request.session.get("currentDesignationSelected") - if str(des) == "Associate Professor" or str(des) == "Professor" or str(des) == "Assistant Professor" : - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - academic_year = request.GET.get('academic_year') - - if academic_year: - if academic_year is None or not academic_year.isdigit(): - return JsonResponse({}) - # print(request.user,1) - unique_course_ids = ( - CourseInstructor.objects.filter(instructor_id_id=request.user.username) - .values("course_id_id") - .distinct() - ) - # unique_course_ids = course_registration.objects.values( - # 'course_id').distinct() - - # Cast the course IDs to integers - unique_course_ids = unique_course_ids.annotate( - course_id_int=Cast("course_id", IntegerField()) - ) - # Retrieve course names and course codes based on unique course IDs - - courses_info = Student_grades.objects.filter( - year=academic_year, - course_id_id__in=unique_course_ids.values_list("course_id_int", flat=True) - ) - courses_details=Courses.objects.filter( - id__in=courses_info.values_list("course_id_id", flat=True) - ) - # print(courses_info.values(),'abcd') - return JsonResponse({"courses": list(courses_details.values())}) - - - working_years = course_registration.objects.values("working_year").distinct() - - context = {"working_years": working_years} - - return render(request, "../templates/examination/download_resultProf.html", context) - - -@login_required(login_url="/accounts/login") -def generate_pdf(request): - des = request.session.get("currentDesignationSelected") - if str(des) == "Associate Professor" or str(des) == "Professor" or str(des) == "Assistant Professor" : - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - course_id = request.POST.get('course_id') - academic_year = request.POST.get('academic_year') - course_info = get_object_or_404(Courses, id=course_id) - grades = Student_grades.objects.filter(course_id_id=course_id, year=academic_year).order_by("roll_no") - course=CourseInstructor.objects.filter(course_id_id=course_id,year=academic_year,instructor_id_id=request.user.username) - if not course: - return JsonResponse({"success": False, "error": "course not found."}, status=404) - semester=course.first().semester_no - - all_grades = ["O", "A+", "A", "B+", "B", "C+", "C", "D+", "D", "F", "I", "S", "X"] - grade_counts = {grade: grades.filter(grade=grade).count() for grade in all_grades} - - response = HttpResponse(content_type="application/pdf") - response["Content-Disposition"] = f'attachment; filename="{course_info.code}_grades.pdf"' - - doc = SimpleDocTemplate(response, pagesize=letter) - elements = [] - styles = getSampleStyleSheet() - - # Custom Header Style - header_style = ParagraphStyle( - "HeaderStyle", - parent=styles["Heading1"], - fontName="Helvetica-Bold", - fontSize=16, - textColor=HexColor("#333333"), - spaceAfter=20, - alignment=1, # Center alignment - ) - subheader_style = ParagraphStyle( - "SubheaderStyle", - parent=styles["Normal"], - fontSize=12, - textColor=HexColor("#666666"), - spaceAfter=10, - ) - instructor = request.user.first_name + " " + request.user.last_name - - # Add Header - elements.append(Paragraph(f"Grade Sheet", header_style)) - field_label_style = ParagraphStyle( - "FieldLabelStyle", - parent=styles["Normal"], - fontSize=12, - textColor=colors.black, # Black text color for labels - spaceAfter=5, -) - field_value_style = ParagraphStyle( - "FieldValueStyle", - parent=styles["Normal"], - fontSize=12, - textColor=HexColor("#666666"), # Gray text color for values - spaceAfter=10, -) - -# Add fields with labels in black and values in gray - elements.append(Paragraph(f"Session: {academic_year}", field_label_style)) - elements.append(Paragraph(f"Semester: {semester}", field_label_style)) - elements.append(Paragraph(f"Course Code: {course_info.code}", field_label_style)) - elements.append(Paragraph(f"Course Name: {course_info.name}", field_label_style)) - elements.append(Paragraph(f"Instructor: {instructor}", field_label_style)) - - # Table Data with Wider Column Widths - data = [["S.No.", "Roll Number", "Grade"]] - for i, grade in enumerate(grades, 1): - data.append([i, grade.roll_no, grade.grade]) - table = Table(data, colWidths=[80, 300, 100]) - - # Improved Table Style - table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), HexColor("#E0E0E0")), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.black), - ("ALIGN", (0, 0), (-1, 0), "CENTER"), - ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), - ("FONTSIZE", (0, 0), (-1, 0), 14), - ("BOTTOMPADDING", (0, 0), (-1, 0), 10), - ("BACKGROUND", (0, 1), (-1, -1), HexColor("#F9F9F9")), - ("ROWBACKGROUNDS", (0, 1), (-1, -1), [HexColor("#F9F9F9"), colors.white]), - ("TEXTCOLOR", (0, 1), (-1, -1), colors.black), - ("FONTNAME", (0, 1), (-1, -1), "Helvetica"), - ("FONTSIZE", (0, 1), (-1, -1), 12), - ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ] - ) - ) - elements.append(table) - elements.append(Spacer(1, 20)) - - # Add Grade Distribution with Row Splitting - elements.append(Paragraph(f"Grade Distribution:", header_style)) - - # First Grade Table - grade_data1 = [["O", "A+", "A", "B+", "B", "C+", "C", "D+"]] - grade_data1.append([grade_counts[grade] for grade in grade_data1[0]]) - grade_table1 = Table(grade_data1, colWidths=[60] * 8) - grade_table1.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), HexColor("#E0E0E0")), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.black), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), - ("FONTSIZE", (0, 0), (-1, 0), 12), - ("BOTTOMPADDING", (0, 0), (-1, 0), 8), - ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), - ("BACKGROUND", (0, 1), (-1, -1), colors.white), - ("TEXTCOLOR", (0, 1), (-1, -1), colors.black), - ] - ) - ) - elements.append(grade_table1) - elements.append(Spacer(1, 10)) - - # Second Grade Table - grade_data2 = [["D", "F", "I", "S", "X"]] - grade_data2.append([grade_counts[grade] for grade in grade_data2[0]]) - grade_table2 = Table(grade_data2, colWidths=[60] * 5) - grade_table2.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, 0), HexColor("#E0E0E0")), - ("TEXTCOLOR", (0, 0), (-1, 0), colors.black), - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), - ("FONTSIZE", (0, 0), (-1, 0), 12), - ("BOTTOMPADDING", (0, 0), (-1, 0), 8), - ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), - ("BACKGROUND", (0, 1), (-1, -1), colors.white), - ("TEXTCOLOR", (0, 1), (-1, -1), colors.black), - ] - ) - ) - elements.append(grade_table2) - elements.append(Spacer(1, 40)) - - verified_style = ParagraphStyle( - "VerifiedStyle", - parent=styles["Normal"], - fontSize=13, - textColor=HexColor("#333333"), - alignment=0, # Center alignment - spaceAfter=20, - ) - elements.append(Paragraph("I have carefully checked and verified the submitted grade. The grade distribution and submitted grades are correct. [Please mention any exception below.]", verified_style)) - - # Footer Signatures - def draw_signatures(canvas, doc): - canvas.saveState() - width, height = letter - canvas.drawString(inch, 0.75 * inch, "") - canvas.drawString(inch, 0.5 * inch, "Date") - canvas.drawString(width - 4 * inch, 0.75 * inch, "") - canvas.drawString(width - 4 * inch, 0.5 * inch, "Course Instructor's Signature") - canvas.restoreState() - - doc.build(elements, onLaterPages=draw_signatures, onFirstPage=draw_signatures) - return response - - -@login_required(login_url="/accounts/login") -def generate_result(request): - if request.method == 'POST': - des = request.session.get("currentDesignationSelected") - if des == "acadadmin": - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - try: - data = json.loads(request.body) - semester = data.get('semester') - branch = data.get('specialization') - batch = data.get('batch') - - branch_info = Discipline.objects.filter(acronym=branch).first() - if not branch_info: - return JsonResponse({'error': 'Branch not found'}, status=404) - - curriculum_id = Batch.objects.filter( - year=batch, discipline_id=branch_info.id - ).values_list('curriculum_id', flat=True).first() - if not curriculum_id: - return JsonResponse({'error': 'Curriculum not found'}, status=404) - - semester_info = Semester.objects.filter( - curriculum_id=curriculum_id, semester_no=semester - ).first() - if not semester_info: - return JsonResponse({'error': 'Semester not found'}, status=404) - # print(batch, branch) - course_slots = CourseSlot.objects.filter(semester_id=semester_info) - course_ids_from_slots = course_slots.values_list('courses', flat=True) - course_ids_from_grades = Student_grades.objects.filter( - batch=batch, - semester=semester - ).values_list('course_id_id', flat=True) - course_ids = set(course_ids_from_slots).union(set(course_ids_from_grades)) - courses = Courses.objects.filter(id__in=course_ids) - courses_map={} - for course in courses: - courses_map[course.id]=(course.credit) - students = Student.objects.filter(batch=batch, specialization=branch).order_by('id') - # print(students.first().id_id,"studejt id") - - wb = Workbook() - ws = wb.active - ws.title = "Student Grades" - - - # ws.merge_cells(start_row=1, start_column=1, end_row=4, end_column=1) - # ws.merge_cells(start_row=1, start_column=2, end_row=4, end_column=2) - ws["A1"] = "S. No" - ws["B1"] = "Roll No" - for cell in ("A1", "B1"): - ws[cell].alignment = Alignment(horizontal="center", vertical="center") - ws[cell].font = Font(bold=True) - - - ws.column_dimensions[get_column_letter(1)].width = 12 - ws.column_dimensions[get_column_letter(2)].width = 18 - col_idx = 3 - for course in courses: - - ws.merge_cells(start_row=1, start_column=col_idx, end_row=1, end_column=col_idx + 1) - ws.merge_cells(start_row=2, start_column=col_idx, end_row=2, end_column=col_idx + 1) - ws.merge_cells(start_row=3, start_column=col_idx, end_row=3, end_column=col_idx + 1) - - ws.cell(row=1, column=col_idx).value = course.code - ws.cell(row=1, column=col_idx).alignment = Alignment(horizontal="center", vertical="center") - ws.cell(row=1, column=col_idx).font = Font(bold=True) - ws.cell(row=2, column=col_idx).value = course.name - ws.cell(row=2, column=col_idx).alignment = Alignment(horizontal="center", vertical="center") - ws.cell(row=2, column=col_idx).font = Font(bold=True) - - ws.cell(row=3, column=col_idx).value=course.credit - ws.cell(row=3, column=col_idx).alignment = Alignment(horizontal="center", vertical="center") - ws.cell(row=3, column=col_idx).font = Font(bold=True) - ws.cell(row=4, column=col_idx).value = "Grade" - ws.cell(row=4, column=col_idx + 1).value = "Remarks" - ws.cell(row=4, column=col_idx).alignment = Alignment(horizontal="center", vertical="center") - ws.cell(row=4, column=col_idx+1).alignment = Alignment(horizontal="center", vertical="center") - ws.column_dimensions[get_column_letter(col_idx)].width = 25 - ws.column_dimensions[get_column_letter(col_idx+1)].width = 25 - col_idx += 2 - - # ws.merge_cells(start_row=1, start_column=col_idx, end_row=4, end_column=col_idx) # SPI - ws.cell(row=1, column=col_idx).value = "SPI" - ws.cell(row=1, column=col_idx).alignment = Alignment(horizontal="center", vertical="center") - ws.cell(row=1, column=col_idx).font = Font(bold=True) - - # ws.merge_cells(start_row=1, start_column=col_idx + 1, end_row=4, end_column=col_idx + 1) # CPI - ws.cell(row=1, column=col_idx + 1).value = "CPI" - ws.cell(row=1, column=col_idx + 1).alignment = Alignment(horizontal="center", vertical="center") - ws.cell(row=1, column=col_idx + 1).font = Font(bold=True) - - - row_idx = 5 - for idx, student in enumerate(students, start=1): - ws.cell(row=row_idx, column=1).value = idx - ws.cell(row=row_idx, column=2).value = student.id_id - - ws.cell(row=row_idx, column=1).alignment = Alignment(horizontal="center", vertical="center") - ws.cell(row=row_idx, column=2).alignment = Alignment(horizontal="center", vertical="center") - student_grades = Student_grades.objects.filter( - roll_no=student.id_id, course_id_id__in=course_ids, semester=semester - ) - - grades_map = {} - for grade in student_grades: - grades_map[grade.course_id_id] = (grade.grade, grade.remarks,courses_map.get(grade.course_id_id) ) - - col_idx = 3 - gained_credit=0 - total_credit=0 - for course in courses: - grade, remark, credits = grades_map.get(course.id, ("N/A", "N/A",0)) - ws.cell(row=row_idx, column=col_idx).value = grade - ws.cell(row=row_idx, column=col_idx + 1).value = remark - ws.cell(row=row_idx, column=col_idx).alignment = Alignment(horizontal="center", vertical="center") - ws.cell(row=row_idx, column=col_idx+1).alignment = Alignment(horizontal="center", vertical="center") - if grade=="O" or grade=="A+": - gained_credit+=1*credits - total_credit+=credits - elif grade=="A": - gained_credit+=0.9*credits - total_credit+=credits - elif grade=="B+": - gained_credit+=0.8*credits - total_credit+=credits - elif grade=="B": - gained_credit+=0.7*credits - total_credit+=credits - elif grade=="C+": - gained_credit+=0.6*credits - total_credit+=credits - elif grade=="C": - gained_credit+=0.5*credits - total_credit+=credits - elif grade=="D+": - gained_credit+=0.4*credits - total_credit+=credits - elif grade=="D": - gained_credit+=0.3*credits - total_credit+=credits - elif grade=="F": - gained_credit+=0.2*credits - total_credit+=credits - - - col_idx += 2 - if total_credit==0 : - ws.cell(row=row_idx, column=col_idx).value =0 - else: - ws.cell(row=row_idx, column=col_idx).value = 10*(gained_credit/total_credit) - ws.cell(row=row_idx, column=col_idx + 1).value = 0 - ws.cell(row=row_idx, column=col_idx).alignment = Alignment(horizontal="center", vertical="center") - ws.cell(row=row_idx, column=col_idx+1).alignment = Alignment(horizontal="center", vertical="center") - - row_idx += 1 - - response = HttpResponse(content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') - response['Content-Disposition'] = 'attachment; filename="student_grades.xlsx"' - wb.save(response) - return response - - except json.JSONDecodeError: - return JsonResponse({'error': 'Invalid JSON data'}, status=400) - except Exception as e: - traceback.print_exc() - return JsonResponse({'error': str(e)}, status=500) - - - return JsonResponse({'error': 'Invalid request method'}, status=405) - -@login_required(login_url='/accounts/login') -def checkresult(request): - des = request.session.get("currentDesignationSelected") - if des == "student": - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - return render(request, "../templates/examination/check_result.html") - - -@login_required(login_url='/accounts/login') -def grades_report(request): - if request.method == 'POST': - des = request.session.get("currentDesignationSelected") - if des == "student": - pass - else: - if request.is_ajax(): # For AJAX or JSON requests - return JsonResponse({"success": False, "error": "Access denied."}, status=403) - else: # For non-AJAX requests - return HttpResponseRedirect('/dashboard/') - roll_number=request.user.username - semester=request.POST.get('semester') - grades_info=Student_grades.objects.filter(roll_no=roll_number,semester=semester).select_related('course_id') - gained_credit=0 - total_credit=0 - all_credits=0 - for grades in grades_info: - credits=grades.course_id.credit - grade=grades.grade - if grade=="O" or grade=="A+": - gained_credit+=1*credits - total_credit+=credits - elif grade=="A": - gained_credit+=0.9*credits - total_credit+=credits - elif grade=="B+": - gained_credit+=0.8*credits - total_credit+=credits - elif grade=="B": - gained_credit+=0.7*credits - total_credit+=credits - elif grade=="C+": - gained_credit+=0.6*credits - total_credit+=credits - elif grade=="C": - gained_credit+=0.5*credits - total_credit+=credits - elif grade=="D+": - gained_credit+=0.4*credits - total_credit+=credits - elif grade=="D": - gained_credit+=0.3*credits - total_credit+=credits - elif grade=="F": - gained_credit+=0.2*credits - total_credit+=credits - all_credits+=credits - spi = 10*(gained_credit/total_credit) if total_credit > 0 else 0 - all_grades = Student_grades.objects.filter(roll_no=roll_number) - total_units = sum(grade.course_id.credit for grade in all_grades) - student_user = request.user.first_name+' '+request.user.last_name - student = Student.objects.filter(id=roll_number).first() - # count=Student.objects.filter(id=roll.id).count() - # student=students.count() - - context = { - 'student': student, - 'student_user': student_user, - 'grades': grades_info, - 'spi': round(spi, 2), - 'semester_units': all_credits, - 'total_units': total_units, - } - return render(request, '../templates/examination/grades_report.html', context) - - diff --git a/FusionIIIT/applications/globals/access.py b/FusionIIIT/applications/globals/access.py new file mode 100644 index 000000000..f715fdce2 --- /dev/null +++ b/FusionIIIT/applications/globals/access.py @@ -0,0 +1,128 @@ + + + + + + + + + + + +"""Server-side role / designation checks. +Authorization must be derived from the authenticated user's real designation +""" + +from django.db.models import Q + + +def user_holds_role(user, role): + """True if ``user`` actually holds the designation named ``role``.""" + if not role or not getattr(user, "is_authenticated", False): + return False + from applications.globals.models import HoldsDesignation + + return HoldsDesignation.objects.filter( + Q(working=user) | Q(user=user), + designation__name=role, + ).exists() + + +def user_holds_any_role(user, roles): + """True if ``user`` holds any designation in ``roles``.""" + if not getattr(user, "is_authenticated", False): + return False + names = [r for r in roles if r] + if not names: + return False + from applications.globals.models import HoldsDesignation + + return HoldsDesignation.objects.filter( + Q(working=user) | Q(user=user), + designation__name__in=names, + ).exists() + + +# --- Reusable DRF permission classes (declarative, fail-closed) ------------- +# Prefer these on admin views: `permission_classes = [IsAcadAdminOrDean]` or +# `permission_classes = [has_any_role("acadadmin", "Dean Academic")]`. They +# authorize from the real designation and deny by default. +from rest_framework.permissions import BasePermission # noqa: E402 + + +class HasDesignation(BasePermission): + """Allow only users who hold one of ``allowed_roles`` (designation names).""" + + allowed_roles = () + message = "You do not have permission to perform this action." + + def has_permission(self, request, view): + return user_holds_any_role(request.user, self.allowed_roles) + + +def has_any_role(*roles): + """Factory: return a permission class allowing any of ``roles``.""" + + class _HasAnyRole(HasDesignation): + allowed_roles = roles + + return _HasAnyRole + + +class IsAcadAdmin(HasDesignation): + allowed_roles = ("acadadmin",) + + +class IsDeanAcademic(HasDesignation): + allowed_roles = ("Dean Academic",) + + +class IsAcadAdminOrDean(HasDesignation): + allowed_roles = ("acadadmin", "Dean Academic") + + +# --- Decorator for PLAIN Django views (non-DRF) ----------------------------- +# Plain function views (e.g. those using @require_http_methods) bypass DRF, so +# DRF auth/permission don't apply and the token header is ignored. This +# decorator authenticates the token itself and enforces the designation, so +# such views are no longer effectively open. Apply it as the OUTERMOST decorator. +from functools import wraps # noqa: E402 + +from django.http import JsonResponse # noqa: E402 + + +def _user_from_request(request): + """Resolve the user from a DRF-authenticated request or a Token header.""" + existing = getattr(request, "user", None) + if getattr(existing, "is_authenticated", False): + return existing + auth = request.META.get("HTTP_AUTHORIZATION", "") + if not auth.startswith("Token "): + return None + key = auth.split(" ", 1)[1].strip() + from rest_framework.authtoken.models import Token + + try: + return Token.objects.select_related("user").get(key=key).user + except Token.DoesNotExist: + return None + + +def require_designation(*roles): + """Gate a plain Django view to users holding one of ``roles`` (server-side).""" + + def decorator(view_func): + @wraps(view_func) + def _wrapped(request, *args, **kwargs): + user = _user_from_request(request) + if user is None or not user_holds_any_role(user, roles): + return JsonResponse( + {"error": "You do not have permission to perform this action."}, + status=403, + ) + request.user = user + return view_func(request, *args, **kwargs) + + return _wrapped + + return decorator diff --git a/FusionIIIT/applications/globals/api/views.py b/FusionIIIT/applications/globals/api/views.py index 7a38063cf..f1c02caf0 100644 --- a/FusionIIIT/applications/globals/api/views.py +++ b/FusionIIIT/applications/globals/api/views.py @@ -150,7 +150,22 @@ def profile(request, username=None): if profile['user_type'] == 'student': student = user.extrainfo.student - std_sem = student.curr_semester_no + std_sem = Student.objects.select_related('batch_id__curriculum__programme').get(id=student.id).curr_semester_no + + # Get programme_type from batch -> curriculum -> programme -> category + programme_type = None + try: + student_obj = Student.objects.select_related( + 'batch_id__curriculum__programme' + ).get(id=student.id) + + if (student_obj.batch_id and + student_obj.batch_id.curriculum and + student_obj.batch_id.curriculum.programme): + programme_type = student_obj.batch_id.curriculum.programme.category + except (Student.DoesNotExist, AttributeError): + programme_type = None + skills = list( Has.objects.filter(unique_id_id=student) .select_related("skill_id") @@ -171,6 +186,7 @@ def profile(request, username=None): resp = { 'profile' : profile, 'semester_no' : std_sem, + 'programme_type' : programme_type, 'skills' : formatted_skills, 'education' : education, 'course' : course, diff --git a/FusionIIIT/applications/globals/migrations/0007_moduleaccess_thesis_research.py b/FusionIIIT/applications/globals/migrations/0007_moduleaccess_thesis_research.py new file mode 100644 index 000000000..486684ec9 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0007_moduleaccess_thesis_research.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-07-07 19:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0006_auto_20260304_0836'), + ] + + operations = [ + migrations.AddField( + model_name='moduleaccess', + name='thesis_research', + field=models.BooleanField(default=False), + ), + ] diff --git a/FusionIIIT/applications/globals/models.py b/FusionIIIT/applications/globals/models.py index 618b917bc..6992c2761 100644 --- a/FusionIIIT/applications/globals/models.py +++ b/FusionIIIT/applications/globals/models.py @@ -319,6 +319,7 @@ class ModuleAccess(models.Model): designation = models.CharField(max_length=155) program_and_curriculum = models.BooleanField(default=False) course_registration = models.BooleanField(default=False) + thesis_research = models.BooleanField(default=False) course_management = models.BooleanField(default=False) other_academics = models.BooleanField(default=False) spacs = models.BooleanField(default=False) diff --git a/FusionIIIT/applications/programme_curriculum/admin.py b/FusionIIIT/applications/programme_curriculum/admin.py index 26be61196..b60d23b60 100644 --- a/FusionIIIT/applications/programme_curriculum/admin.py +++ b/FusionIIIT/applications/programme_curriculum/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.contrib.admin.options import ModelAdmin -from .models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot,CourseInstructor, NewProposalFile,Proposal_Tracking +from .models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot, CourseInstructor, NewProposalFile, Proposal_Tracking, Thesis +from .models_student_management import StudentBatchUpload, PhdStudentBatchUpload class ProgrammeAdmin(admin.ModelAdmin): @@ -22,6 +23,10 @@ class SemesterAdmin(admin.ModelAdmin): class CourseAdmin(admin.ModelAdmin): list_display = ('code', 'name', 'credit',) +class ThesisAdmin(admin.ModelAdmin): + list_display = ('code', 'name', 'discipline', 'programme_type', 'credit', 'working_thesis') + list_filter = ('discipline', 'programme_type', 'working_thesis') + class BatchAdmin(admin.ModelAdmin): list_display = ('name', 'discipline', 'year', 'curriculum',) list_filter = ('discipline', 'year', 'curriculum',) @@ -41,8 +46,28 @@ class ProposalTrackingAdmin(admin.ModelAdmin): admin.site.register(Curriculum, CurriculumAdmin) admin.site.register(Semester, SemesterAdmin) admin.site.register(Course, CourseAdmin) +admin.site.register(Thesis, ThesisAdmin) admin.site.register(Batch, BatchAdmin) admin.site.register(CourseSlot, CourseSlotAdmin) admin.site.register(CourseInstructor) -admin.site.register(NewProposalFile,NewProposalFileAdmin) -admin.site.register(Proposal_Tracking,ProposalTrackingAdmin) \ No newline at end of file +admin.site.register(NewProposalFile, NewProposalFileAdmin) +admin.site.register(Proposal_Tracking, ProposalTrackingAdmin) + + +@admin.register(PhdStudentBatchUpload) +class PhdStudentBatchUploadAdmin(admin.ModelAdmin): + list_display = ( + 'name', 'roll_number', 'application_no', 'discipline', + 'admission_semester', 'year', 'admission_type', + 'gate_qualified', 'gate_rank', 'reported_status', + ) + list_filter = ('discipline', 'year', 'admission_semester', 'reported_status', 'gate_qualified', 'category') + search_fields = ('name', 'roll_number', 'application_no', 'institute_email', 'discipline') + ordering = ['-year', 'admission_semester', 'roll_number'] + + +@admin.register(StudentBatchUpload) +class StudentBatchUploadAdmin(admin.ModelAdmin): + list_display = ('name', 'roll_number', 'programme_type', 'branch', 'year', 'reported_status') + list_filter = ('programme_type', 'year', 'reported_status', 'category') + search_fields = ('name', 'roll_number', 'jee_app_no', 'institute_email') \ No newline at end of file diff --git a/FusionIIIT/applications/programme_curriculum/api/serializers.py b/FusionIIIT/applications/programme_curriculum/api/serializers.py index 3572462df..3b94c1642 100644 --- a/FusionIIIT/applications/programme_curriculum/api/serializers.py +++ b/FusionIIIT/applications/programme_curriculum/api/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from applications.programme_curriculum.models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot, CourseInstructor +from applications.programme_curriculum.models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot, CourseInstructor, Thesis, ProgressSeminar from applications.programme_curriculum.models_student_management import StudentBatchUpload # this is for Programme model .... @@ -101,6 +101,23 @@ class Meta: fields = "__all__" +class ThesisSerializer(serializers.ModelSerializer): + discipline_name = serializers.CharField(source='discipline.name', read_only=True) + programme_type_display = serializers.CharField(source='get_programme_type_display', read_only=True) + + class Meta: + model = Thesis + fields = "__all__" + + +class ProgressSeminarSerializer(serializers.ModelSerializer): + discipline_name = serializers.CharField(source='discipline.name', read_only=True) + programme_type_display = serializers.CharField(source='get_programme_type_display', read_only=True) + + class Meta: + model = ProgressSeminar + fields = "__all__" + # this is for Batch model ... # field in frontend form --> name, discipline, year, curriculum . diff --git a/FusionIIIT/applications/programme_curriculum/api/urls.py b/FusionIIIT/applications/programme_curriculum/api/urls.py index 489107a5a..65560f33d 100644 --- a/FusionIIIT/applications/programme_curriculum/api/urls.py +++ b/FusionIIIT/applications/programme_curriculum/api/urls.py @@ -86,6 +86,37 @@ path('admin_update_course_instructor//', views.update_course_instructor_form, name='update_course_instructor_form'), path('admin_delete_course_instructor//', views.admin_delete_course_instructor, name='admin_delete_course_instructor'), + # Thesis APIs + path('admin_theses/', views.admin_view_all_theses, name='admin_view_all_theses'), + path('admin_thesis//', views.admin_view_a_thesis, name='admin_view_a_thesis'), + path('admin_add_thesis/', views.add_thesis, name='add_thesis'), + path('admin_delete_thesis//', views.admin_delete_thesis, name='admin_delete_thesis'), + path('admin_update_thesis//', views.update_thesis, name='update_thesis'), + + # Progress Seminar APIs + path('admin_progress_seminars/', views.admin_view_all_progress_seminars, name='admin_view_all_progress_seminars'), + path('admin_add_progress_seminar/', views.add_progress_seminar, name='add_progress_seminar'), + path('admin_delete_progress_seminar//', views.admin_delete_progress_seminar, name='admin_delete_progress_seminar'), + path('admin_update_progress_seminar//', views.update_progress_seminar, name='update_progress_seminar'), + + # Thesis Slot APIs + path('admin_add_thesis_slot/', views.add_thesis_slot, name='add_thesis_slot'), + + # Progress Seminar Slot APIs + path('admin_add_progress_seminar_slot/', views.add_progress_seminar_slot, name='add_progress_seminar_slot'), + + # Thesis Slot Detail / Delete APIs + path('admin_thesis_slot//', views.admin_view_a_thesis_slot, name='admin_view_a_thesis_slot'), + path('admin_delete_thesis_slot//', views.delete_thesis_slot, name='delete_thesis_slot'), + + # Progress Seminar Slot Detail / Delete APIs + path('admin_progress_seminar_slot//', views.admin_view_a_progress_seminar_slot, name='admin_view_a_progress_seminar_slot'), + path('admin_delete_progress_seminar_slot//', views.delete_progress_seminar_slot, name='delete_progress_seminar_slot'), + + # Edit Thesis Slot / Progress Seminar Slot + path('admin_edit_thesis_slot//', views.edit_thesis_slot_form, name='edit_thesis_slot_form'), + path('admin_edit_progress_seminar_slot//', views.edit_progress_seminar_slot_form, name='edit_progress_seminar_slot_form'), + # Delete APIs path('admin_delete_course//', views.admin_delete_course, name='admin_delete_course'), path('admin_delete_programme//', views.admin_delete_programme, name='admin_delete_programme'), diff --git a/FusionIIIT/applications/programme_curriculum/api/views.py b/FusionIIIT/applications/programme_curriculum/api/views.py index dec3ea7ce..24ce0beab 100644 --- a/FusionIIIT/applications/programme_curriculum/api/views.py +++ b/FusionIIIT/applications/programme_curriculum/api/views.py @@ -8,11 +8,11 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User -from ..models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot,NewProposalFile,Proposal_Tracking,CourseInstructor,CourseAuditLog -from ..forms import ProgrammeForm, DisciplineForm, CurriculumForm, SemesterForm, CourseForm, BatchForm, CourseSlotForm, ReplicateCurriculumForm,NewCourseProposalFile,CourseProposalTrackingFile, CourseInstructor, CourseInstructorForm +from ..models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot,NewProposalFile,Proposal_Tracking,CourseInstructor,CourseAuditLog, Thesis, ProgressSeminar, ThesisSlot, ProgressSeminarSlot +from ..forms import ProgrammeForm, DisciplineForm, CurriculumForm, SemesterForm, CourseForm, BatchForm, CourseSlotForm, ReplicateCurriculumForm,NewCourseProposalFile,CourseProposalTrackingFile, CourseInstructor, CourseInstructorForm, ThesisForm, ProgressSeminarForm, ThesisSlotForm, ProgressSeminarSlotForm from ..filters import CourseFilter, BatchFilter, CurriculumFilter -from .serializers import CourseSerializer,CurriculumSerializer,BatchSerializer +from .serializers import CourseSerializer,CurriculumSerializer,BatchSerializer, ThesisSerializer, ProgressSeminarSerializer from .views_student_management import get_batch_curriculum_display, get_available_curriculums_for_batch from django.core.serializers import serialize from django.db import IntegrityError, transaction @@ -33,6 +33,7 @@ from notification.views import prog_and_curr_notif # from applications.academic_information.models import Student from applications.globals.models import (DepartmentInfo, Designation,ExtraInfo, Faculty, HoldsDesignation) +from applications.globals.access import IsAcadAdminOrDean, require_designation, user_holds_role, _user_from_request # ------------module-functions---------------# @login_required(login_url='/accounts/login') @@ -467,6 +468,7 @@ def view_all_batches(request): # @api_view(['GET']) # @login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic", "student", "Professor", "Associate Professor", "Assistant Professor") def admin_view_all_programmes(request): """ API to return all programmes (UG, PG, PhD) for an admin user. @@ -493,6 +495,7 @@ def admin_view_all_programmes(request): return JsonResponse(response_data, status=200, safe=False) @api_view(['GET']) +@permission_classes([IsAuthenticated]) def admin_view_curriculums_of_a_programme(request, programme_id): program = get_object_or_404(Programme, id=programme_id) curriculums = program.curriculums.all() @@ -514,6 +517,7 @@ def admin_view_curriculums_of_a_programme(request, programme_id): return JsonResponse(data) @api_view(['GET']) +@permission_classes([IsAuthenticated]) def Admin_view_all_working_curriculums(request): """API view to return all working curriculums offered by the institute as JSON""" @@ -569,6 +573,7 @@ def Admin_view_all_working_curriculums(request): # Return the data as JSON response return JsonResponse({'curriculums': curriculum_data}, safe=False) +@require_designation("acadadmin", "Dean Academic", "student", "Professor", "Associate Professor", "Assistant Professor") def admin_view_semesters_of_a_curriculum(request, curriculum_id): """API endpoint to get all semesters of a specific curriculum for React frontend.""" @@ -593,9 +598,32 @@ def admin_view_semesters_of_a_curriculum(request, curriculum_id): 'id': slot.id, 'type': slot.type, 'name': slot.name, + 'slot_type': 'course', 'courses': courses }) + # Thesis slots + for ts in ThesisSlot.objects.filter(semester=semester).order_by('id'): + theses = list(ts.theses.values('id', 'name', 'code', 'credit')) + slots.append({ + 'id': ts.id, + 'type': 'Thesis', + 'name': ts.name, + 'slot_type': 'thesis', + 'courses': theses # reuse 'courses' key for table rendering compatibility + }) + + # Progress seminar slots + for pss in ProgressSeminarSlot.objects.filter(semester=semester).order_by('id'): + progress_seminars = list(pss.progress_seminars.values('id', 'name', 'code', 'credit')) + slots.append({ + 'id': pss.id, + 'type': 'Progress Seminar', + 'name': pss.name, + 'slot_type': 'progress_seminar', + 'courses': progress_seminars # reuse 'courses' key for table rendering compatibility + }) + # Calculate total credits for the semester based on maximum credit of each course slot credits_sum = sum(max(course['credit'] for course in slot['courses']) if slot['courses'] else 0 for slot in slots) @@ -640,6 +668,7 @@ def admin_view_semesters_of_a_curriculum(request, curriculum_id): return JsonResponse(curriculum_data) +@require_designation("acadadmin", "Dean Academic", "student", "Professor", "Associate Professor", "Assistant Professor") def admin_view_a_semester_of_a_curriculum(request, semester_id): # user_details = ExtraInfo.objects.get(user=request.user) # des = HoldsDesignation.objects.filter(user=request.user).first() @@ -690,6 +719,7 @@ def admin_view_a_semester_of_a_curriculum(request, semester_id): return JsonResponse(semester_data, safe=False) +@require_designation("acadadmin", "Dean Academic", "student", "Professor", "Associate Professor", "Assistant Professor") def admin_view_a_courseslot(request, courseslot_id): """API to view a course slot""" @@ -758,6 +788,7 @@ def admin_view_a_courseslot(request, courseslot_id): }) @api_view(['GET']) +@permission_classes([IsAuthenticated]) def admin_view_all_courses(request): """Returns all courses with required fields as JSON data.""" @@ -791,6 +822,7 @@ def admin_view_all_courses(request): return JsonResponse({'courses': courses_data}) @api_view(['GET']) +@permission_classes([IsAuthenticated]) def admin_view_a_course(request, course_id): """View to handle the details of a Course as an API""" @@ -835,6 +867,7 @@ def admin_view_a_course(request, course_id): # disciplines = Discipline.objects.all() # return render(request, 'programme_curriculum/acad_admin/admin_view_all_disciplines.html', {'disciplines': disciplines}) @api_view(['GET']) +@permission_classes([IsAuthenticated]) def admin_view_all_discplines(request): """API to view all disciplines with related programmes""" @@ -872,6 +905,7 @@ def admin_view_all_discplines(request): @api_view(['GET']) +@permission_classes([IsAcadAdminOrDean]) def admin_view_all_batches(request): """ views the details of a Course """ @@ -987,7 +1021,7 @@ def admin_view_all_batches(request): @csrf_exempt @api_view(['POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def add_discipline_form(request): if request.method == 'POST': try: @@ -1007,6 +1041,7 @@ def add_discipline_form(request): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def edit_discipline_form(request, discipline_id): # user_details = ExtraInfo.objects.get(user = request.user) @@ -1085,6 +1120,7 @@ def edit_discipline_form(request, discipline_id): # @permission_classes([IsAuthenticated]) # @api_view(['POST']) @csrf_exempt +@require_designation("acadadmin", "Dean Academic") def add_programme_form(request): if request.method == 'POST': data = json.loads(request.body) @@ -1114,6 +1150,7 @@ def add_programme_form(request): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def edit_programme_form(request, programme_id): # user_details = ExtraInfo.objects.get(user = request.user) @@ -1166,7 +1203,7 @@ def edit_programme_form(request, programme_id): }, status=405) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) @api_view(['POST']) def add_curriculum_form(request): """ @@ -1236,7 +1273,7 @@ def add_curriculum_form(request): return JsonResponse({'error': 'Invalid request method.'}, status=405) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) @api_view(['GET', 'PUT']) def edit_curriculum_form(request, curriculum_id): """ @@ -1330,6 +1367,7 @@ def edit_curriculum_form(request, curriculum_id): return JsonResponse({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) return JsonResponse({'error': 'Invalid request method.'}, status=status.HTTP_405_METHOD_NOT_ALLOWED) @csrf_exempt +@require_designation("acadadmin", "Dean Academic") def add_course_form(request): # user_details = ExtraInfo.objects.get(user = request.user) @@ -1349,57 +1387,58 @@ def add_course_form(request): if form.is_valid(): try: - new_course = form.save(commit=False) - new_course.save() - - # Handle many-to-many relationships if any - if 'disciplines' in data: - new_course.disciplines.set(data['disciplines']) - - if 'pre_requisit_courses' in data and data['pre_requisit_courses']: - new_course.pre_requisit_courses.set(data['pre_requisit_courses']) - - # Create initial audit log for course creation (only for authenticated users) - if request.user.is_authenticated and not request.user.is_anonymous: - initial_data = { - 'name': new_course.name, - 'code': new_course.code, - 'credit': new_course.credit, - 'version': new_course.version, - 'lecture_hours': new_course.lecture_hours, - 'tutorial_hours': new_course.tutorial_hours, - 'pratical_hours': new_course.pratical_hours, - 'discussion_hours': new_course.discussion_hours, - 'project_hours': new_course.project_hours, - 'pre_requisits': new_course.pre_requisits, - 'syllabus': new_course.syllabus, - 'ref_books': new_course.ref_books, - 'percent_quiz_1': new_course.percent_quiz_1, - 'percent_midsem': new_course.percent_midsem, - 'percent_quiz_2': new_course.percent_quiz_2, - 'percent_endsem': new_course.percent_endsem, - 'percent_project': new_course.percent_project, - 'percent_lab_evaluation': new_course.percent_lab_evaluation, - 'percent_course_attendance': new_course.percent_course_attendance, - 'max_seats': new_course.max_seats, - 'working_course': new_course.working_course, - } - - create_course_audit_log( - course=new_course, - user=request.user, - action='CREATE', - old_data=None, - new_data=initial_data, - version_bump_type='MAJOR', # New course creation is always major - old_version=None, - new_version=new_course.version, - admin_override=False, - reason="New course created" - ) - + with transaction.atomic(): + new_course = form.save(commit=False) + new_course.save() + + # Handle many-to-many relationships if any + if 'disciplines' in data: + new_course.disciplines.set(data['disciplines']) + + if 'pre_requisit_courses' in data and data['pre_requisit_courses']: + new_course.pre_requisit_courses.set(data['pre_requisit_courses']) + + # Create initial audit log for course creation (only for authenticated users) + if request.user.is_authenticated and not request.user.is_anonymous: + initial_data = { + 'name': new_course.name, + 'code': new_course.code, + 'credit': new_course.credit, + 'version': new_course.version, + 'lecture_hours': new_course.lecture_hours, + 'tutorial_hours': new_course.tutorial_hours, + 'pratical_hours': new_course.pratical_hours, + 'discussion_hours': new_course.discussion_hours, + 'project_hours': new_course.project_hours, + 'pre_requisits': new_course.pre_requisits, + 'syllabus': new_course.syllabus, + 'ref_books': new_course.ref_books, + 'percent_quiz_1': new_course.percent_quiz_1, + 'percent_midsem': new_course.percent_midsem, + 'percent_quiz_2': new_course.percent_quiz_2, + 'percent_endsem': new_course.percent_endsem, + 'percent_project': new_course.percent_project, + 'percent_lab_evaluation': new_course.percent_lab_evaluation, + 'percent_course_attendance': new_course.percent_course_attendance, + 'max_seats': new_course.max_seats, + 'working_course': new_course.working_course, + } + + create_course_audit_log( + course=new_course, + user=request.user, + action='CREATE', + old_data=None, + new_data=initial_data, + version_bump_type='MAJOR', # New course creation is always major + old_version=None, + new_version=new_course.version, + admin_override=False, + reason="New course created" + ) + return JsonResponse({'success': True, 'message': 'Course added successfully', 'course_id': new_course.id}, status=201) - + except Exception as save_error: return JsonResponse({'success': False, 'message': f'Error saving course: {str(save_error)}'}, status=500) else: @@ -1422,7 +1461,7 @@ def add_course_form(request): @csrf_exempt @api_view(['GET', 'PUT']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def update_course_form(request, course_id): """ Handle getting and updating Course through an API endpoint. @@ -1614,7 +1653,7 @@ def update_course_form(request, course_id): @csrf_exempt @api_view(['GET']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def course_audit_logs(request, course_id): """ Get audit logs for a specific course @@ -1662,7 +1701,7 @@ def course_audit_logs(request, course_id): ) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) @api_view(['POST']) def add_courseslot_form(request): try: @@ -1708,6 +1747,7 @@ def add_courseslot_form(request): @csrf_exempt # Use this decorator if you're not using CSRF tokens in your API calls @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def edit_courseslot_form(request, courseslot_id): courseslot = get_object_or_404(CourseSlot, Q(id=courseslot_id)) @@ -1749,7 +1789,7 @@ def edit_courseslot_form(request, courseslot_id): @csrf_exempt @api_view(['DELETE']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def delete_courseslot(request, courseslot_id): try: # Check if the user has the required session key @@ -1786,7 +1826,7 @@ def delete_courseslot(request, courseslot_id): return JsonResponse({'error': 'Internal server error'}, status=500) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) @api_view(['POST']) @csrf_exempt # Use this decorator if CSRF is not handled elsewhere def add_batch_form(request): @@ -1818,6 +1858,7 @@ def add_batch_form(request): @csrf_exempt # Use this decorator if you're not using CSRF tokens in your API calls @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def edit_batch_form(request, batch_id): # user_details = ExtraInfo.objects.get(user = request.user) @@ -1947,6 +1988,7 @@ def edit_batch_form(request, batch_id): return JsonResponse({'status': 'error', 'message': str(e)}, status=500) return JsonResponse({'error': 'Invalid request method'}, status=405) +@require_designation("acadadmin", "Dean Academic") def instigate_semester(request, semester_id): """ This function is used to add the semester information. @@ -1990,6 +2032,7 @@ def instigate_semester(request, semester_id): @csrf_exempt # Use this decorator if you're not using CSRF tokens in your API calls @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def replicate_curriculum(request, curriculum_id): """ This function is used to replicate the previous curriculum into a new curriculum. @@ -2473,6 +2516,7 @@ def outward_files(request): 'message': str(e) }, status=500) +@require_designation("Professor", "Associate Professor", "Assistant Professor") @csrf_exempt def update_course_proposal_file(request, course_id): # Fetch user designation (will break if request.user is Anonymous) @@ -2685,8 +2729,13 @@ def update_course_proposal_file(request, course_id): # return render(request,'programme_curriculum/faculty/forward.html',{'form':form,'receive_date':file.receive_date,'proposal':file2,'submitbutton': submitbutton,'id':Proposal_D}) @csrf_exempt -@permission_classes([IsAuthenticated]) def forward_course_forms(request, ProposalId): + # This is a plain Django view, so DRF auth/permissions do not apply. Resolve + # the user from the token ourselves and refuse to trust the client-supplied + # username/designation: a caller may only act as a designation they hold. + auth_user = _user_from_request(request) + if auth_user is None: + return JsonResponse({'status': 'error', 'message': 'Authentication required.'}, status=401) try: # Parse JSON data from request body data = json.loads(request.body) @@ -2697,7 +2746,15 @@ def forward_course_forms(request, ProposalId): 'status': 'error', 'message': 'Username and designation are required' }, status=400) - + + # Authorize against the user's REAL held designation, not the client-sent + # param, so a non-privileged user cannot forward as Dean/HOD. + if not user_holds_role(auth_user, designation): + return JsonResponse({ + 'status': 'error', + 'message': 'You do not hold the designation you are forwarding as.' + }, status=403) + # Get the tracking record file = get_object_or_404(Proposal_Tracking, id=ProposalId) file_id = int(file.file_id) @@ -3268,6 +3325,7 @@ def file_unarchive(request,FileId): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def course_slot_type_choices(request): """ API endpoint to return the list of course slot type choices from the CourseSlot model. @@ -3277,6 +3335,7 @@ def course_slot_type_choices(request): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def semester_details(request): curriculum_id = request.GET.get('curriculum_id') @@ -3309,7 +3368,7 @@ def semester_details(request): @api_view(['GET']) @csrf_exempt -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def get_programme(request, programme_id): program = get_object_or_404(Programme, id=programme_id) # curriculums = program.curriculums.all() @@ -3332,12 +3391,14 @@ def get_programme(request, programme_id): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def get_batch_names(request): choices = [{'value': key, 'label': label} for key, label in Batch._meta.get_field('name').choices] return JsonResponse({'choices': choices}) @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def get_all_disciplines(request): # Fetch all disciplines from the database disciplines = Discipline.objects.all() @@ -3357,6 +3418,7 @@ def get_all_disciplines(request): return JsonResponse(disciplines_data, safe=False) @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def get_unused_curriculam(request): used_curriculum_ids = Batch.objects.exclude(curriculum__isnull=True).values_list('curriculum_id', flat=True) unused_curricula = Curriculum.objects.exclude(id__in=used_curriculum_ids) @@ -3376,6 +3438,7 @@ def get_unused_curriculam(request): return JsonResponse(unused_curricula_data, safe=False) @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def admin_view_all_course_instructor(request): # Fetch all records from the CourseInstructor table course_instructors = CourseInstructor.objects.select_related( @@ -3406,6 +3469,7 @@ def admin_view_all_course_instructor(request): return JsonResponse({'course_instructors': course_instructors_data}) @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def admin_view_all_faculties(request): # Fetch all faculties with their user details faculties = Faculty.objects.select_related('id__user').annotate( @@ -3445,6 +3509,7 @@ def parse_academic_year(academic_year, semester_type): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def add_course_instructor(request): if request.method != "POST": return JsonResponse({"error": "Invalid request method"}, status=405) @@ -3544,6 +3609,7 @@ def add_course_instructor(request): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def update_course_instructor_form(request, instructor_id): # Retrieve the CourseInstructor object or return 404 if not found course_instructor = get_object_or_404(CourseInstructor, id=instructor_id) @@ -3661,7 +3727,7 @@ def get_superior_data(request): @csrf_exempt @api_view(['DELETE']) @authentication_classes([TokenAuthentication]) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def admin_delete_course_instructor(request, instructor_id): """ Delete a course instructor assignment @@ -3733,7 +3799,7 @@ def admin_delete_course_instructor(request, instructor_id): @csrf_exempt @api_view(['DELETE']) @authentication_classes([TokenAuthentication]) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def admin_delete_course(request, course_id): """ Delete a course @@ -3809,7 +3875,7 @@ def admin_delete_course(request, course_id): @csrf_exempt @api_view(['DELETE']) @authentication_classes([TokenAuthentication]) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def admin_delete_programme(request, programme_id): """ Delete a programme @@ -3878,7 +3944,7 @@ def admin_delete_programme(request, programme_id): @csrf_exempt @api_view(['DELETE']) @authentication_classes([TokenAuthentication]) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def admin_delete_curriculum(request, curriculum_id): """ Delete a curriculum @@ -3954,7 +4020,7 @@ def admin_delete_curriculum(request, curriculum_id): @csrf_exempt @api_view(['DELETE']) @authentication_classes([TokenAuthentication]) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def admin_delete_discipline(request, discipline_id): """ Delete a discipline @@ -4025,6 +4091,7 @@ def admin_delete_discipline(request, discipline_id): }, status=500) +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["DELETE", "POST"]) def delete_batch(request, batch_id): @@ -4048,11 +4115,11 @@ def delete_batch(request, batch_id): # 🔒 STUDENT VALIDATION: Check if batch has any students try: - # Step 1: Check StudentBatchUpload table - FIRST filter by year, THEN by discipline - uploaded_students_this_year = StudentBatchUpload.objects.filter( - year=batch.year # FIRST: Only students from this academic year (e.g., 2025) - ).filter( - branch__icontains=batch.discipline.name # THEN: Only this discipline within that year + # Step 1: Check StudentBatchUpload table - Filter by year, discipline AND programme_type + uploaded_students_this_batch = StudentBatchUpload.objects.filter( + year=batch.year, # Academic year (e.g., 2025) + branch__icontains=batch.discipline.name, # Discipline (e.g., CSE, ECE) + programme_type__iexact=batch.name # Programme type (B.Tech, M.Tech, PhD, etc.) ).count() # Step 2: Check academic_information.Student table @@ -4065,14 +4132,16 @@ def delete_batch(request, batch_id): except ImportError: academic_students_this_batch = 0 - # Total = students in this year's uploads + students assigned to this specific batch - total_students = uploaded_students_this_year + academic_students_this_batch + # Total = students in this specific batch (year + discipline + programme_type) + students assigned to this batch ID + total_students = uploaded_students_this_batch + academic_students_this_batch if total_students > 0: return JsonResponse({ 'success': False, - 'message': f'Cannot delete batch "{batch.name} {batch.discipline.acronym} {batch.year}". It contains {total_students} students. Please transfer or remove students first.', + 'message': f'Cannot delete batch "{batch.name} {batch.discipline.acronym} {batch.year}". It contains {total_students} students ({uploaded_students_this_batch} uploaded, {academic_students_this_batch} assigned). Please transfer or remove students first.', 'student_count': total_students, + 'uploaded_students': uploaded_students_this_batch, + 'assigned_students': academic_students_this_batch, 'validation_error': 'batch_has_students', 'batch_info': { 'id': batch.id, @@ -4116,6 +4185,7 @@ def delete_batch(request, batch_id): }, status=500) +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["DELETE", "POST"]) def delete_batch_invalid(request, batch_id): @@ -4318,4 +4388,516 @@ def create_course_audit_log(course, user, action, old_data=None, new_data=None, reason=reason ) - return audit_log \ No newline at end of file + return audit_log + + +# ============== THESIS API VIEWS ============== + +@api_view(['GET']) +def admin_view_all_theses(request): + """Returns all theses with required fields as JSON data.""" + + theses = Thesis.objects.all() + + # Prepare data for JSON response + theses_data = [ + { + "id": thesis.id, + "code": thesis.code, + "name": thesis.name, + "discipline": thesis.discipline.name, + "discipline_acronym": thesis.discipline.acronym, + "programme_type": thesis.programme_type, + "programme_type_display": thesis.get_programme_type_display(), + "credits": thesis.credit, + "working_thesis": thesis.working_thesis + } + for thesis in theses + ] + + return JsonResponse({'theses': theses_data}) + + +@api_view(['GET']) +def admin_view_a_thesis(request, thesis_id): + """View to handle the details of a Thesis as an API""" + + # Fetch the thesis based on the thesis_id + thesis = get_object_or_404(Thesis, Q(id=thesis_id)) + thesis_serializer = ThesisSerializer(thesis) + + return Response(thesis_serializer.data) + +@api_view(['POST']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def add_thesis(request): + """Add a new thesis""" + + try: + data = request.data + + # Validate required fields + required_fields = ['code', 'name', 'credit', 'discipline', 'programme_type'] + for field in required_fields: + if field not in data: + return JsonResponse({'error': f'{field} is required'}, status=400) + + # Get discipline + discipline = get_object_or_404(Discipline, id=data['discipline']) + + # Create thesis + thesis = Thesis.objects.create( + code=data['code'], + name=data['name'], + credit=data['credit'], + discipline=discipline, + programme_type=data['programme_type'], + working_thesis=data.get('working_thesis', True) + ) + + return JsonResponse({ + 'success': True, + 'message': 'Thesis added successfully', + 'thesis_id': thesis.id + }, status=201) + + except IntegrityError: + return JsonResponse({ + 'error': 'A thesis with this code already exists for this discipline' + }, status=400) + except Exception as e: + return JsonResponse({ + 'error': str(e) + }, status=500) + + +@api_view(['DELETE']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def admin_delete_thesis(request, thesis_id): + """Delete a thesis""" + + try: + thesis = get_object_or_404(Thesis, id=thesis_id) + thesis_code = thesis.code + thesis_name = thesis.name + + thesis.delete() + + return JsonResponse({ + 'success': True, + 'message': f'Thesis {thesis_code} - {thesis_name} deleted successfully' + }, status=200) + + except Exception as e: + return JsonResponse({ + 'error': str(e) + }, status=500) + + +# ------------ Progress Seminar Views ---------------# + +@api_view(['GET']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def admin_view_all_progress_seminars(request): + """Returns all progress seminars with required fields as JSON data.""" + + progress_seminars = ProgressSeminar.objects.all() + + progress_seminars_data = [ + { + "id": ps.id, + "code": ps.code, + "name": ps.name, + "discipline": ps.discipline.name, + "discipline_acronym": ps.discipline.acronym, + "programme_type": ps.programme_type, + "programme_type_display": ps.get_programme_type_display(), + "credits": ps.credit, + "working_progress_seminar": ps.working_progress_seminar + } + for ps in progress_seminars + ] + + return JsonResponse({'progress_seminars': progress_seminars_data}) + + +@api_view(['POST']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def add_progress_seminar(request): + """Add a new progress seminar""" + + try: + data = request.data + + required_fields = ['code', 'name', 'credit', 'discipline', 'programme_type'] + for field in required_fields: + if field not in data: + return JsonResponse({'error': f'{field} is required'}, status=400) + + discipline = get_object_or_404(Discipline, id=data['discipline']) + + progress_seminar = ProgressSeminar.objects.create( + code=data['code'], + name=data['name'], + credit=data['credit'], + discipline=discipline, + programme_type=data['programme_type'], + working_progress_seminar=data.get('working_progress_seminar', True) + ) + + return JsonResponse({ + 'success': True, + 'message': 'Progress Seminar added successfully', + 'progress_seminar_id': progress_seminar.id + }, status=201) + + except IntegrityError: + return JsonResponse({ + 'error': 'A progress seminar with this code already exists for this discipline' + }, status=400) + except Exception as e: + return JsonResponse({ + 'error': str(e) + }, status=500) + + +@api_view(['DELETE']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def admin_delete_progress_seminar(request, progress_seminar_id): + """Delete a progress seminar""" + + try: + ps = get_object_or_404(ProgressSeminar, id=progress_seminar_id) + ps_code = ps.code + ps_name = ps.name + + ps.delete() + + return JsonResponse({ + 'success': True, + 'message': f'Progress Seminar {ps_code} - {ps_name} deleted successfully' + }, status=200) + + except Exception as e: + return JsonResponse({ + 'error': str(e) + }, status=500) + + +@csrf_exempt +@api_view(['GET', 'PUT']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def update_thesis(request, thesis_id): + """Get thesis details for editing (GET) or update an existing thesis (PUT).""" + thesis = get_object_or_404(Thesis, id=thesis_id) + + if request.method == 'GET': + data = { + 'id': thesis.id, + 'code': thesis.code, + 'name': thesis.name, + 'credit': thesis.credit, + 'discipline': thesis.discipline.id, + 'discipline_name': thesis.discipline.name, + 'discipline_acronym': thesis.discipline.acronym, + 'programme_type': thesis.programme_type, + } + return Response(data, status=status.HTTP_200_OK) + + elif request.method == 'PUT': + try: + data = json.loads(request.body) + discipline = get_object_or_404(Discipline, id=data.get('discipline')) + + thesis.code = data.get('code', thesis.code) + thesis.name = data.get('name', thesis.name) + thesis.credit = data.get('credit', thesis.credit) + thesis.discipline = discipline + thesis.programme_type = data.get('programme_type', thesis.programme_type) + thesis.save() + + return JsonResponse({ + 'success': True, + 'message': f'Thesis {thesis.code} - {thesis.name} updated successfully', + 'thesis_id': thesis.id, + }, status=200) + + except IntegrityError: + return JsonResponse({ + 'error': 'A thesis with this code already exists for this discipline' + }, status=400) + except Exception as e: + return JsonResponse({ + 'error': str(e) + }, status=500) + + +@csrf_exempt +@api_view(['GET', 'PUT']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def update_progress_seminar(request, progress_seminar_id): + """Get progress seminar details for editing (GET) or update an existing progress seminar (PUT).""" + ps = get_object_or_404(ProgressSeminar, id=progress_seminar_id) + + if request.method == 'GET': + data = { + 'id': ps.id, + 'code': ps.code, + 'name': ps.name, + 'credit': ps.credit, + 'discipline': ps.discipline.id, + 'discipline_name': ps.discipline.name, + 'discipline_acronym': ps.discipline.acronym, + 'programme_type': ps.programme_type, + } + return Response(data, status=status.HTTP_200_OK) + + elif request.method == 'PUT': + try: + data = json.loads(request.body) + discipline = get_object_or_404(Discipline, id=data.get('discipline')) + + ps.code = data.get('code', ps.code) + ps.name = data.get('name', ps.name) + ps.credit = data.get('credit', ps.credit) + ps.discipline = discipline + ps.programme_type = data.get('programme_type', ps.programme_type) + ps.save() + + return JsonResponse({ + 'success': True, + 'message': f'Progress Seminar {ps.code} - {ps.name} updated successfully', + 'progress_seminar_id': ps.id, + }, status=200) + + except IntegrityError: + return JsonResponse({ + 'error': 'A progress seminar with this code already exists for this discipline' + }, status=400) + except Exception as e: + return JsonResponse({ + 'error': str(e) + }, status=500) + + +@csrf_exempt +@api_view(['POST']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def add_thesis_slot(request): + """Add a new thesis slot to a semester.""" + try: + data = json.loads(request.body) + + thesis_slot = ThesisSlot.objects.create( + semester_id=data['semester'], + name=data['name'], + thesis_slot_info=data.get('thesis_slot_info', ''), + duration=data.get('duration', 1), + min_registration_limit=data.get('min_registration_limit', 0), + max_registration_limit=data.get('max_registration_limit', 1000) + ) + + if 'theses' in data and data['theses']: + thesis_slot.theses.set(data['theses']) + + return JsonResponse({ + 'status': 'success', + 'message': 'Thesis slot created successfully', + 'id': thesis_slot.id + }) + + except Exception as e: + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }, status=400) + + +@csrf_exempt +@api_view(['POST']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def add_progress_seminar_slot(request): + """Add a new progress seminar slot to a semester.""" + try: + data = json.loads(request.body) + + ps_slot = ProgressSeminarSlot.objects.create( + semester_id=data['semester'], + name=data['name'], + progress_seminar_slot_info=data.get('progress_seminar_slot_info', ''), + duration=data.get('duration', 1), + min_registration_limit=data.get('min_registration_limit', 0), + max_registration_limit=data.get('max_registration_limit', 1000) + ) + + if 'progress_seminars' in data and data['progress_seminars']: + ps_slot.progress_seminars.set(data['progress_seminars']) + + return JsonResponse({ + 'status': 'success', + 'message': 'Progress seminar slot created successfully', + 'id': ps_slot.id + }) + + except Exception as e: + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }, status=400) + + +def admin_view_a_thesis_slot(request, thesis_slot_id): + """API to view a thesis slot""" + thesis_slot = get_object_or_404(ThesisSlot, id=thesis_slot_id) + + return JsonResponse({ + 'thesis_slot': { + 'id': thesis_slot.id, + 'name': thesis_slot.name, + 'thesis_slot_info': thesis_slot.thesis_slot_info, + 'duration': thesis_slot.duration, + 'min_registration_limit': thesis_slot.min_registration_limit, + 'max_registration_limit': thesis_slot.max_registration_limit, + 'theses': [ + { + 'id': t.id, + 'code': t.code, + 'name': t.name, + 'credit': t.credit, + } for t in thesis_slot.theses.all() + ], + 'curriculum': { + 'id': thesis_slot.semester.curriculum.id, + 'name': thesis_slot.semester.curriculum.name, + 'version': thesis_slot.semester.curriculum.version, + 'semester_no': thesis_slot.semester.semester_no, + } + }, + }) + + +def admin_view_a_progress_seminar_slot(request, ps_slot_id): + """API to view a progress seminar slot""" + ps_slot = get_object_or_404(ProgressSeminarSlot, id=ps_slot_id) + + return JsonResponse({ + 'progress_seminar_slot': { + 'id': ps_slot.id, + 'name': ps_slot.name, + 'progress_seminar_slot_info': ps_slot.progress_seminar_slot_info, + 'duration': ps_slot.duration, + 'min_registration_limit': ps_slot.min_registration_limit, + 'max_registration_limit': ps_slot.max_registration_limit, + 'progress_seminars': [ + { + 'id': ps.id, + 'code': ps.code, + 'name': ps.name, + 'credit': ps.credit, + } for ps in ps_slot.progress_seminars.all() + ], + 'curriculum': { + 'id': ps_slot.semester.curriculum.id, + 'name': ps_slot.semester.curriculum.name, + 'version': ps_slot.semester.curriculum.version, + 'semester_no': ps_slot.semester.semester_no, + } + }, + }) + + +def delete_thesis_slot(request, thesis_slot_id): + """Delete a thesis slot""" + thesis_slot = get_object_or_404(ThesisSlot, id=thesis_slot_id) + thesis_slot.delete() + return JsonResponse({'status': 'success', 'message': 'Thesis slot deleted successfully'}) + + +def delete_progress_seminar_slot(request, ps_slot_id): + """Delete a progress seminar slot""" + ps_slot = get_object_or_404(ProgressSeminarSlot, id=ps_slot_id) + ps_slot.delete() + return JsonResponse({'status': 'success', 'message': 'Progress seminar slot deleted successfully'}) + + +def edit_thesis_slot_form(request, thesis_slot_id): + """GET returns existing thesis slot data; PUT updates it.""" + thesis_slot = get_object_or_404(ThesisSlot, id=thesis_slot_id) + curriculum_id = thesis_slot.semester.curriculum.id + + if request.method == 'GET': + data = { + 'id': thesis_slot.id, + 'semester': thesis_slot.semester.id, + 'name': thesis_slot.name, + 'thesis_slot_info': thesis_slot.thesis_slot_info, + 'theses': [t.id for t in thesis_slot.theses.all()], + 'duration': thesis_slot.duration, + 'min_registration_limit': thesis_slot.min_registration_limit, + 'max_registration_limit': thesis_slot.max_registration_limit, + 'curriculum_id': curriculum_id, + } + return JsonResponse({'status': 'success', 'thesis_slot': data}) + + elif request.method == 'PUT': + try: + data = json.loads(request.body) + form = ThesisSlotForm(data, instance=thesis_slot) + if form.is_valid(): + form.save() + return JsonResponse({ + 'status': 'success', + 'message': 'Thesis slot updated successfully', + }) + else: + return JsonResponse({'status': 'error', 'errors': form.errors}, status=400) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=500) + + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}, status=405) + + +def edit_progress_seminar_slot_form(request, ps_slot_id): + """GET returns existing progress seminar slot data; PUT updates it.""" + ps_slot = get_object_or_404(ProgressSeminarSlot, id=ps_slot_id) + curriculum_id = ps_slot.semester.curriculum.id + + if request.method == 'GET': + data = { + 'id': ps_slot.id, + 'semester': ps_slot.semester.id, + 'name': ps_slot.name, + 'progress_seminar_slot_info': ps_slot.progress_seminar_slot_info, + 'progress_seminars': [ps.id for ps in ps_slot.progress_seminars.all()], + 'duration': ps_slot.duration, + 'min_registration_limit': ps_slot.min_registration_limit, + 'max_registration_limit': ps_slot.max_registration_limit, + 'curriculum_id': curriculum_id, + } + return JsonResponse({'status': 'success', 'progress_seminar_slot': data}) + + elif request.method == 'PUT': + try: + data = json.loads(request.body) + form = ProgressSeminarSlotForm(data, instance=ps_slot) + if form.is_valid(): + form.save() + return JsonResponse({ + 'status': 'success', + 'message': 'Progress seminar slot updated successfully', + }) + else: + return JsonResponse({'status': 'error', 'errors': form.errors}, status=400) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=500) + + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}, status=405) \ No newline at end of file diff --git a/FusionIIIT/applications/programme_curriculum/api/views_student_management.py b/FusionIIIT/applications/programme_curriculum/api/views_student_management.py index cd449fa6b..17159bc64 100644 --- a/FusionIIIT/applications/programme_curriculum/api/views_student_management.py +++ b/FusionIIIT/applications/programme_curriculum/api/views_student_management.py @@ -25,6 +25,7 @@ from applications.academic_information.models import Student as AcademicStudent from applications.globals.models import ExtraInfo, Designation, HoldsDesignation +from applications.globals.access import IsAcadAdminOrDean, require_designation from django.contrib.auth.models import User from applications.programme_curriculum.models import ( Programme, Curriculum, Batch, Discipline @@ -35,7 +36,7 @@ try: from applications.programme_curriculum.models_student_management import ( - StudentBatchUpload, BatchConfiguration, StudentStatusLog + StudentBatchUpload, BatchConfiguration, StudentStatusLog, PhdStudentBatchUpload ) except ImportError: from django.db import models @@ -169,6 +170,90 @@ def _safe_int_conversion(value): pass return None +def _safe_decimal_conversion(value): + """Convert a value to Decimal, returning None for empty/invalid values.""" + if value is None or value == '' or value == 'null': + return None + try: + from decimal import Decimal, InvalidOperation + clean = str(value).replace(',', '').strip() + if not clean or clean.lower() == 'nan': + return None + return Decimal(clean) + except Exception: + return None + +def normalize_category(value, is_phd=False): + """Normalize Excel/frontend category values to DB codes. + UG/PG model uses 'GEN-EWS' and 'OBC-NCL'; PhD model uses 'EWS' and 'OBC'. + """ + if not value: + return '' + v = str(value).strip() + # Base mapping (UG/PG codes) + mapping = { + 'general': 'GEN', + 'general ews': 'GEN-EWS', + 'general-ews': 'GEN-EWS', + 'gen-ews': 'GEN-EWS', + 'economically weaker section': 'GEN-EWS', + 'ews': 'GEN-EWS', + 'obc-ncl': 'OBC-NCL', + 'other backward class': 'OBC-NCL', + 'other backward class (non-creamy layer)': 'OBC-NCL', + 'obc': 'OBC-NCL', + 'sc': 'SC', + 'scheduled caste': 'SC', + 'st': 'ST', + 'scheduled tribe': 'ST', + 'gen': 'GEN', + } + normalized = mapping.get(v.lower(), v) + # PhD choices only have 'GEN', 'OBC', 'SC', 'ST', 'EWS' — re-map the compound codes + if is_phd: + phd_remap = { + 'GEN-EWS': 'EWS', + 'OBC-NCL': 'OBC', + } + normalized = phd_remap.get(normalized, normalized) + return normalized[:10] # safety truncation to respect max_length=10 + + +def normalize_gender(value): + """Normalize Excel gender values to model choices: 'Male', 'Female', 'Other'.""" + if not value: + return '' + v = str(value).strip().lower() + if v in ('male', 'm'): + return 'Male' + if v in ('female', 'f'): + return 'Female' + return 'Other' + + +def normalize_yes_no(value, default='NO'): + """Normalize Excel YES/NO values to DB choices 'YES' / 'NO'.""" + if not value: + return default + v = str(value).strip().lower() + if v in ('yes', 'y', '1', 'true'): + return 'YES' + if v in ('no', 'n', '0', 'false'): + return 'NO' + return str(value).strip() + + +def to_academic_category(value): + """Map any raw/batch-model category value to the narrower AcademicStudent choices. + AcademicStudent.category only accepts: GEN, SC, ST, OBC + Batch models may hold: GEN-EWS, OBC-NCL, EWS — these must be remapped. + """ + normalized = normalize_category(value or '') + remap = {'GEN-EWS': 'GEN', 'OBC-NCL': 'OBC', 'EWS': 'GEN'} + result = remap.get(normalized, normalized) + return result or 'GEN' + + def parse_date_flexible(date_value): if date_value is None or date_value == '': return None @@ -217,18 +302,33 @@ def get_academic_year_from_batch_year(batch_year): def calculate_batch_filled_seats(batch): """ - Calculate filled seats for a batch using curriculum-based counting only. + Calculate filled seats for a batch. + - PhD batches: count PhdStudentBatchUpload rows matching year + semester + discipline. + - UG/PG batches: count AcademicStudent records enrolled in this batch. """ try: - from applications.academic_information.models import Student - if batch.curriculum: - curriculum_count = Student.objects.filter( - batch_id=batch - ).count() - return curriculum_count + if 'PhD' in (batch.name or ''): + from applications.programme_curriculum.models_student_management import PhdStudentBatchUpload + qs = PhdStudentBatchUpload.objects.filter(year=batch.year) + if 'Odd' in batch.name: + qs = qs.filter(admission_semester__iexact='Odd') + elif 'Even' in batch.name: + qs = qs.filter(admission_semester__iexact='Even') + if batch.discipline: + # Try full name first; fall back to acronym only if name gives 0. + # Avoids cross-matches for batches sharing the same acronym (NS-). + name_qs = qs.filter(discipline__icontains=batch.discipline.name) + if name_qs.exists(): + qs = name_qs + else: + qs = qs.filter(discipline__icontains=batch.discipline.acronym) + return qs.count() else: - return 0 - + from applications.academic_information.models import Student + if batch.curriculum: + return Student.objects.filter(batch_id=batch).count() + else: + return 0 except Exception as e: return 0 @@ -325,28 +425,45 @@ def validate_batch_curriculum_requirements(batch_year, academic_year, action_con return None -def calculate_current_semester(academic_year, current_date=None): +def calculate_current_semester(academic_year, current_date=None, admission_semester=None): + """ + Calculate the current semester number for a student. + + Convention: batch year = academic year start (e.g. 2025 = academic year 2025-26). + + Odd-semester (UG/PG/PhD Odd) — first semester starts August of batch_year. + Even-semester (PhD Even) — first semester starts January of batch_year + 1. + + Examples with batch_year=2025: + Odd: Aug 2025→Sem1, Jan 2026→Sem2, Aug 2026→Sem3 ... + Even: Jan 2026→Sem1, Aug 2026→Sem2, Jan 2027→Sem3 ... + """ if current_date is None: current_date = timezone.now().date() - + current_year = current_date.year current_month = current_date.month - - years_completed = 0 - - if current_month >= 8: - years_completed = current_year - academic_year - semester_in_year = 1 + + is_even_phd = admission_semester and str(admission_semester).strip().lower() == 'even' + + if is_even_phd: + # Even PhD: effective start = January of (batch_year + 1) + eff_year = academic_year + 1 + if current_month >= 8: # Aug–Dec → even numbered semester for them + total_semester = (current_year - eff_year) * 2 + 2 + else: # Jan–Jul → odd numbered semester for them + total_semester = (current_year - eff_year) * 2 + 1 else: - years_completed = current_year - academic_year - 1 - if current_month <= 5: + # Odd-semester (UG/PG/PhD Odd): effective start = August of batch_year + if current_month >= 8: # Aug–Dec → Odd semester in progress + years_completed = current_year - academic_year + semester_in_year = 1 + else: # Jan–Jul → Even semester in progress + years_completed = current_year - academic_year - 1 semester_in_year = 2 - else: - semester_in_year = 2 - - total_semester = (years_completed * 2) + semester_in_year - total_semester = max(1, min(total_semester, 8)) - + total_semester = (years_completed * 2) + semester_in_year + + total_semester = max(1, min(total_semester, 12)) return total_semester @csrf_exempt @@ -368,6 +485,7 @@ def get_display_branch_name(discipline): @csrf_exempt @require_http_methods(["POST"]) +@require_designation("acadadmin", "Dean Academic") def process_excel_upload(request): try: if 'file' not in request.FILES: @@ -449,7 +567,14 @@ def process_excel_upload(request): 'admission_mode': ['admission mode', 'admission type'], 'admission_mode_remarks': ['admission mode remarks', 'admission type remarks'], 'income_group': ['income group', 'family income group'], - 'income': ['income', 'family income', 'annual income'] + 'income': ['income', 'family income', 'annual income'], + # PhD-specific columns + 'application_no': ['application no.', 'application no', 'application number', 'phd application no', 'phd app no'], + 'gate_qualified': ['gate qualaified', 'gate qualified', 'gate qualification'], + 'gate_stream': ['gate stream'], + 'gate_rank': ['gate rank'], + 'admission_type': ['admission type'], + 'admission_semester': ['admission semester', 'semester of admission'], } df.columns = df.columns.str.lower().str.strip() @@ -562,7 +687,15 @@ def process_excel_upload(request): 'Admission Mode': student_data.get('admission_mode', ''), 'Admission Mode Remarks': student_data.get('admission_mode_remarks', ''), 'Income Group': student_data.get('income_group', ''), - 'Income': student_data.get('income', '') + 'Income': student_data.get('income', ''), + # PhD-specific fields + 'Application No.': student_data.get('application_no', ''), + 'Admission Type': student_data.get('admission_type', ''), + 'GATE Qualaified': student_data.get('gate_qualified', ''), + 'GATE Qualified': student_data.get('gate_qualified', ''), + 'GATE Stream': student_data.get('gate_stream', ''), + 'GATE Rank': student_data.get('gate_rank', ''), + 'Admission Semester': student_data.get('admission_semester', ''), } valid_students.append(cleaned_data) @@ -593,9 +726,12 @@ def process_excel_upload(request): # STUDENT BATCH OPERATIONS # ============================================================================= -@csrf_exempt -@require_http_methods(["POST"]) -def check_student_duplicate(student, duplicate_check_fields): +def check_student_duplicate(student, duplicate_check_fields, programme_type='ug'): + """ + Check for duplicate students. + For PhD students, checks PhdStudentBatchUpload. + For UG/PG students, checks StudentBatchUpload. + """ field_mapping = { 'jeeAppNo': 'jee_app_no', @@ -606,6 +742,8 @@ def check_student_duplicate(student, duplicate_check_fields): 'mobile': 'mobile_number' } + is_phd = (programme_type == 'phd') + try: for field in duplicate_check_fields: backend_field = field_mapping.get(field, field.lower()) @@ -615,17 +753,24 @@ def check_student_duplicate(student, duplicate_check_fields): continue if backend_field == 'jee_app_no': - existing = StudentBatchUpload.objects.filter(jee_app_no=student_value).first() - if existing: - return True, f"JEE Application Number {student_value} already exists for {existing.name}" + if not is_phd: # PhD students don't have JEE app numbers + existing = StudentBatchUpload.objects.filter(jee_app_no=student_value).first() + if existing: + return True, f"JEE Application Number {student_value} already exists for {existing.name}" elif backend_field == 'roll_number': - existing = StudentBatchUpload.objects.filter(roll_number=student_value).first() + if is_phd: + existing = PhdStudentBatchUpload.objects.filter(roll_number=student_value).first() + else: + existing = StudentBatchUpload.objects.filter(roll_number=student_value).first() if existing: return True, f"Roll Number {student_value} already exists for {existing.name}" elif backend_field == 'institute_email': - existing = StudentBatchUpload.objects.filter(institute_email=student_value).first() + if is_phd: + existing = PhdStudentBatchUpload.objects.filter(institute_email=student_value).first() + else: + existing = StudentBatchUpload.objects.filter(institute_email=student_value).first() if existing: return True, f"Institute Email {student_value} already exists for {existing.name}" @@ -647,11 +792,13 @@ def check_student_duplicate(student, duplicate_check_fields): @csrf_exempt @require_http_methods(["POST"]) +@require_designation("acadadmin", "Dean Academic") def save_students_batch(request): try: data = json.loads(request.body) students = data.get('students', []) programme_type = data.get('programme_type', 'ug') + phd_semester = data.get('phd_semester', None) # Get PhD semester (odd/even) year_result = validate_and_normalize_year(data.get('academic_year')) if isinstance(year_result, JsonResponse): return year_result @@ -687,7 +834,7 @@ def save_students_batch(request): duplicate_students = [] for student in students: - is_duplicate, duplicate_info = check_student_duplicate(student, duplicate_check_fields) + is_duplicate, duplicate_info = check_student_duplicate(student, duplicate_check_fields, programme_type) if is_duplicate: skipped_duplicates += 1 @@ -732,8 +879,20 @@ def save_students_batch(request): discipline_name = student_data.get('Discipline') or student_data.get('branch', '') specialization = student_data.get('Specialization') or student_data.get('specialization', '') + # Debug PhD semester + if programme_type == 'phd': + print(f"DEBUG: programme_type={programme_type}, phd_semester={phd_semester}, type={type(phd_semester)}") + + # For PhD students, use semester-specific batch names + if programme_type == 'phd' and phd_semester: + if phd_semester.lower() == 'odd': + batch_name = 'PhD (Odd)' + elif phd_semester.lower() == 'even': + batch_name = 'PhD (Even)' + else: + batch_name = get_batch_name_from_discipline(discipline_name, programme_type) # For M.Tech students, use specialization-specific batch names - if programme_type == 'pg' and specialization: + elif programme_type == 'pg' and specialization: if 'design' in discipline_name.lower(): batch_name = 'M.Des' elif specialization == 'Mechatronics': @@ -759,6 +918,10 @@ def save_students_batch(request): else: discipline_obj = get_or_create_discipline(discipline_name) + # Debug logging for PhD batch matching + if programme_type == 'phd': + print(f"DEBUG PhD: batch_name={batch_name}, discipline={discipline_obj.name}, year={batch_year}") + try: batch_obj = Batch.objects.get( name=batch_name, @@ -766,7 +929,14 @@ def save_students_batch(request): year=batch_year, running_batch=True ) + if programme_type == 'phd': + print(f"DEBUG PhD: Found batch {batch_obj.id}") except Batch.DoesNotExist: + if programme_type == 'phd': + print(f"DEBUG PhD: Batch not found! Looking for alternatives...") + all_phd_batches = Batch.objects.filter(name__icontains='phd', year=batch_year, running_batch=True) + print(f"DEBUG PhD: Available PhD batches: {[(b.id, b.name, b.discipline.name) for b in all_phd_batches]}") + name_year_matches = Batch.objects.filter(name=batch_name, year=batch_year, running_batch=True) discipline_year_matches = Batch.objects.filter(discipline=discipline_obj, year=batch_year, running_batch=True) existing_batches = Batch.objects.filter(year=batch_year, running_batch=True) @@ -784,21 +954,25 @@ def save_students_batch(request): continue student_name = student_data.get('Name') or student_data.get('name', 'Unknown') - - student_upload = StudentBatchUpload.objects.create( - # Core identification - handle both field name formats - jee_app_no=student_data.get('JEE App. No./CCMT Roll. No.') or student_data.get('JEE App. No / CCMT Roll No') or student_data.get('Jee Main Application Number') or student_data.get('jee_app_no') or student_data.get('jeeAppNo') or None, + + # Normalise admission_semester from the phd_semester param or data + _adm_sem_raw = ( + phd_semester if phd_semester else + student_data.get('Admission Semester') or + student_data.get('admission_semester') or + student_data.get('admissionSemester', '') or '' + ) + _adm_sem = _adm_sem_raw.strip().capitalize() if _adm_sem_raw else '' + + # Common kwargs shared by both PhD and UG/PG models + _common = dict( roll_number=student_data.get('Institute Roll Number') or student_data.get('rollNumber', ''), - institute_email=student_data.get('Institute Email ID') or student_data.get('instituteEmail', ''), - name=student_data.get('Name') or student_data.get('name', ''), - father_name=student_data.get("Father's Name") or student_data.get('fname', ''), - mother_name=student_data.get("Mother's Name") or student_data.get('mname', ''), - gender=student_data.get('Gender') or student_data.get('gender', ''), - category=student_data.get('Category') or student_data.get('category', ''), - pwd=student_data.get('PWD') or student_data.get('pwd', 'NO'), + institute_email=student_data.get('Institute Email ID') or student_data.get('instituteEmail', ''), + gender=normalize_gender(student_data.get('Gender') or student_data.get('gender', '')), + category=normalize_category(student_data.get('Category') or student_data.get('category', ''), is_phd=(programme_type == 'phd')), + pwd=normalize_yes_no(student_data.get('PWD') or student_data.get('pwd', ''), default='NO'), minority=student_data.get('Minority') or student_data.get('minority', ''), - phone_number=sanitize_phone_number(student_data.get('Mobile No') or student_data.get('phoneNumber', '')), personal_email=student_data.get('Alternate Email ID') or student_data.get('email', '') or student_data.get('alternateEmail', '') or student_data.get('personalEmail', '') or student_data.get('personal_email', ''), parent_email=student_data.get('Parent Email') or student_data.get('parentEmail', '') or student_data.get('parent_email', ''), @@ -813,29 +987,48 @@ def save_students_batch(request): admission_mode=student_data.get('Admission Mode') or student_data.get('admissionMode', ''), admission_mode_remarks=student_data.get('Admission Mode Remarks') or student_data.get('admissionModeRemarks', ''), income_group=student_data.get('Income Group') or student_data.get('incomeGroup', ''), - income=student_data.get('Income') or student_data.get('income', None), - - branch=student_data.get('Discipline') or student_data.get('branch', ''), - specialization=student_data.get('Specialization') or student_data.get('specialization', ''), - date_of_birth=dob, - ai_rank=_safe_int_conversion(sanitize_rank_value(student_data.get('AI rank') or student_data.get('jeeRank'))), - category_rank=_safe_int_conversion(sanitize_rank_value(student_data.get('Category Rank') or student_data.get('categoryRank'))), - + income=_safe_decimal_conversion(student_data.get('Income') or student_data.get('income') or None), + father_name=student_data.get("Father's Name") or student_data.get('fname', ''), + mother_name=student_data.get("Mother's Name") or student_data.get('mname', ''), father_occupation=student_data.get("Father's Occupation") or student_data.get('fatherOccupation', ''), father_mobile=sanitize_phone_number(student_data.get('Father Mobile Number') or student_data.get('fatherMobile', '')), mother_occupation=student_data.get("Mother's Occupation") or student_data.get('motherOccupation', ''), mother_mobile=sanitize_phone_number(student_data.get('Mother Mobile Number') or student_data.get('motherMobile', '')), - - allotted_category=student_data.get('allottedcat') or student_data.get('allottedCategory', ''), - allotted_gender=student_data.get('Allotted Gender') or student_data.get('allottedGender', ''), - + date_of_birth=dob, year=batch_year, - academic_year=academic_year, # Use the normalized academic year - programme_type=programme_type, + academic_year=academic_year, reported_status='NOT_REPORTED', - source='excel_upload', # Track that this came from Excel upload - uploaded_by=request.user if request.user.is_authenticated else None + source='excel_upload', + uploaded_by=request.user if request.user.is_authenticated else None, ) + + if programme_type == 'phd': + student_upload = PhdStudentBatchUpload.objects.create( + discipline=discipline_name, + application_no=student_data.get('Application No.') or student_data.get('applicationNo') or None, + admission_type=student_data.get('Admission Type') or student_data.get('admissionType', ''), + gate_qualified=normalize_yes_no(student_data.get('GATE Qualified') or student_data.get('GATE Qualaified') or student_data.get('gateQualified', ''), default='NO'), + gate_stream=student_data.get('GATE Stream') or student_data.get('gateStream', ''), + gate_rank=_safe_int_conversion(sanitize_rank_value(student_data.get('GATE Rank') or student_data.get('gateRank'))), + category_rank=_safe_int_conversion(sanitize_rank_value(student_data.get('Category Rank') or student_data.get('categoryRank'))), + allotted_category=student_data.get('allottedcat') or student_data.get('allottedCategory', ''), + allotted_gender=student_data.get('Allotted Gender') or student_data.get('allottedGender', ''), + admission_semester=_adm_sem, + **_common + ) + else: + student_upload = StudentBatchUpload.objects.create( + jee_app_no=student_data.get('JEE App. No./CCMT Roll. No.') or student_data.get('JEE App. No / CCMT Roll No') or student_data.get('Jee Main Application Number') or student_data.get('jee_app_no') or student_data.get('jeeAppNo') or None, + branch=student_data.get('Discipline') or student_data.get('branch', ''), + specialization=student_data.get('Specialization') or student_data.get('specialization', ''), + ai_rank=_safe_int_conversion(sanitize_rank_value(student_data.get('AI rank') or student_data.get('jeeRank'))), + category_rank=_safe_int_conversion(sanitize_rank_value(student_data.get('Category Rank') or student_data.get('categoryRank'))), + allotted_category=student_data.get('allottedcat') or student_data.get('allottedCategory', ''), + allotted_gender=student_data.get('Allotted Gender') or student_data.get('allottedGender', ''), + admission_semester=_adm_sem, + programme_type=programme_type, + **_common + ) # AUTOMATIC USER ACCOUNT CREATION with HASHED PASSWORD if student_upload.roll_number: @@ -864,7 +1057,8 @@ def save_students_batch(request): total_processed = successful_uploads + failed_uploads + validation_errors + skipped_invalid response_data = { - 'success': True, + 'success': successful_uploads > 0, + 'error_detail': errors[0] if errors and successful_uploads == 0 else None, 'data': { 'successful_uploads': successful_uploads, 'failed_uploads': failed_uploads, @@ -1027,10 +1221,12 @@ def get_allocation_summary(students, programme_type): @csrf_exempt @require_http_methods(["POST"]) +@require_designation("acadadmin", "Dean Academic") def add_single_student(request): try: data = json.loads(request.body) programme_type = data.get('programme_type', 'ug') + phd_semester = data.get('phd_semester', None) # Get PhD semester (odd/even) year_result = validate_and_normalize_year(data.get('academic_year')) if isinstance(year_result, JsonResponse): @@ -1071,6 +1267,13 @@ def add_single_student(request): 'admissionMode': 'admission_mode', 'admissionModeRemarks': 'admission_mode_remarks', 'incomeGroup': 'income_group', + # PhD-specific field name mappings + 'applicationNo': 'application_no', + 'admissionType': 'admission_type', + 'gateQualified': 'gate_qualified', + 'gateStream': 'gate_stream', + 'gateRank': 'gate_rank', + 'admissionSemester': 'admission_semester', } mapped_data = {} @@ -1083,7 +1286,13 @@ def add_single_student(request): data = mapped_data - required_fields = ['name', 'father_name', 'mother_name', 'branch', 'gender', 'category', 'pwd', 'address'] + # Required fields differ by programme type: + # PhD does not require father/mother names or address (adult graduate admissions) + if programme_type == 'phd': + required_fields = ['name', 'branch', 'gender', 'category', 'pwd'] + else: + required_fields = ['name', 'father_name', 'mother_name', 'branch', 'gender', 'category', 'pwd', 'address'] + missing_fields = [field for field in required_fields if not data.get(field)] if missing_fields: @@ -1101,77 +1310,126 @@ def add_single_student(request): }, status=400) student_data = processed_students[0] + dob = parse_date_flexible(data.get('date_of_birth')) + + # Build shared kwargs used by both PhD and UG/PG models + _shared_kwargs = dict( + name=student_data.get('name') or data.get('name', ''), + roll_number=student_data.get('roll_number') or data.get('rollNumber', '') or data.get('roll_number', ''), + institute_email=student_data.get('institute_email') or data.get('instituteEmail', '') or data.get('institute_email', ''), + father_name=data.get('father_name', ''), + mother_name=data.get('mother_name', ''), + gender=normalize_gender(data.get('gender', '')), + category=normalize_category(data.get('category', ''), is_phd=(programme_type == 'phd')), + pwd=normalize_yes_no(data.get('pwd', ''), default='NO'), + minority=data.get('minority', ''), + date_of_birth=dob or None, + phone_number=sanitize_phone_number(data.get('phone_number', '') or data.get('MobileNo', '')), + personal_email=data.get('personal_email', '') or data.get('email', '') or data.get('alternateEmail', ''), + parent_email=data.get('parent_email', '') or data.get('parentEmail', ''), + address=data.get('address', '') or student_data.get('address', ''), + state=data.get('state', ''), + country=data.get('country', 'India'), + nationality=data.get('nationality', 'Indian'), + blood_group=data.get('blood_group', ''), + blood_group_remarks=data.get('blood_group_remarks', ''), + pwd_category=data.get('pwd_category', ''), + pwd_category_remarks=data.get('pwd_category_remarks', ''), + income_group=data.get('income_group', ''), + income=_safe_decimal_conversion(data.get('income') or None), + father_occupation=data.get('father_occupation', ''), + father_mobile=sanitize_phone_number(data.get('father_mobile', '')), + mother_occupation=data.get('mother_occupation', ''), + mother_mobile=sanitize_phone_number(data.get('mother_mobile', '')), + allotted_category=data.get('allotted_category', ''), + allotted_gender=data.get('allotted_gender', ''), + year=batch_year, + academic_year=academic_year, + reported_status='NOT_REPORTED', + allocation_status='ALLOCATED', + source='manual_entry', + ) - jee_app_no = data.get('jee_app_no') - if jee_app_no: - existing_student = StudentBatchUpload.objects.filter(jee_app_no=jee_app_no).first() - if existing_student: + if programme_type == 'phd': + # --- PhD path: save to PhdStudentBatchUpload --- + application_no = data.get('application_no') or None + if application_no: + existing_phd = PhdStudentBatchUpload.objects.filter(application_no=application_no).first() + if existing_phd: + return JsonResponse({ + 'success': False, + 'message': f'Student with Application Number {application_no} already exists (Roll Number: {existing_phd.roll_number})' + }, status=400) + + phd_semester_val = '' + if phd_semester: + phd_semester_val = phd_semester.strip().capitalize() + elif data.get('admission_semester'): + phd_semester_val = str(data['admission_semester']).strip().capitalize() + + if phd_semester_val not in ['Odd', 'Even']: return JsonResponse({ 'success': False, - 'message': f'Student with JEE Application Number {jee_app_no} already exists (Roll Number: {existing_student.roll_number})' + 'message': 'PhD semester (Odd or Even) is required for manual student entry', + 'validation_error': 'missing_phd_semester' }, status=400) - dob = parse_date_flexible(data.get('date_of_birth')) - - # Save to database with ALL Excel-equivalent fields for complete synchronization - with transaction.atomic(): - student = StudentBatchUpload.objects.create( - name=student_data.get('name'), - jee_app_no=student_data.get('jee_app_no'), - roll_number=student_data.get('roll_number'), - institute_email=student_data.get('institute_email'), - - father_name=student_data.get('father_name'), - mother_name=student_data.get('mother_name'), - gender=student_data.get('gender'), - category=student_data.get('category'), - pwd=student_data.get('pwd'), - minority=data.get('minority', ''), - date_of_birth=dob or data.get('date_of_birth'), - - phone_number=sanitize_phone_number(data.get('phone_number', '') or data.get('MobileNo', '')), - personal_email=data.get('personal_email', '') or data.get('email', '') or data.get('alternateEmail', '') or data.get('Alternate Email ID', ''), - parent_email=data.get('parent_email', '') or data.get('parentEmail', ''), - address=student_data.get('address'), - state=data.get('state', '') or data.get('State', ''), - country=data.get('country', 'India'), - nationality=data.get('nationality', 'Indian'), - blood_group=data.get('blood_group', '') or data.get('bloodGroup', ''), - blood_group_remarks=data.get('blood_group_remarks', '') or data.get('bloodGroupRemarks', ''), - pwd_category=data.get('pwd_category', '') or data.get('pwdCategory', ''), - pwd_category_remarks=data.get('pwd_category_remarks', '') or data.get('pwdCategoryRemarks', ''), - admission_mode=data.get('admission_mode', '') or data.get('admissionMode', ''), - admission_mode_remarks=data.get('admission_mode_remarks', '') or data.get('admissionModeRemarks', ''), - income_group=data.get('income_group', '') or data.get('incomeGroup', ''), - income=data.get('income', None), - - father_occupation=data.get('father_occupation', '') or data.get("Father's Occupation", ''), - father_mobile=sanitize_phone_number(data.get('father_mobile', '') or data.get('Father Mobile Number', '')), - mother_occupation=data.get('mother_occupation', '') or data.get("Mother's Occupation", ''), - mother_mobile=sanitize_phone_number(data.get('mother_mobile', '') or data.get('Mother Mobile Number', '')), + with transaction.atomic(): + student = PhdStudentBatchUpload.objects.create( + discipline=student_data.get('branch') or data.get('discipline', ''), + application_no=application_no, + admission_type=data.get('admission_type', ''), + gate_qualified=normalize_yes_no(data.get('gate_qualified', ''), default='NO'), + gate_stream=data.get('gate_stream', ''), + gate_rank=_safe_int_conversion(sanitize_rank_value(data.get('gate_rank'))), + admission_semester=phd_semester_val, + admission_mode=data.get('admission_mode', ''), + admission_mode_remarks=data.get('admission_mode_remarks', ''), + **_shared_kwargs + ) + # Auto-create user account (consistent with Excel upload path) + if student.roll_number: + try: + student.create_user_account() + except Exception: + pass + else: + # --- UG / PG path: save to StudentBatchUpload --- + jee_app_no = data.get('jee_app_no') + if jee_app_no: + existing_student = StudentBatchUpload.objects.filter(jee_app_no=jee_app_no).first() + if existing_student: + return JsonResponse({ + 'success': False, + 'message': f'Student with JEE Application Number {jee_app_no} already exists (Roll Number: {existing_student.roll_number})' + }, status=400) + with transaction.atomic(): + student = StudentBatchUpload.objects.create( + jee_app_no=jee_app_no or None, branch=student_data.get('branch'), specialization=data.get('specialization', ''), - ai_rank=sanitize_rank_value(data.get('ai_rank') or data.get('AI rank')), - category_rank=sanitize_rank_value(data.get('category_rank') or data.get('Category Rank')), - - allotted_category=data.get('allotted_category', '') or data.get('allottedcat', ''), - allotted_gender=data.get('allotted_gender', '') or data.get('allottedGender', ''), - - year=batch_year, + ai_rank=_safe_int_conversion(sanitize_rank_value(data.get('ai_rank') or data.get('AI rank'))), + category_rank=_safe_int_conversion(sanitize_rank_value(data.get('category_rank') or data.get('Category Rank'))), + admission_mode=data.get('admission_mode', ''), + admission_mode_remarks=data.get('admission_mode_remarks', ''), + admission_semester=str(data.get('admission_semester', '') or '').strip().capitalize(), programme_type=programme_type, - reported_status='NOT_REPORTED', - academic_year=academic_year, - allocation_status='ALLOCATED', - source='manual_entry' + **_shared_kwargs ) + # Auto-create user account (consistent with Excel upload path) + if student.roll_number: + try: + student.create_user_account() + except Exception: + pass return JsonResponse({ 'success': True, 'data': { 'student_id': student.id, - 'roll_number': student.roll_number or student_data.get('roll_number'), - 'institute_email': student.institute_email or student_data.get('institute_email'), + 'roll_number': student.roll_number, + 'institute_email': student.institute_email, 'name': student.name, 'personal_email': student.personal_email, 'parent_email': student.parent_email, @@ -1199,6 +1457,7 @@ def add_single_student(request): @csrf_exempt @require_http_methods(["PUT"]) +@require_designation("acadadmin", "Dean Academic") def set_total_seats(request): try: data = json.loads(request.body) @@ -1246,6 +1505,7 @@ def set_total_seats(request): # STUDENT STATUS MANAGEMENT # ============================================================================= +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["PUT", "POST", "OPTIONS"]) def update_student_status(request): @@ -1265,6 +1525,7 @@ def update_student_status(request): student_id = data.get('studentId') reported_status = data.get('reportedStatus') + programme_type_hint = (data.get('programmeType') or data.get('programme_type') or '').lower() if not student_id or not reported_status: return JsonResponse({ @@ -1279,13 +1540,30 @@ def update_student_status(request): 'message': 'Invalid reportedStatus. Must be REPORTED, NOT_REPORTED, or PENDING' }, status=400) - try: - student = StudentBatchUpload.objects.get(id=student_id) - except StudentBatchUpload.DoesNotExist: - return JsonResponse({ - 'success': False, - 'message': 'Student not found' - }, status=404) + is_phd_student = False + # If caller explicitly indicates a PhD student, skip the UG/PG table lookup to + # avoid collisions between StudentBatchUpload.id and PhdStudentBatchUpload.id. + if programme_type_hint == 'phd': + try: + student = PhdStudentBatchUpload.objects.get(id=student_id) + is_phd_student = True + except PhdStudentBatchUpload.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': 'Student not found' + }, status=404) + else: + try: + student = StudentBatchUpload.objects.get(id=student_id) + except StudentBatchUpload.DoesNotExist: + try: + student = PhdStudentBatchUpload.objects.get(id=student_id) + is_phd_student = True + except PhdStudentBatchUpload.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': 'Student not found' + }, status=404) old_status = student.reported_status student.reported_status = reported_status @@ -1350,10 +1628,21 @@ def update_student_status(request): dept_name = 'Design' # Exact database name discipline_name = 'Design' discipline_acronym = 'Des.' # Use existing acronym + elif 'NATURAL SCIENCE' in branch_upper or 'NS-' in branch_upper: + dept_name = 'Natural Science' + discipline_name = branch_field # Keep exact name for Discipline lookup + elif 'LIBERAL ARTS' in branch_upper or 'LA-' in branch_upper: + dept_name = 'Natural Science' # Closest available dept; no dedicated LA dept + discipline_name = branch_field # Keep exact name for Discipline lookup else: - # Default fallback - dept_name = 'CSE' - discipline_name = 'Computer Science and Engineering' + if is_phd_student: + # For any unrecognised PhD discipline, keep the exact string for Discipline lookup + dept_name = 'Natural Science' + discipline_name = branch_field + else: + # Default fallback for UG/PG + dept_name = 'CSE' + discipline_name = 'Computer Science and Engineering' try: department = DepartmentInfo.objects.get(name=dept_name) @@ -1447,16 +1736,32 @@ def update_student_status(request): dept_name = 'Design' # Exact database name discipline_name = 'Design' discipline_acronym = 'Des.' # Use existing acronym + elif 'NATURAL SCIENCE' in branch_upper or 'NS-' in branch_upper: + dept_name = 'Natural Science' + discipline_name = branch_field + elif 'LIBERAL ARTS' in branch_upper or 'LA-' in branch_upper: + dept_name = 'Natural Science' + discipline_name = branch_field else: - dept_name = 'CSE' - discipline_name = 'Computer Science and Engineering' - - if dept_name == 'Design': + if is_phd_student: + dept_name = 'Natural Science' + discipline_name = branch_field + else: + dept_name = 'CSE' + discipline_name = 'Computer Science and Engineering' + + if is_phd_student: + # PhD: PhdStudentBatchUpload.discipline stores the exact Discipline.name, + # so a direct lookup is the most reliable path. + discipline = Discipline.objects.filter(name__iexact=branch_field).first() + if not discipline: + discipline = Discipline.objects.filter(name=branch_field).first() + elif dept_name == 'Design': discipline = Discipline.objects.filter( acronym='Des.', name='Design' ).first() - + if not discipline: discipline = Discipline.objects.filter(name__icontains='design').first() else: @@ -1464,7 +1769,7 @@ def update_student_status(request): acronym=dept_name, name__exact=discipline_name ).first() - + if not discipline: disciplines = Discipline.objects.filter(acronym=dept_name).order_by('name') discipline = disciplines.first() @@ -1577,8 +1882,30 @@ def update_student_status(request): batch_obj = None batch_created = False + # For PhD students, match batch by admission_semester (Odd or Even) + if programme_category == 'PhD' and student.admission_semester: + if student.admission_semester.strip().lower() in ['odd', 'even']: + semester_capitalized = student.admission_semester.strip().capitalize() + batch_name = f'PhD ({semester_capitalized})' + batch_obj = Batch.objects.filter( + name=batch_name, + year=student.year, + discipline=discipline, + running_batch=True + ).first() + + if not batch_obj: + return JsonResponse({ + 'success': False, + 'message': f'No batch found for {batch_name} {discipline.name} Year-{student.year}. Please create the required batch first.', + 'error_code': 'BATCH_NOT_FOUND', + 'required_batch': batch_name, + 'discipline': discipline.name, + 'year': student.year + }, status=400) + # For PG students with specialization, only use existing batches - if programme_category == 'PG' and student.specialization and student_specific_curriculum: + elif programme_category == 'PG' and student.specialization and student_specific_curriculum: if student.specialization == 'Design': batch_obj = Batch.objects.filter( name=programme_name, # M.Des @@ -1680,7 +2007,10 @@ def update_student_status(request): transfer_message_addition = transfer_message_addition if 'transfer_message_addition' in locals() else "" - current_semester = calculate_current_semester(int(student.year)) + # For PhD students, pass admission_semester so Even-semester + # admits get the correct (+1) offset in semester calculation. + _admission_sem = getattr(student, 'admission_semester', None) + current_semester = calculate_current_semester(int(student.year), admission_semester=_admission_sem) # USE EXISTING BATCH (NO AUTOMATIC BATCH CREATION) final_batch = batch_obj @@ -1703,7 +2033,7 @@ def update_student_status(request): 'batch': student.year, 'father_name': student.father_name or '', 'mother_name': student.mother_name or '', - 'category': student.category or '', + 'category': to_academic_category(student.category), 'cpi': 0.0, 'curr_semester_no': current_semester, 'hall_no': 0, @@ -1719,7 +2049,7 @@ def update_student_status(request): academic_student.batch = student.year academic_student.father_name = student.father_name or '' academic_student.mother_name = student.mother_name or '' - academic_student.category = student.category or '' + academic_student.category = to_academic_category(student.category) academic_student.curr_semester_no = current_semester from applications.academic_information.models import Constants valid_choices = [choice[0] for choice in Constants.MTechSpecialization] @@ -2019,6 +2349,7 @@ def update_student_status(request): # EXPORT FUNCTIONALITY # ============================================================================= +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["GET"]) def export_students(request, programme_type): @@ -2107,6 +2438,7 @@ def export_students(request, programme_type): # UPLOAD HISTORY # ============================================================================= +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["GET"]) def upload_history(request): @@ -2136,6 +2468,7 @@ def upload_history(request): # STUDENT LISTING # ============================================================================= +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["GET"]) def list_students(request): @@ -2253,6 +2586,7 @@ def list_students(request): @csrf_exempt @require_http_methods(["POST"]) +@require_designation("acadadmin", "Dean Academic") def create_batch(request): """ Create new batch @@ -2404,6 +2738,7 @@ def create_batch(request): 'message': f'Failed to create batch: {str(e)}' }, status=500) +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["PUT"]) def update_batch(request, batch_id): @@ -2473,6 +2808,7 @@ def update_batch(request, batch_id): }, status=500) +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["GET"]) def list_batches_with_status(request): @@ -2510,6 +2846,7 @@ def list_batches_with_status(request): # STUDENT STATUS CRUD # ============================================================================= +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["PUT"]) def update_student_status_crud(request, student_id): @@ -2581,6 +2918,7 @@ def update_student_status_crud(request, student_id): # PASSWORD MANAGEMENT # ============================================================================= +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["POST"]) def auto_generate_passwords_for_batch(request): @@ -2961,10 +3299,17 @@ def get_or_create_discipline(discipline_name): normalized_name = discipline_name.strip() discipline_lower = normalized_name.lower() + # Mapping of variations to canonical names discipline_mapping = { 'computer science and engineering': 'Computer Science and Engineering', - 'electronics and communication engineering': 'Electronics and Communication Engineering', + 'cse': 'Computer Science and Engineering', + 'computer science': 'Computer Science and Engineering', + 'electronics and communication engineering': 'Electronics and Communication Engineering', + 'ece': 'Electronics and Communication Engineering', + 'electronics and communication': 'Electronics and Communication Engineering', 'mechanical engineering': 'Mechanical Engineering', + 'me': 'Mechanical Engineering', + 'mechanical': 'Mechanical Engineering', 'smart manufacturing': 'Smart Manufacturing', 'design': 'Design', 'mechatronics': 'Mechatronics' @@ -3047,16 +3392,12 @@ def create_or_update_main_student_record(student_data, batch_obj, batch_year): extra_info.date_of_birth = dob extra_info.save() - category_mapping = { - 'General': 'GEN', - 'GEN': 'GEN', - 'OBC': 'OBC', - 'SC': 'SC', - 'ST': 'ST' - } - category = category_mapping.get(student_data.get('Category', 'GEN'), 'GEN') + # Normalize raw Excel category to AcademicStudent valid choices (GEN, SC, ST, OBC) + category = to_academic_category(student_data.get('Category') or student_data.get('category', '')) - current_semester = calculate_current_semester(batch_year) + # Pass admission_semester for PhD Even-semester admits + _admission_sem = student_data.get('admission_semester') or student_data.get('Admission Semester') + current_semester = calculate_current_semester(batch_year, admission_semester=_admission_sem) academic_student, created = AcademicStudent.objects.get_or_create( id=extra_info, @@ -3082,6 +3423,7 @@ def create_or_update_main_student_record(student_data, batch_obj, batch_year): # INDIVIDUAL STUDENT CRUD OPERATIONS # ============================================================================= +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["GET"]) def get_student(request, student_id): @@ -3198,6 +3540,7 @@ def get_student(request, student_id): }, status=500) +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["PUT", "POST"]) def update_student(request, student_id): @@ -3205,9 +3548,30 @@ def update_student(request, student_id): Update a student by ID """ try: - student = StudentBatchUpload.objects.get(id=student_id) data = json.loads(request.body) - old_discipline = student.branch + programme_type_hint = (data.get('programmeType') or data.get('programme_type') or '').lower() + + is_phd = False + if programme_type_hint == 'phd': + try: + student = PhdStudentBatchUpload.objects.get(id=student_id) + is_phd = True + except PhdStudentBatchUpload.DoesNotExist: + return JsonResponse({'success': False, + 'message': f'PhD Student with ID {student_id} not found'}, status=404) + else: + try: + student = StudentBatchUpload.objects.get(id=student_id) + except StudentBatchUpload.DoesNotExist: + # Fallback: check PhdStudentBatchUpload (in case programmeType was not sent) + try: + student = PhdStudentBatchUpload.objects.get(id=student_id) + is_phd = True + except PhdStudentBatchUpload.DoesNotExist: + return JsonResponse({'success': False, + 'message': f'Student with ID {student_id} not found'}, status=404) + + old_discipline = student.discipline if is_phd else student.branch discipline_changed = False field_mapping = { @@ -3275,22 +3639,26 @@ def update_student(request, student_id): roll_number = data.get('rollNumber') or data.get('roll_number') institute_email = data.get('instituteEmail') or data.get('institute_email') + # Use the correct model and field for uniqueness checks + _dup_model = PhdStudentBatchUpload if is_phd else StudentBatchUpload + _app_field = 'application_no' if is_phd else 'jee_app_no' + if jee_app_no and jee_app_no != student.jee_app_no: - if StudentBatchUpload.objects.filter(jee_app_no=jee_app_no).exclude(id=student_id).exists(): + if _dup_model.objects.filter(**{_app_field: jee_app_no}).exclude(id=student_id).exists(): return JsonResponse({ 'success': False, - 'message': f'JEE Application Number {jee_app_no} already exists for another student' + 'message': f'Application Number {jee_app_no} already exists for another student' }, status=400) if roll_number and roll_number != student.roll_number: - if StudentBatchUpload.objects.filter(roll_number=roll_number).exclude(id=student_id).exists(): + if _dup_model.objects.filter(roll_number=roll_number).exclude(id=student_id).exists(): return JsonResponse({ 'success': False, 'message': f'Roll Number {roll_number} already exists for another student' }, status=400) if institute_email and institute_email != student.institute_email: - if StudentBatchUpload.objects.filter(institute_email=institute_email).exclude(id=student_id).exists(): + if _dup_model.objects.filter(institute_email=institute_email).exclude(id=student_id).exists(): return JsonResponse({ 'success': False, 'message': f'Institute Email {institute_email} already exists for another student' @@ -3299,8 +3667,13 @@ def update_student(request, student_id): for frontend_field, backend_field in field_mapping.items(): if frontend_field in data: value = data[frontend_field] - if hasattr(student, backend_field): - setattr(student, backend_field, value) + # For PhD: jee_app_no is a read-only compat property; write to application_no + actual_field = 'application_no' if (is_phd and backend_field == 'jee_app_no') else backend_field + if hasattr(student, actual_field): + try: + setattr(student, actual_field, value) + except AttributeError: + pass direct_fields = [ 'name', 'gender', 'category', 'pwd', 'minority', 'address', 'state', 'branch', 'specialization', @@ -3311,9 +3684,25 @@ def update_student(request, student_id): for field in direct_fields: if field in data: + # For PhD: 'branch' and 'specialization' are read-only compat properties. + # Map 'branch' → 'discipline'; skip 'specialization'. + if is_phd and field == 'specialization': + continue + actual_field = 'discipline' if (is_phd and field == 'branch') else field if field == 'branch' and data[field] != old_discipline: discipline_changed = True - setattr(student, field, data[field]) + value = data[field] + # Normalize choice fields to match DB expectations + if field == 'gender': + value = normalize_gender(value) + elif field == 'category': + value = normalize_category(value, is_phd=is_phd) + elif field == 'pwd': + value = normalize_yes_no(value, default='NO') + try: + setattr(student, actual_field, value) + except AttributeError: + pass dob_value = data.get('dob') or data.get('dateOfBirth') or data.get('date_of_birth') if dob_value: @@ -3329,7 +3718,7 @@ def update_student(request, student_id): pass jee_rank_value = data.get('jeeRank') or data.get('aiRank') or data.get('ai_rank') - if jee_rank_value: + if jee_rank_value and not is_phd: # PhD uses gate_rank, not ai_rank try: sanitized_rank = sanitize_rank_value(jee_rank_value) student.ai_rank = int(sanitized_rank.replace(',', '')) if sanitized_rank.replace(',', '').isdigit() else None @@ -3459,6 +3848,7 @@ def update_student(request, student_id): }, status=500) +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["DELETE", "POST"]) def delete_student(request, student_id): @@ -3478,7 +3868,26 @@ def delete_student(request, student_id): from django.contrib.auth.models import User from django.db import transaction - student = StudentBatchUpload.objects.get(id=student_id) + is_phd = False + programme_type_hint = request.GET.get('programme_type', '').lower() + if programme_type_hint == 'phd': + try: + student = PhdStudentBatchUpload.objects.get(id=student_id) + is_phd = True + except PhdStudentBatchUpload.DoesNotExist: + return JsonResponse({'success': False, + 'message': f'PhD Student with ID {student_id} not found'}, status=404) + else: + try: + student = StudentBatchUpload.objects.get(id=student_id) + except StudentBatchUpload.DoesNotExist: + # Fallback: also check PhdStudentBatchUpload + try: + student = PhdStudentBatchUpload.objects.get(id=student_id) + is_phd = True + except PhdStudentBatchUpload.DoesNotExist: + return JsonResponse({'success': False, + 'message': f'Student with ID {student_id} not found'}, status=404) student_name = student.name student_roll = student.roll_number @@ -3597,6 +4006,7 @@ def delete_student(request, student_id): # BULK STATUS UPDATE FUNCTIONALITY # ============================================================================= +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["POST"]) def bulk_update_student_status(request): @@ -3876,6 +4286,7 @@ def check_transfer_status(request): @csrf_exempt @require_http_methods(["GET"]) +@require_designation("acadadmin", "Dean Academic") def get_batch_students(request, batch_id): """ Get students for a specific batch - ONLY from StudentBatchUpload table @@ -3898,26 +4309,35 @@ def get_batch_students(request, batch_id): specialization = request.GET.get('specialization') discipline = request.GET.get('discipline') - students = StudentBatchUpload.objects.filter( - year=batch.year, - programme_type=programme_type - ) - - # Apply disciplinary filtering based on programme type - if programme_type == 'pg': + if programme_type == 'phd': + # Primary store: PhdStudentBatchUpload + phd_qs = PhdStudentBatchUpload.objects.filter(year=batch.year) + if 'Odd' in batch.name: + phd_qs = phd_qs.filter(admission_semester__iexact='Odd') + elif 'Even' in batch.name: + phd_qs = phd_qs.filter(admission_semester__iexact='Even') + if batch.discipline: + # Try full discipline name first; fall back to acronym. + # This handles Excel uploads that store short codes ('ECE', 'ME') + # while avoiding cross-matches for batches with shared acronyms + # (e.g. NS- is used by both NS-MATHS and NS-PHSYICS). + name_qs = phd_qs.filter(discipline__icontains=batch.discipline.name) + if name_qs.exists(): + phd_qs = name_qs + else: + phd_qs = phd_qs.filter(discipline__icontains=batch.discipline.acronym) + students = sorted(list(phd_qs), key=lambda s: s.roll_number or '') + elif programme_type == 'pg': + students = StudentBatchUpload.objects.filter(year=batch.year, programme_type=programme_type) if specialization: students = students.filter(specialization__icontains=specialization) else: - discipline_name = batch.discipline.name discipline_filters = Q() - discipline_filters |= Q(branch__icontains=discipline_name) - if 'Engineering' in discipline_name: typo_name = discipline_name.replace('Engineering', 'Enginnering') discipline_filters |= Q(branch__icontains=typo_name) - if 'Computer Science' in discipline_name: discipline_filters |= Q(branch__icontains='CSE') discipline_filters |= Q(branch__icontains='Computer Science') @@ -3927,21 +4347,17 @@ def get_batch_students(request, batch_id): elif 'Mechanical' in discipline_name: discipline_filters |= Q(branch__icontains='ME') discipline_filters |= Q(branch__icontains='Mechanical') - students = students.filter(discipline_filters) + students = students.order_by('roll_number') else: + students = StudentBatchUpload.objects.filter(year=batch.year, programme_type=programme_type) discipline_name = batch.discipline.name discipline_filters = Q() - discipline_filters |= Q(branch__icontains=discipline_name) - if 'Engineering' in discipline_name: typo_name = discipline_name.replace('Engineering', 'Enginnering') discipline_filters |= Q(branch__icontains=typo_name) - - students = students.filter(discipline_filters) - - students = students.order_by('roll_number') + students = students.filter(discipline_filters).order_by('roll_number') upload_students = [] for student in students: @@ -3950,7 +4366,20 @@ def get_batch_students(request, batch_id): 'name': student.name, 'roll_number': student.roll_number, 'institute_email': student.institute_email, - 'jee_app_no': student.jee_app_no, + 'jee_app_no': getattr(student, 'jee_app_no', None), + + # PhD-specific fields (safe getattr; empty/None for UG/PG students) + 'application_no': getattr(student, 'application_no', ''), + 'admission_type': getattr(student, 'admission_type', ''), + 'gate_qualified': getattr(student, 'gate_qualified', ''), + 'gateQualified': getattr(student, 'gate_qualified', ''), + 'gate_stream': getattr(student, 'gate_stream', ''), + 'gateStream': getattr(student, 'gate_stream', ''), + 'gate_rank': getattr(student, 'gate_rank', None), + 'gateRank': getattr(student, 'gate_rank', None), + 'admission_semester': getattr(student, 'admission_semester', ''), + 'admissionSemester': getattr(student, 'admission_semester', ''), + 'discipline': getattr(student, 'discipline', ''), 'father_name': student.father_name, 'mother_name': getattr(student, 'mother_name', ''), @@ -4104,7 +4533,7 @@ def get_batch_students(request, batch_id): # ============================================================================= @api_view(['GET']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def admin_batches_unified(request): """ UNIFIED API for both 'Batches' tab and 'Upcoming Batches' tab @@ -4159,7 +4588,17 @@ def admin_batches_unified(request): students = StudentBatchUpload.objects.filter( year=batch.year, branch__icontains=batch.discipline.name - ).order_by('roll_number') + ) + + # For PhD batches, also filter by admission_semester (case-insensitive) + programme_type = 'phd' if batch.name.upper().startswith('PHD') else 'pg' if batch.name.startswith('M.') else 'ug' + if programme_type == 'phd': + if 'Odd' in batch.name: + students = students.filter(admission_semester__iexact='Odd') + elif 'Even' in batch.name: + students = students.filter(admission_semester__iexact='Even') + + students = students.order_by('roll_number') for student in students: students_data.append({ @@ -4253,6 +4692,7 @@ def admin_batches_unified(request): }, status=500) +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["GET"]) def sync_batch_data(request): @@ -4320,6 +4760,7 @@ def sync_batch_data(request): }, status=500) +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["POST"]) def validate_batch_prerequisites(request): @@ -4331,24 +4772,36 @@ def validate_batch_prerequisites(request): try: data = json.loads(request.body) academic_year = data.get('academic_year') # e.g., 2025 + requested_disciplines = data.get('disciplines') or [] if not academic_year: return JsonResponse({ 'success': False, 'message': 'Academic year is required' }, status=400) + + if isinstance(requested_disciplines, str): + requested_disciplines = [requested_disciplines] + + requested_disciplines = [ + str(discipline).strip() + for discipline in requested_disciplines + if str(discipline).strip() + ] from applications.programme_curriculum.models import Batch, Discipline - required_disciplines = ['Computer Science and Engineering', 'Electronics and Communication Engineering', - 'Mechanical Engineering', 'Smart Manufacturing', 'Design'] + default_required_disciplines = ['Computer Science and Engineering', 'Electronics and Communication Engineering', + 'Mechanical Engineering', 'Smart Manufacturing', 'Design'] + + required_disciplines = requested_disciplines or default_required_disciplines missing_batches = [] existing_batches = [] for discipline_name in required_disciplines: try: - discipline = Discipline.objects.filter(name=discipline_name).first() + discipline = Discipline.objects.filter(name__iexact=discipline_name).first() if discipline: batch = Batch.objects.filter( year=academic_year, @@ -4371,6 +4824,12 @@ def validate_batch_prerequisites(request): 'acronym': discipline.acronym, 'action_required': f'Create {discipline.acronym} batch for year {academic_year}' }) + else: + missing_batches.append({ + 'discipline': discipline_name, + 'acronym': discipline_name, + 'action_required': f'Create {discipline_name} batch for year {academic_year}' + }) except Exception as e: pass @@ -4397,6 +4856,7 @@ def validate_batch_prerequisites(request): # BATCH CURRICULUM STATUS CHECK # ============================================================================= +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["GET"]) def check_batches_curriculum_status(request): @@ -4468,6 +4928,7 @@ def check_batches_curriculum_status(request): }, status=500) +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["GET"]) def validate_student_upload_prerequisites(request): @@ -4561,6 +5022,7 @@ def validate_student_upload_prerequisites(request): # CURRICULUM REDUNDANCY CLEANUP UTILITIES # ============================================================================= +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["GET"]) def find_duplicate_curriculums(request): @@ -4623,6 +5085,7 @@ def find_duplicate_curriculums(request): }, status=500) +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["POST"]) def consolidate_duplicate_curriculums(request): @@ -4695,6 +5158,7 @@ def consolidate_duplicate_curriculums(request): # BATCH REDUNDANCY CLEANUP UTILITIES # ============================================================================= +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["GET"]) def find_duplicate_batches(request): @@ -4770,6 +5234,7 @@ def find_duplicate_batches(request): }, status=500) +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["POST"]) def consolidate_duplicate_batches(request): @@ -4856,6 +5321,7 @@ def consolidate_duplicate_batches(request): }, status=500) +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["POST"]) def fix_stuck_reported_students(request): @@ -4983,7 +5449,7 @@ def transfer_student_to_academic_system(student): current_semester=calculate_current_semester(student.academic_year), current_year=student.academic_year, cpi=0.0, - category=student.category or 'General', + category=to_academic_category(student.category), father_name=student.father_name or '', mother_name=student.mother_name or '', hall_no=0, @@ -5015,6 +5481,7 @@ def transfer_student_to_academic_system(student): } +@require_designation("acadadmin", "Dean Academic") @csrf_exempt @require_http_methods(["POST"]) def sync_batches_to_configuration(request): diff --git a/FusionIIIT/applications/programme_curriculum/forms.py b/FusionIIIT/applications/programme_curriculum/forms.py index 49189b282..8d2d18261 100644 --- a/FusionIIIT/applications/programme_curriculum/forms.py +++ b/FusionIIIT/applications/programme_curriculum/forms.py @@ -3,7 +3,7 @@ from django.forms import ModelForm, widgets from django.forms import Form, ValidationError from django.forms.models import ModelChoiceField -from .models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot, PROGRAMME_CATEGORY_CHOICES,NewProposalFile,Proposal_Tracking, CourseInstructor +from .models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot, PROGRAMME_CATEGORY_CHOICES,NewProposalFile,Proposal_Tracking, CourseInstructor, Thesis, ProgressSeminar, ThesisSlot, ProgressSeminarSlot from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import User from applications.globals.models import (DepartmentInfo, Designation,ExtraInfo, Faculty, HoldsDesignation) @@ -442,3 +442,63 @@ class Meta: # user_id = cleaned_data.get('receive_id') # if user_id: # self.fields['receive_design'].queryset = HoldsDesignation.objects.select_related('designation').filter(user_id=user_id) + + +class ThesisForm(ModelForm): + class Meta: + model = Thesis + fields = '__all__' + widgets = { + 'code': forms.TextInput(attrs={'placeholder': 'Thesis Code (e.g., CS799)', 'max_length': 10}), + 'name': forms.TextInput(attrs={'placeholder': 'Thesis Name', 'max_length': 100}), + 'credit': forms.NumberInput(attrs={'placeholder': 'Credits'}), + 'discipline': forms.Select(attrs={'class': 'ui fluid search selection dropdown'}), + 'programme_type': forms.Select(attrs={'class': 'ui fluid search selection dropdown'}), + 'working_thesis': forms.CheckboxInput(attrs={'class': 'ui checkbox'}), + } + + +class ProgressSeminarForm(ModelForm): + class Meta: + model = ProgressSeminar + fields = '__all__' + widgets = { + 'code': forms.TextInput(attrs={'placeholder': 'Progress Seminar Code (e.g., CS898)', 'max_length': 10}), + 'name': forms.TextInput(attrs={'placeholder': 'Progress Seminar Name', 'max_length': 100}), + 'credit': forms.NumberInput(attrs={'placeholder': 'Credits'}), + 'discipline': forms.Select(attrs={'class': 'ui fluid search selection dropdown'}), + 'programme_type': forms.Select(attrs={'class': 'ui fluid search selection dropdown'}), + 'working_progress_seminar': forms.CheckboxInput(attrs={'class': 'ui checkbox'}), + } + + +class ThesisSlotForm(ModelForm): + class Meta: + model = ThesisSlot + fields = '__all__' + widgets = { + 'semester': forms.Select(attrs={'class': 'ui fluid search selection dropdown'}), + 'name': forms.TextInput(attrs={'placeholder': 'Name/Code', 'max_length': 100}), + 'type': forms.Select(attrs={'class': 'ui fluid search selection dropdown'}), + 'thesis_slot_info': forms.Textarea(attrs={'placeholder': 'Enter Information about this Thesis Slot'}), + 'theses': forms.SelectMultiple(attrs={'class': 'ui fluid search selection dropdown'}), + 'duration': forms.NumberInput(attrs={'placeholder': 'Semester Duration'}), + 'min_registration_limit': forms.NumberInput(attrs={'placeholder': 'Min Reg limit'}), + 'max_registration_limit': forms.NumberInput(attrs={'placeholder': 'Max Reg limit'}), + } + + +class ProgressSeminarSlotForm(ModelForm): + class Meta: + model = ProgressSeminarSlot + fields = '__all__' + widgets = { + 'semester': forms.Select(attrs={'class': 'ui fluid search selection dropdown'}), + 'name': forms.TextInput(attrs={'placeholder': 'Name/Code', 'max_length': 100}), + 'type': forms.Select(attrs={'class': 'ui fluid search selection dropdown'}), + 'progress_seminar_slot_info': forms.Textarea(attrs={'placeholder': 'Enter Information about this Progress Seminar Slot'}), + 'progress_seminars': forms.SelectMultiple(attrs={'class': 'ui fluid search selection dropdown'}), + 'duration': forms.NumberInput(attrs={'placeholder': 'Semester Duration'}), + 'min_registration_limit': forms.NumberInput(attrs={'placeholder': 'Min Reg limit'}), + 'max_registration_limit': forms.NumberInput(attrs={'placeholder': 'Max Reg limit'}), + } diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0032_add_phd_admission_semester.py b/FusionIIIT/applications/programme_curriculum/migrations/0032_add_phd_admission_semester.py new file mode 100644 index 000000000..09a99ef52 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0032_add_phd_admission_semester.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0031_add_curriculum_options_to_batch'), + ] + + operations = [ + migrations.AddField( + model_name='studentbatchupload', + name='admission_semester', + field=models.CharField(blank=True, choices=[('Odd', 'Odd Semester'), ('Even', 'Even Semester')], help_text='For PhD students: Semester in which admission occurred (becomes their Semester 1)', max_length=10, null=True), + ), + migrations.AlterField( + model_name='batch', + name='name', + field=models.CharField(choices=[('B.Tech', 'B.Tech'), ('M.Tech', 'M.Tech'), ('M.Tech AI & ML', 'M.Tech AI & ML'), ('M.Tech Data Science', 'M.Tech Data Science'), ('M.Tech Communication and Signal Processing', 'M.Tech Communication and Signal Processing'), ('M.Tech Nanoelectronics and VLSI Design', 'M.Tech Nanoelectronics and VLSI Design'), ('M.Tech Power & Control', 'M.Tech Power & Control'), ('M.Tech Design', 'M.Tech Design'), ('M.Tech CAD/CAM', 'M.Tech CAD/CAM'), ('M.Tech Manufacturing and Automation', 'M.Tech Manufacturing and Automation'), ('B.Des', 'B.Des'), ('M.Des', 'M.Des'), ('Phd', 'Phd')], max_length=50), + ), + migrations.AlterField( + model_name='studentbatchupload', + name='jee_app_no', + field=models.CharField(blank=True, help_text='JEE App. No./CCMT Roll. No.', max_length=50, null=True, unique=True), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0033_auto_20260210_1723.py b/FusionIIIT/applications/programme_curriculum/migrations/0033_auto_20260210_1723.py new file mode 100644 index 000000000..e9a3f5d72 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0033_auto_20260210_1723.py @@ -0,0 +1,49 @@ +# Generated by Django 3.1.5 on 2026-02-10 17:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0032_add_phd_admission_semester'), + ] + + operations = [ + migrations.AlterField( + model_name='batch', + name='name', + field=models.CharField(choices=[('B.Tech', 'B.Tech'), ('M.Tech', 'M.Tech'), ('M.Tech AI & ML', 'M.Tech AI & ML'), ('M.Tech Data Science', 'M.Tech Data Science'), ('M.Tech Communication and Signal Processing', 'M.Tech Communication and Signal Processing'), ('M.Tech Nanoelectronics and VLSI Design', 'M.Tech Nanoelectronics and VLSI Design'), ('M.Tech Power & Control', 'M.Tech Power & Control'), ('M.Tech Design', 'M.Tech Design'), ('M.Tech CAD/CAM', 'M.Tech CAD/CAM'), ('M.Tech Manufacturing and Automation', 'M.Tech Manufacturing and Automation'), ('B.Des', 'B.Des'), ('M.Des', 'M.Des'), ('PhD (Odd)', 'PhD (Odd)'), ('PhD (Even)', 'PhD (Even)')], max_length=50), + ), + migrations.AlterField( + model_name='batch', + name='year', + field=models.PositiveIntegerField(default=2026), + ), + migrations.AlterField( + model_name='courseinstructor', + name='year', + field=models.IntegerField(default=2026), + ), + migrations.AlterField( + model_name='programme', + name='programme_begin_year', + field=models.PositiveIntegerField(default=2026), + ), + migrations.CreateModel( + name='Thesis', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=10, unique=True)), + ('name', models.CharField(max_length=100)), + ('credit', models.PositiveIntegerField(default=0)), + ('programme_type', models.CharField(choices=[('PG', 'Postgraduate'), ('PHD', 'Doctor of Philosophy')], max_length=3)), + ('working_thesis', models.BooleanField(default=True)), + ('discipline', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.discipline')), + ], + options={ + 'unique_together': {('code', 'discipline')}, + }, + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0034_progressseminar.py b/FusionIIIT/applications/programme_curriculum/migrations/0034_progressseminar.py new file mode 100644 index 000000000..ec939cc29 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0034_progressseminar.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.5 on 2026-02-11 15:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0033_auto_20260210_1723'), + ] + + operations = [ + migrations.CreateModel( + name='ProgressSeminar', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=10, unique=True)), + ('name', models.CharField(max_length=100)), + ('credit', models.PositiveIntegerField(default=0)), + ('programme_type', models.CharField(choices=[('PG', 'Postgraduate'), ('PHD', 'Doctor of Philosophy')], max_length=3)), + ('working_progress_seminar', models.BooleanField(default=True)), + ('discipline', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.discipline')), + ], + options={ + 'unique_together': {('code', 'discipline')}, + }, + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0035_progressseminarslot_thesisslot.py b/FusionIIIT/applications/programme_curriculum/migrations/0035_progressseminarslot_thesisslot.py new file mode 100644 index 000000000..f46b5fa9a --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0035_progressseminarslot_thesisslot.py @@ -0,0 +1,48 @@ +# Generated by Django 3.1.5 on 2026-02-11 16:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0034_progressseminar'), + ] + + operations = [ + migrations.CreateModel( + name='ThesisSlot', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('type', models.CharField(choices=[('Professional Core', 'Professional Core'), ('Professional Elective', 'Professional Elective'), ('Professional Lab', 'Professional Lab'), ('Engineering Science', 'Engineering Science'), ('Natural Science', 'Natural Science'), ('Humanities', 'Humanities'), ('Design', 'Design'), ('Manufacturing', 'Manufacturing'), ('Management Science', 'Management Science'), ('Open Elective', 'Open Elective'), ('Swayam', 'Swayam'), ('Project', 'Project'), ('Optional', 'Optional'), ('Backlog', 'Backlog'), ('Others', 'Others')], max_length=70)), + ('thesis_slot_info', models.TextField(blank=True, null=True)), + ('duration', models.PositiveIntegerField(default=1)), + ('min_registration_limit', models.PositiveIntegerField(default=0)), + ('max_registration_limit', models.PositiveIntegerField(default=1000)), + ('semester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.semester')), + ('theses', models.ManyToManyField(blank=True, to='programme_curriculum.Thesis')), + ], + options={ + 'unique_together': {('semester', 'name', 'type')}, + }, + ), + migrations.CreateModel( + name='ProgressSeminarSlot', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('type', models.CharField(choices=[('Professional Core', 'Professional Core'), ('Professional Elective', 'Professional Elective'), ('Professional Lab', 'Professional Lab'), ('Engineering Science', 'Engineering Science'), ('Natural Science', 'Natural Science'), ('Humanities', 'Humanities'), ('Design', 'Design'), ('Manufacturing', 'Manufacturing'), ('Management Science', 'Management Science'), ('Open Elective', 'Open Elective'), ('Swayam', 'Swayam'), ('Project', 'Project'), ('Optional', 'Optional'), ('Backlog', 'Backlog'), ('Others', 'Others')], max_length=70)), + ('progress_seminar_slot_info', models.TextField(blank=True, null=True)), + ('duration', models.PositiveIntegerField(default=1)), + ('min_registration_limit', models.PositiveIntegerField(default=0)), + ('max_registration_limit', models.PositiveIntegerField(default=1000)), + ('progress_seminars', models.ManyToManyField(blank=True, to='programme_curriculum.ProgressSeminar')), + ('semester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.semester')), + ], + options={ + 'unique_together': {('semester', 'name', 'type')}, + }, + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0036_phd_student_batch_upload.py b/FusionIIIT/applications/programme_curriculum/migrations/0036_phd_student_batch_upload.py new file mode 100644 index 000000000..99deb34bb --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0036_phd_student_batch_upload.py @@ -0,0 +1,200 @@ +from django.conf import settings +from django.db import migrations, models +import applications.programme_curriculum.models_student_management +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0035_progressseminarslot_thesisslot'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PhdStudentBatchUpload', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # Core identification + ('application_no', models.CharField( + blank=True, max_length=50, null=True, unique=True, + help_text='PhD Application Number (col: Application No.)' + )), + ('roll_number', models.CharField( + blank=True, max_length=20, null=True, unique=True, + help_text='Institute Roll Number' + )), + ('institute_email', models.EmailField( + blank=True, max_length=254, null=True, + help_text='Institute Email ID' + )), + # Personal + ('name', models.CharField(max_length=200, help_text='Full Name')), + ('discipline', models.CharField(max_length=200, help_text='Discipline / Branch')), + ('admission_type', models.CharField( + blank=True, max_length=100, null=True, + choices=[ + ('FULL TIME with Institute Assistantship', 'FULL TIME with Institute Assistantship'), + ('FULL TIME with Govt. / Semi Govt. Fellowship Award', 'FULL TIME with Govt. / Semi Govt. Fellowship Award'), + ('FULL TIME Self Financed', 'FULL TIME Self Financed'), + ('PART TIME (External)', 'PART TIME (External)'), + ('QIP', 'QIP'), + ('Any other (remarks)', 'Any other (remarks)'), + ], + help_text='Admission Type' + )), + ('gender', models.CharField( + max_length=10, + choices=[('Male', 'Male'), ('Female', 'Female'), ('Other', 'Other')] + )), + ('category', models.CharField( + max_length=10, + choices=[ + ('GEN', 'General'), ('OBC', 'Other Backward Class'), + ('SC', 'Scheduled Caste'), ('ST', 'Scheduled Tribe'), + ('EWS', 'Economically Weaker Section'), + ] + )), + ('minority', models.TextField(blank=True, null=True, help_text='Minority Status')), + ('pwd', models.CharField( + default='NO', max_length=3, + choices=[('YES', 'Yes'), ('NO', 'No')] + )), + ('pwd_category', models.CharField( + blank=True, max_length=100, null=True, + choices=[ + ('Locomotor Disability', 'Locomotor Disability'), + ('Visual Impairment', 'Visual Impairment'), + ('Hearing Impairment', 'Hearing Impairment'), + ('Speech and Language Disability', 'Speech and Language Disability'), + ('Intellectual Disability', 'Intellectual Disability'), + ('Autism Spectrum Disorder', 'Autism Spectrum Disorder'), + ('Multiple Disabilities', 'Multiple Disabilities'), + ('Any other (remarks)', 'Any other (remarks)'), + ] + )), + ('pwd_category_remarks', models.TextField(blank=True, null=True)), + # Contact + ('phone_number', models.CharField(blank=True, max_length=15, null=True)), + ('personal_email', models.EmailField(blank=True, max_length=254, null=True)), + ('parent_email', models.EmailField(blank=True, max_length=254, null=True)), + ('address', models.TextField(blank=True, null=True, help_text='Full Address (with pincode)')), + ('state', models.CharField(blank=True, max_length=100, null=True)), + # Family + ('father_name', models.CharField(blank=True, max_length=200, null=True)), + ('father_occupation', models.CharField(blank=True, max_length=200, null=True)), + ('father_mobile', models.CharField(blank=True, max_length=15, null=True)), + ('mother_name', models.CharField(blank=True, max_length=200, null=True)), + ('mother_occupation', models.CharField(blank=True, max_length=200, null=True)), + ('mother_mobile', models.CharField(blank=True, max_length=15, null=True)), + # Personal details + ('date_of_birth', models.DateField(blank=True, null=True)), + ('blood_group', models.CharField( + blank=True, max_length=10, null=True, + choices=[ + ('A+', 'A+'), ('A-', 'A-'), ('B+', 'B+'), ('B-', 'B-'), + ('AB+', 'AB+'), ('AB-', 'AB-'), ('O+', 'O+'), ('O-', 'O-'), + ('Other', 'Other'), + ] + )), + ('blood_group_remarks', models.TextField(blank=True, null=True)), + ('country', models.CharField(blank=True, default='India', max_length=100, null=True)), + ('nationality', models.CharField(blank=True, default='Indian', max_length=100, null=True)), + # Admission details + ('admission_mode', models.CharField( + blank=True, max_length=50, null=True, + choices=[ + ('Institute Level', 'Institute Level'), ('QIP', 'QIP'), + ('GATE', 'GATE'), ('Sponsored', 'Sponsored'), + ('Foreign National', 'Foreign National'), + ('Any other (remarks)', 'Any other (remarks)'), + ] + )), + ('admission_mode_remarks', models.TextField(blank=True, null=True)), + ('income_group', models.CharField( + blank=True, max_length=30, null=True, + choices=[ + ('Below 1 Lakh', 'Below 1 Lakh'), + ('Between 1 to 4 Lakh', 'Between 1 to 4 Lakh'), + ('Between 4 to 6 Lakh', 'Between 4 to 6 Lakh'), + ('Above 6 Lakh', 'Above 6 Lakh'), + ] + )), + ('income', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('allotted_category', models.CharField(blank=True, max_length=50, null=True)), + # PhD / GATE specific + ('gate_qualified', models.CharField( + blank=True, max_length=3, null=True, + choices=[('YES', 'Yes'), ('NO', 'No')], + help_text='GATE Qualified' + )), + ('gate_stream', models.CharField(blank=True, max_length=100, null=True)), + ('gate_rank', models.IntegerField(blank=True, null=True)), + # System / batch tracking + ('admission_semester', models.CharField( + blank=True, max_length=10, null=True, + choices=[('Odd', 'Odd Semester'), ('Even', 'Even Semester')] + )), + ('year', models.IntegerField( + db_column='batch_year', + default=applications.programme_curriculum.models_student_management.get_current_academic_year, + help_text='Admission batch year (e.g., 2025)' + )), + ('academic_year', models.CharField(blank=True, max_length=20)), + ('reported_status', models.CharField( + default='NOT_REPORTED', max_length=20, + choices=[ + ('NOT_REPORTED', 'Not Reported'), + ('REPORTED', 'Reported'), + ('WITHDRAWAL', 'Withdrawal'), + ] + )), + ('source', models.CharField(default='admin_upload', max_length=50)), + # Auth / metadata + ('user', models.ForeignKey( + blank=True, db_column='user_account_id', null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='phd_student_profile', + to=settings.AUTH_USER_MODEL + )), + ('uploaded_by', models.ForeignKey( + blank=True, db_column='created_by_id', null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='uploaded_phd_students', + to=settings.AUTH_USER_MODEL + )), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'PhD Student Batch Upload', + 'verbose_name_plural': 'PhD Student Batch Uploads', + 'ordering': ['roll_number', 'name'], + }, + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['year'], name='phd_stud_year_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['discipline'], name='phd_stud_disc_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['reported_status'], name='phd_stud_status_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['admission_semester'], name='phd_stud_sem_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['application_no'], name='phd_stud_appno_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['roll_number'], name='phd_stud_roll_idx'), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0037_phd_student_missing_fields.py b/FusionIIIT/applications/programme_curriculum/migrations/0037_phd_student_missing_fields.py new file mode 100644 index 000000000..c529803fd --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0037_phd_student_missing_fields.py @@ -0,0 +1,64 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + Adds fields present in StudentBatchUpload that were missing from PhdStudentBatchUpload: + - allotted_gender + - aadhar_number + - category_rank + - allocation_status + - email_password + - password_email_sent + - password_generated_at + """ + + dependencies = [ + ('programme_curriculum', '0036_phd_student_batch_upload'), + ] + + operations = [ + # Allotment tracking + migrations.AddField( + model_name='phdstudentbatchupload', + name='allotted_gender', + field=models.CharField(blank=True, max_length=50, null=True, help_text='Allotted Gender'), + ), + # Identity + migrations.AddField( + model_name='phdstudentbatchupload', + name='aadhar_number', + field=models.CharField(blank=True, max_length=12, null=True, help_text='Aadhaar Number (12 digits)'), + ), + # Rank + migrations.AddField( + model_name='phdstudentbatchupload', + name='category_rank', + field=models.IntegerField(blank=True, null=True, help_text='Category Rank in admission (GATE category rank or equivalent)'), + ), + # Allocation state + migrations.AddField( + model_name='phdstudentbatchupload', + name='allocation_status', + field=models.CharField(default='ALLOCATED', max_length=50, help_text='Allocation Status (e.g., ALLOCATED, PENDING)'), + ), + # Password / email notification workflow + migrations.AddField( + model_name='phdstudentbatchupload', + name='email_password', + field=models.CharField( + blank=True, max_length=50, null=True, + help_text='Temporary plain-text password storage for email notification (cleared after sending)', + ), + ), + migrations.AddField( + model_name='phdstudentbatchupload', + name='password_email_sent', + field=models.BooleanField(default=False, help_text='Whether the password email has been sent to the student'), + ), + migrations.AddField( + model_name='phdstudentbatchupload', + name='password_generated_at', + field=models.DateTimeField(blank=True, null=True, help_text='Timestamp when the password was generated'), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0038_thesis_registration_models.py b/FusionIIIT/applications/programme_curriculum/migrations/0038_thesis_registration_models.py new file mode 100644 index 000000000..afca966a9 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0038_thesis_registration_models.py @@ -0,0 +1,141 @@ +# Generated by Django 3.1.5 on 2026-03-06 11:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0037_phd_student_missing_fields'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='phdstudentbatchupload', + name='phd_stud_year_idx', + ), + migrations.RemoveIndex( + model_name='phdstudentbatchupload', + name='phd_stud_disc_idx', + ), + migrations.RemoveIndex( + model_name='phdstudentbatchupload', + name='phd_stud_status_idx', + ), + migrations.RemoveIndex( + model_name='phdstudentbatchupload', + name='phd_stud_sem_idx', + ), + migrations.RemoveIndex( + model_name='phdstudentbatchupload', + name='phd_stud_appno_idx', + ), + migrations.RemoveIndex( + model_name='phdstudentbatchupload', + name='phd_stud_roll_idx', + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='academic_year', + field=models.CharField(blank=True, help_text='Academic Year String (e.g., 2025-26)', max_length=20), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='admission_semester', + field=models.CharField(blank=True, choices=[('Odd', 'Odd Semester'), ('Even', 'Even Semester')], help_text='Semester of PhD admission: Odd or Even', max_length=10, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='admission_type', + field=models.CharField(blank=True, choices=[('FULL TIME with Institute Assistantship', 'FULL TIME with Institute Assistantship'), ('FULL TIME with Govt. / Semi Govt. Fellowship Award', 'FULL TIME with Govt. / Semi Govt. Fellowship Award'), ('FULL TIME Self Financed', 'FULL TIME Self Financed'), ('PART TIME (External)', 'PART TIME (External)'), ('QIP', 'QIP'), ('Any other (remarks)', 'Any other (remarks)')], help_text='Admission Type (col: Admission Type)', max_length=100, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='allotted_category', + field=models.CharField(blank=True, help_text='allottedcat', max_length=50, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='discipline', + field=models.CharField(help_text='Discipline / Branch (col: Discipline)', max_length=200), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='gate_qualified', + field=models.CharField(blank=True, choices=[('YES', 'Yes'), ('NO', 'No')], help_text='GATE Qualified (col: GATE Qualaified)', max_length=3, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='gate_rank', + field=models.IntegerField(blank=True, help_text='GATE Rank (col: GATE Rank)', null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='gate_stream', + field=models.CharField(blank=True, help_text='GATE Stream (col: GATE Stream)', max_length=100, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='institute_email', + field=models.EmailField(blank=True, help_text='Institute Email ID (col: Institute Email ID)', max_length=254, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='minority', + field=models.TextField(blank=True, help_text='Minority Status (col: Minority)', null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='name', + field=models.CharField(help_text='Full Name (col: Name)', max_length=200), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='parent_email', + field=models.EmailField(blank=True, help_text='Parent Email', max_length=254, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='personal_email', + field=models.EmailField(blank=True, help_text='Alternate Email ID', max_length=254, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='phone_number', + field=models.CharField(blank=True, help_text='MobileNo', max_length=15, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='roll_number', + field=models.CharField(blank=True, help_text='Institute Roll Number (col: Institute Roll Number)', max_length=20, null=True, unique=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='source', + field=models.CharField(default='admin_upload', help_text='Source of data: excel_upload, manual_entry, etc.', max_length=50), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['year'], name='programme_c_batch_y_c985fe_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['discipline'], name='programme_c_discipl_72ce54_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['reported_status'], name='programme_c_reporte_7022dd_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['admission_semester'], name='programme_c_admissi_a286bd_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['application_no'], name='programme_c_applica_e9ceaf_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['roll_number'], name='programme_c_roll_nu_64bfc6_idx'), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0039_auto_20260306_1157.py b/FusionIIIT/applications/programme_curriculum/migrations/0039_auto_20260306_1157.py new file mode 100644 index 000000000..ac76bfbf0 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0039_auto_20260306_1157.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.5 on 2026-03-06 11:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0038_thesis_registration_models'), + ] + + operations = [ + migrations.AlterField( + model_name='progressseminarslot', + name='type', + field=models.CharField(choices=[('Thesis', 'Thesis'), ('Progress Seminar', 'Progress Seminar'), ('Teaching Credit', 'Teaching Credit')], max_length=70), + ), + migrations.AlterField( + model_name='thesisslot', + name='type', + field=models.CharField(choices=[('Thesis', 'Thesis'), ('Progress Seminar', 'Progress Seminar'), ('Teaching Credit', 'Teaching Credit')], max_length=70), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0040_remove_type_from_thesis_and_ps_slots.py b/FusionIIIT/applications/programme_curriculum/migrations/0040_remove_type_from_thesis_and_ps_slots.py new file mode 100644 index 000000000..877367256 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0040_remove_type_from_thesis_and_ps_slots.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.5 on 2026-03-06 12:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0039_auto_20260306_1157'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='progressseminarslot', + unique_together={('semester', 'name')}, + ), + migrations.AlterUniqueTogether( + name='thesisslot', + unique_together={('semester', 'name')}, + ), + migrations.RemoveField( + model_name='progressseminarslot', + name='type', + ), + migrations.RemoveField( + model_name='thesisslot', + name='type', + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0041_merge_20260314_1604.py b/FusionIIIT/applications/programme_curriculum/migrations/0041_merge_20260314_1604.py new file mode 100644 index 000000000..e7aff6a66 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0041_merge_20260314_1604.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-03-14 16:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0032_auto_20260210_1820'), + ('programme_curriculum', '0040_remove_type_from_thesis_and_ps_slots'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0042_auto_20260410_1454.py b/FusionIIIT/applications/programme_curriculum/migrations/0042_auto_20260410_1454.py new file mode 100644 index 000000000..4e93a8e87 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0042_auto_20260410_1454.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-04-10 14:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0041_merge_20260314_1604'), + ] + + operations = [ + migrations.AlterField( + model_name='batch', + name='name', + field=models.CharField(choices=[('B.Tech', 'B.Tech'), ('M.Tech', 'M.Tech'), ('M.Tech AI & ML', 'M.Tech AI & ML'), ('M.Tech Data Science', 'M.Tech Data Science'), ('M.Tech Communication and Signal Processing', 'M.Tech Communication and Signal Processing'), ('M.Tech Nanoelectronics and VLSI Design', 'M.Tech Nanoelectronics and VLSI Design'), ('M.Tech Power & Control', 'M.Tech Power & Control'), ('M.Tech Design', 'M.Tech Design'), ('M.Tech CAD/CAM', 'M.Tech CAD/CAM'), ('M.Tech Manufacturing and Automation', 'M.Tech Manufacturing and Automation'), ('B.Des', 'B.Des'), ('M.Des', 'M.Des'), ('PhD (Odd)', 'PhD (Odd)'), ('PhD (Even)', 'PhD (Even)')], max_length=50), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0043_courseauditlog_json_encoder.py b/FusionIIIT/applications/programme_curriculum/migrations/0043_courseauditlog_json_encoder.py new file mode 100644 index 000000000..1b0075a58 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0043_courseauditlog_json_encoder.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.5 on 2026-07-09 10:47 + +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0042_auto_20260410_1454'), + ] + + operations = [ + migrations.AlterField( + model_name='courseauditlog', + name='new_values', + field=models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), + ), + migrations.AlterField( + model_name='courseauditlog', + name='old_values', + field=models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/models.py b/FusionIIIT/applications/programme_curriculum/models.py index 54276ba6e..8d2c57d27 100644 --- a/FusionIIIT/applications/programme_curriculum/models.py +++ b/FusionIIIT/applications/programme_curriculum/models.py @@ -2,6 +2,7 @@ from django import forms import datetime import json +from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone from django.db.models.fields import IntegerField, PositiveIntegerField from django.db.models import CheckConstraint, Q, F @@ -50,7 +51,8 @@ ('M.Tech Manufacturing and Automation', 'M.Tech Manufacturing and Automation'), ('B.Des', 'B.Des'), ('M.Des', 'M.Des'), - ('Phd', 'Phd') + ('PhD (Odd)', 'PhD (Odd)'), + ('PhD (Even)', 'PhD (Even)') ] VERSION_BUMP_CHOICES = [ @@ -71,8 +73,8 @@ class CourseAuditLog(models.Model): ('UPDATE', 'Update'), ('DELETE', 'Delete') ], default='UPDATE') - old_values = models.JSONField(null=True, blank=True) # Store old field values - new_values = models.JSONField(null=True, blank=True) # Store new field values + old_values = models.JSONField(null=True, blank=True, encoder=DjangoJSONEncoder) # Store old field values + new_values = models.JSONField(null=True, blank=True, encoder=DjangoJSONEncoder) # Store new field values changed_fields = models.JSONField(default=list) # List of field names that changed version_bump_type = models.CharField(max_length=10, choices=VERSION_BUMP_CHOICES, default='NONE') old_version = models.DecimalField(max_digits=5, decimal_places=1, null=True, blank=True) @@ -228,6 +230,50 @@ def courseslots(self): return CourseSlot.objects.filter(courses=self.id) +class Thesis(models.Model): + """Store thesis details for PhD and M.Tech programmes""" + + code = models.CharField(max_length=10, null=False, blank=False, unique=True) + name = models.CharField(max_length=100, null=False, blank=False) + credit = models.PositiveIntegerField(default=0, null=False, blank=False) + discipline = models.ForeignKey(Discipline, on_delete=models.CASCADE, null=False) + programme_type = models.CharField( + max_length=3, + choices=[('PG', 'Postgraduate'), ('PHD', 'Doctor of Philosophy')], + null=False, + blank=False + ) + working_thesis = models.BooleanField(default=True) + + class Meta: + unique_together = ('code', 'discipline') + + def __str__(self): + return f"{self.code} - {self.name} ({self.discipline.acronym})" + + +class ProgressSeminar(models.Model): + """Store progress seminar details for PhD and M.Tech programmes""" + + code = models.CharField(max_length=10, null=False, blank=False, unique=True) + name = models.CharField(max_length=100, null=False, blank=False) + credit = models.PositiveIntegerField(default=0, null=False, blank=False) + discipline = models.ForeignKey(Discipline, on_delete=models.CASCADE, null=False) + programme_type = models.CharField( + max_length=3, + choices=[('PG', 'Postgraduate'), ('PHD', 'Doctor of Philosophy')], + null=False, + blank=False + ) + working_progress_seminar = models.BooleanField(default=True) + + class Meta: + unique_together = ('code', 'discipline') + + def __str__(self): + return f"{self.code} - {self.name} ({self.discipline.acronym})" + + class Batch(models.Model): """Store batch details""" @@ -275,6 +321,52 @@ def for_batches(self): return ((Semester.objects.get(id=self.semester.id)).curriculum).batches +class ThesisSlot(models.Model): + """Store thesis slot details for a semester""" + + semester = models.ForeignKey( + Semester, null=False, on_delete=models.CASCADE) + name = models.CharField(max_length=100, null=False, blank=False) + thesis_slot_info = models.TextField(null=True, blank=True) + theses = models.ManyToManyField(Thesis, blank=True) + duration = models.PositiveIntegerField(default=1) + min_registration_limit = models.PositiveIntegerField(default=0) + max_registration_limit = models.PositiveIntegerField(default=1000) + + def __str__(self): + return str(Semester.__str__(self.semester) + ", " + self.name) + + class Meta: + unique_together = ('semester', 'name') + + @property + def for_batches(self): + return ((Semester.objects.get(id=self.semester.id)).curriculum).batches + + +class ProgressSeminarSlot(models.Model): + """Store progress seminar slot details for a semester""" + + semester = models.ForeignKey( + Semester, null=False, on_delete=models.CASCADE) + name = models.CharField(max_length=100, null=False, blank=False) + progress_seminar_slot_info = models.TextField(null=True, blank=True) + progress_seminars = models.ManyToManyField(ProgressSeminar, blank=True) + duration = models.PositiveIntegerField(default=1) + min_registration_limit = models.PositiveIntegerField(default=0) + max_registration_limit = models.PositiveIntegerField(default=1000) + + def __str__(self): + return str(Semester.__str__(self.semester) + ", " + self.name) + + class Meta: + unique_together = ('semester', 'name') + + @property + def for_batches(self): + return ((Semester.objects.get(id=self.semester.id)).curriculum).batches + + class CourseInstructor(models.Model): course_id = models.ForeignKey(Course, on_delete=models.CASCADE) instructor_id = models.ForeignKey(Faculty, on_delete=models.CASCADE) diff --git a/FusionIIIT/applications/programme_curriculum/models_student_management.py b/FusionIIIT/applications/programme_curriculum/models_student_management.py index ba95d853c..44189f880 100644 --- a/FusionIIIT/applications/programme_curriculum/models_student_management.py +++ b/FusionIIIT/applications/programme_curriculum/models_student_management.py @@ -215,6 +215,15 @@ class StudentBatchUpload(models.Model): programme_type = models.CharField(max_length=10, choices=PROGRAMME_TYPE_CHOICES) allocation_status = models.CharField(max_length=50, default='ALLOCATED', help_text="Allocation Status") reported_status = models.CharField(max_length=20, choices=REPORTED_STATUS_CHOICES, default='NOT_REPORTED') + + # PhD-specific fields + admission_semester = models.CharField( + max_length=10, + blank=True, + null=True, + choices=[('Odd', 'Odd Semester'), ('Even', 'Even Semester')], + help_text="For PhD students: Semester in which admission occurred (becomes their Semester 1)" + ) # Source tracking - Track where the student data came from source = models.CharField( @@ -543,6 +552,362 @@ def __str__(self): return f"{self.student.name} - {self.old_reported_status} → {self.new_reported_status} at {self.created_at}" +class PhdStudentBatchUpload(models.Model): + """ + Model to store PhD student data uploaded via Excel or manual entry. + Maps directly to the PhD Student Data Template Excel sheet columns. + PhD-specific fields: application_no, admission_type, gate_qualified, gate_stream, gate_rank. + """ + + GENDER_CHOICES = [ + ('Male', 'Male'), + ('Female', 'Female'), + ('Other', 'Other'), + ] + + CATEGORY_CHOICES = [ + ('GEN', 'General'), + ('OBC', 'Other Backward Class'), + ('SC', 'Scheduled Caste'), + ('ST', 'Scheduled Tribe'), + ('EWS', 'Economically Weaker Section'), + ] + + PWD_CHOICES = [ + ('YES', 'Yes'), + ('NO', 'No'), + ] + + PWD_CATEGORY_CHOICES = [ + ('Locomotor Disability', 'Locomotor Disability'), + ('Visual Impairment', 'Visual Impairment'), + ('Hearing Impairment', 'Hearing Impairment'), + ('Speech and Language Disability', 'Speech and Language Disability'), + ('Intellectual Disability', 'Intellectual Disability'), + ('Autism Spectrum Disorder', 'Autism Spectrum Disorder'), + ('Multiple Disabilities', 'Multiple Disabilities'), + ('Any other (remarks)', 'Any other (remarks)'), + ] + + BLOOD_GROUP_CHOICES = [ + ('A+', 'A+'), ('A-', 'A-'), + ('B+', 'B+'), ('B-', 'B-'), + ('AB+', 'AB+'), ('AB-', 'AB-'), + ('O+', 'O+'), ('O-', 'O-'), + ('Other', 'Other'), + ] + + ADMISSION_MODE_CHOICES = [ + ('Institute Level', 'Institute Level'), + ('QIP', 'QIP'), + ('GATE', 'GATE'), + ('Sponsored', 'Sponsored'), + ('Foreign National', 'Foreign National'), + ('Any other (remarks)', 'Any other (remarks)'), + ] + + ADMISSION_TYPE_CHOICES = [ + ('FULL TIME with Institute Assistantship', 'FULL TIME with Institute Assistantship'), + ('FULL TIME with Govt. / Semi Govt. Fellowship Award', 'FULL TIME with Govt. / Semi Govt. Fellowship Award'), + ('FULL TIME Self Financed', 'FULL TIME Self Financed'), + ('PART TIME (External)', 'PART TIME (External)'), + ('QIP', 'QIP'), + ('Any other (remarks)', 'Any other (remarks)'), + ] + + INCOME_GROUP_CHOICES = [ + ('Below 1 Lakh', 'Below 1 Lakh'), + ('Between 1 to 4 Lakh', 'Between 1 to 4 Lakh'), + ('Between 4 to 6 Lakh', 'Between 4 to 6 Lakh'), + ('Above 6 Lakh', 'Above 6 Lakh'), + ] + + REPORTED_STATUS_CHOICES = [ + ('NOT_REPORTED', 'Not Reported'), + ('REPORTED', 'Reported'), + ('WITHDRAWAL', 'Withdrawal'), + ] + + ADMISSION_SEMESTER_CHOICES = [ + ('Odd', 'Odd Semester'), + ('Even', 'Even Semester'), + ] + + GATE_QUALIFIED_CHOICES = [ + ('YES', 'Yes'), + ('NO', 'No'), + ] + + # ---- Core identification (col 2, 3) ---- + application_no = models.CharField( + max_length=50, unique=True, blank=True, null=True, + help_text="PhD Application Number (col: Application No.)" + ) + roll_number = models.CharField( + max_length=20, unique=True, blank=True, null=True, + help_text="Institute Roll Number (col: Institute Roll Number)" + ) + institute_email = models.EmailField( + blank=True, null=True, + help_text="Institute Email ID (col: Institute Email ID)" + ) + + # ---- Personal information (col 4, 6, 7, 8, 9, 10, 11, 12) ---- + name = models.CharField(max_length=200, help_text="Full Name (col: Name)") + discipline = models.CharField(max_length=200, help_text="Discipline / Branch (col: Discipline)") + admission_type = models.CharField( + max_length=100, choices=ADMISSION_TYPE_CHOICES, blank=True, null=True, + help_text="Admission Type (col: Admission Type)" + ) + gender = models.CharField(max_length=10, choices=GENDER_CHOICES) + category = models.CharField(max_length=10, choices=CATEGORY_CHOICES) + minority = models.TextField( + blank=True, null=True, + help_text="Minority Status (col: Minority)" + ) + pwd = models.CharField(max_length=3, choices=PWD_CHOICES, default='NO') + pwd_category = models.CharField( + max_length=100, choices=PWD_CATEGORY_CHOICES, blank=True, null=True + ) + pwd_category_remarks = models.TextField(blank=True, null=True) + + # ---- Contact and address (col 13-16, 36, 37) ---- + phone_number = models.CharField(max_length=15, blank=True, null=True, help_text="MobileNo") + personal_email = models.EmailField(blank=True, null=True, help_text="Alternate Email ID") + parent_email = models.EmailField(blank=True, null=True, help_text="Parent Email") + address = models.TextField(blank=True, null=True, help_text="Full Address (with pincode)") + state = models.CharField(max_length=100, blank=True, null=True) + + # ---- Family information (col 17-22) ---- + father_name = models.CharField(max_length=200, blank=True, null=True) + father_occupation = models.CharField(max_length=200, blank=True, null=True) + father_mobile = models.CharField(max_length=15, blank=True, null=True) + mother_name = models.CharField(max_length=200, blank=True, null=True) + mother_occupation = models.CharField(max_length=200, blank=True, null=True) + mother_mobile = models.CharField(max_length=15, blank=True, null=True) + + # ---- Personal details (col 23-27) ---- + date_of_birth = models.DateField(blank=True, null=True) + blood_group = models.CharField(max_length=10, choices=BLOOD_GROUP_CHOICES, blank=True, null=True) + blood_group_remarks = models.TextField(blank=True, null=True) + country = models.CharField(max_length=100, blank=True, null=True, default='India') + nationality = models.CharField(max_length=100, blank=True, null=True, default='Indian') + + # ---- Admission details (col 28-31, 35) ---- + admission_mode = models.CharField( + max_length=50, choices=ADMISSION_MODE_CHOICES, blank=True, null=True + ) + admission_mode_remarks = models.TextField(blank=True, null=True) + income_group = models.CharField( + max_length=30, choices=INCOME_GROUP_CHOICES, blank=True, null=True + ) + income = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True) + allotted_category = models.CharField(max_length=50, blank=True, null=True, help_text="allottedcat") + allotted_gender = models.CharField(max_length=50, blank=True, null=True, help_text="Allotted Gender") + + # ---- PhD / GATE specific fields (col 32-34) ---- + gate_qualified = models.CharField( + max_length=3, choices=GATE_QUALIFIED_CHOICES, blank=True, null=True, + help_text="GATE Qualified (col: GATE Qualaified)" + ) + gate_stream = models.CharField( + max_length=100, blank=True, null=True, + help_text="GATE Stream (col: GATE Stream)" + ) + gate_rank = models.IntegerField( + blank=True, null=True, + help_text="GATE Rank (col: GATE Rank)" + ) + category_rank = models.IntegerField( + blank=True, null=True, + help_text="Category Rank in admission (GATE category rank or equivalent)" + ) + aadhar_number = models.CharField( + max_length=12, blank=True, null=True, + help_text="Aadhaar Number (12 digits)" + ) + + # ---- System / batch tracking fields ---- + admission_semester = models.CharField( + max_length=10, blank=True, null=True, + choices=ADMISSION_SEMESTER_CHOICES, + help_text="Semester of PhD admission: Odd or Even" + ) + year = models.IntegerField( + help_text="Admission batch year (e.g., 2025)", + db_column='batch_year', + default=get_current_academic_year + ) + academic_year = models.CharField( + max_length=20, blank=True, + help_text="Academic Year String (e.g., 2025-26)" + ) + reported_status = models.CharField( + max_length=20, choices=REPORTED_STATUS_CHOICES, default='NOT_REPORTED' + ) + allocation_status = models.CharField( + max_length=50, default='ALLOCATED', + help_text="Allocation Status (e.g., ALLOCATED, PENDING)" + ) + source = models.CharField( + max_length=50, default='admin_upload', + help_text="Source of data: excel_upload, manual_entry, etc." + ) + + # ---- Authentication link ---- + user = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + related_name='phd_student_profile', db_column='user_account_id' + ) + + # ---- Email / password notification fields ---- + email_password = models.CharField( + max_length=50, blank=True, null=True, + help_text="Temporary plain-text password storage for email notification (cleared after sending)" + ) + password_email_sent = models.BooleanField( + default=False, + help_text="Whether the password email has been sent to the student" + ) + password_generated_at = models.DateTimeField( + blank=True, null=True, + help_text="Timestamp when the password was generated" + ) + + # ---- Metadata ---- + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + uploaded_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + related_name='uploaded_phd_students', db_column='created_by_id' + ) + + class Meta: + verbose_name = 'PhD Student Batch Upload' + verbose_name_plural = 'PhD Student Batch Uploads' + ordering = ['roll_number', 'name'] + indexes = [ + models.Index(fields=['year']), + models.Index(fields=['discipline']), + models.Index(fields=['reported_status']), + models.Index(fields=['admission_semester']), + models.Index(fields=['application_no']), + models.Index(fields=['roll_number']), + ] + + def __str__(self): + return f"{self.name} ({self.roll_number or self.application_no})" + + # ------------------------------------------------------------------ + # Compatibility properties + # These allow code written for StudentBatchUpload to work transparently + # with PhdStudentBatchUpload without changes throughout the codebase. + # ------------------------------------------------------------------ + @property + def branch(self): + """PhD stores discipline; expose as 'branch' for backward compat.""" + return self.discipline + + @property + def specialization(self): + """PhD students have no specialization.""" + return '' + + @property + def programme_type(self): + """PhD model always represents PhD students.""" + return 'phd' + + @property + def jee_app_no(self): + """PhD students use application_no; expose as jee_app_no for backward compat.""" + return self.application_no + + @staticmethod + def generate_secure_password(length=12): + """Generate cryptographically secure password""" + import secrets + import string as _string + lowercase = _string.ascii_lowercase + uppercase = _string.ascii_uppercase + digits = _string.digits + special = "!@#$%" + password = [ + secrets.choice(lowercase), + secrets.choice(uppercase), + secrets.choice(digits), + secrets.choice(special), + ] + all_chars = lowercase + uppercase + digits + special + for _ in range(length - 4): + password.append(secrets.choice(all_chars)) + secrets.SystemRandom().shuffle(password) + return ''.join(password) + + def create_user_account(self, password=None): + """Create Django User account for this PhD student.""" + from django.contrib.auth.models import User as DjangoUser + from django.db import IntegrityError + from django.utils import timezone + + if self.user: + return self.user, None + + if not password: + password = self.generate_secure_password() + + username = self.roll_number or self.application_no + email = (self.institute_email or self.personal_email or '').lower() + + try: + existing_user = DjangoUser.objects.filter(username=username).first() + if existing_user: + self.user = existing_user + self.save() + return existing_user, None + + user = DjangoUser.objects.create_user( + username=username, + email=email, + password=password, + first_name=self.name.split()[0] if self.name else '', + last_name=' '.join(self.name.split()[1:]) if self.name and len(self.name.split()) > 1 else '', + is_active=True, + ) + self.user = user + self.email_password = password + self.password_generated_at = timezone.now() + self.password_email_sent = False + self.save() + return user, password + + except IntegrityError as e: + if 'username' in str(e): + existing_user = DjangoUser.objects.filter(username=username).first() + if existing_user: + self.user = existing_user + self.save() + return existing_user, None + raise e + + def save(self, *args, **kwargs): + """Auto-set academic_year string and normalise emails.""" + if not self.academic_year: + year = self.year or get_current_academic_year() + next_year = (year + 1) % 100 + self.academic_year = f"{year}-{next_year:02d}" + + if self.roll_number and not self.institute_email: + self.institute_email = f"{self.roll_number.lower()}@iiitdmj.ac.in" + + for field in ('institute_email', 'personal_email', 'parent_email'): + val = getattr(self, field) + if val: + setattr(self, field, val.lower()) + + super().save(*args, **kwargs) + + class UploadHistory(models.Model): """ Model to track upload history and statistics @@ -642,7 +1007,9 @@ def create_student_profiles_automatically(students_list): 'programme': student.get_programme_name(), 'batch': student.year, 'cpi': 0.0, - 'category': student.category or 'General', + # Remap batch-model category values to AcademicStudent choices (GEN/SC/ST/OBC only) + 'category': {'GEN-EWS': 'GEN', 'OBC-NCL': 'OBC', 'EWS': 'GEN'}.get( + student.category or 'GEN', student.category or 'GEN'), 'father_name': student.father_name or '', 'mother_name': student.mother_name or '', 'hall_no': 0, @@ -689,3 +1056,26 @@ def auto_create_student_profile(sender, instance, created, **kwargs): post_save.connect(auto_create_student_profile, sender=StudentBatchUpload) except: pass + + +@receiver(post_save, sender=PhdStudentBatchUpload) +def auto_create_phd_student_profile(sender, instance, created, **kwargs): + """Automatically create PhD student profile when status changes to REPORTED. + + This is a safety-net signal — the primary path is update_student_status() in + views_student_management.py which handles REPORTED → ExtraInfo/AcademicStudent/ + HoldsDesignation propagation directly. This signal fires for any direct model + .save() calls (e.g. from admin panel or management commands). + """ + if instance.reported_status == 'REPORTED' and not instance.user: + try: + post_save.disconnect(auto_create_phd_student_profile, sender=PhdStudentBatchUpload) + try: + instance.create_user_account() + finally: + post_save.connect(auto_create_phd_student_profile, sender=PhdStudentBatchUpload) + except Exception as e: + try: + post_save.connect(auto_create_phd_student_profile, sender=PhdStudentBatchUpload) + except: + pass diff --git a/FusionIIIT/applications/programme_curriculum/signals.py b/FusionIIIT/applications/programme_curriculum/signals.py index 79411e6b7..9fedbf4e7 100644 --- a/FusionIIIT/applications/programme_curriculum/signals.py +++ b/FusionIIIT/applications/programme_curriculum/signals.py @@ -108,6 +108,9 @@ def create_academic_student(student_upload, extra_info): 'EWS': 'GEN', } category = category_mapping.get(student_upload.category, 'GEN') + specialization = 'None' + if batch and batch.discipline: + specialization = batch.discipline.name academic_student = Student.objects.create( id=extra_info, @@ -120,7 +123,7 @@ def create_academic_student(student_upload, extra_info): mother_name=student_upload.mother_name or '', hall_no=0, room_no='', - specialization='None', + specialization=specialization, curr_semester_no=1 ) @@ -187,12 +190,23 @@ def get_batch_for_student(student_upload): try: discipline = Discipline.objects.filter(name=discipline_name).first() if discipline: - batch = Batch.objects.filter( - discipline=discipline, - year=student_upload.year - ).first() - if batch: - return batch + # For PhD students, look for PhD-specific batch + if student_upload.programme == 'PHD': + batch = Batch.objects.filter( + discipline=discipline, + year=student_upload.year, + name__icontains='phd' + ).first() + if batch: + return batch + else: + # For UG/PG, find regular batch + batch = Batch.objects.filter( + discipline=discipline, + year=student_upload.year + ).exclude(name__icontains='phd').first() + if batch: + return batch except Exception as e: pass diff --git a/FusionIIIT/security_check.py b/FusionIIIT/security_check.py new file mode 100644 index 000000000..f5536e3c3 --- /dev/null +++ b/FusionIIIT/security_check.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Endpoint-auth regression guard for the in-production Fusion apps. + +Static analysis (no Django runtime needed). Run from the FusionIIIT dir: + + python3 security_check.py + +Exit code 0 = clean, 1 = a hard regression was found (suitable for CI / pre-commit). + +Invariants (each previously broke in this codebase): + + GUARD A (hard) A view-auth decorator (@login_required / @require_designation / + @role_required) must only sit on a *view* whose first parameter + is `request`. On a helper it does `firstarg.user` and 500s. + GUARD B (hard) A helper (first param != request) must NOT be routed as a URL + view -- helpers are internal and must stay unreachable over HTTP. + GUARD C (review) Every routed view should be gated: not AllowAny, and a plain + Django function view must carry an auth decorator. Printed for + review (a few cases are intentional public/read endpoints). +""" +import os +import re +import sys +import glob + +APPS = [ + "academic_information", "academic_procedures", + "examination", "online_cms", "programme_curriculum", +] +APP_DIR = "applications" +AUTH_DECORATORS = ("login_required", "require_designation", "role_required") +# In-body auth (the view authorizes inside its function body, not via a decorator). +INBODY_AUTH = ("_user_from_request", "user_holds_role", "user_holds_any_role", + "require_designation(", "role_required(") +# Endpoints that are public catalog reads BY DESIGN (unauthenticated twins exist). +PUBLIC_ALLOWLIST = { + "view_all_programmes", "view_all_working_curriculums", "view_all_courses", + "view_all_discplines", "view_all_batches", "view_a_course", "view_a_courseslot", + "view_a_semester_of_a_curriculum", "view_curriculums_of_a_programme", + "view_semesters_of_a_curriculum", "faculty_view_a_course", +} + +# A decorator header may be separated from its def/class by comment or blank +# lines (valid Python). Capture that whole header; comment lines are stripped +# later so a *commented-out* decorator is NOT mistaken for an active one. +HEADER = r'((?:^[ \t]*(?:@[^\n]*|#[^\n]*|)\n)*)' +DEF_RE = re.compile(HEADER + r'^def[ \t]+(\w+)[ \t]*\(([^)]*)', re.M) +CLASS_RE = re.compile(HEADER + r'^class[ \t]+(\w+)[ \t]*\(', re.M) + + +def active_decorators(header): + """Drop comment/blank lines so commented-out decorators don't count.""" + return "\n".join(l for l in header.splitlines() if l.lstrip().startswith("@")) +# capture the dotted view reference after the route pattern, e.g. views.foo or Bar.as_view +ROUTE_RE = re.compile(r"(?:path|url)\(\s*r?['\"][^'\"]*['\"]\s*,\s*([\w.]+)", re.M) + + +def view_files(app): + out = [] + for d in (f"{APP_DIR}/{app}", f"{APP_DIR}/{app}/api"): + if os.path.isdir(d): + out += sorted(glob.glob(f"{d}/views*.py")) + return out + + +def parse_defs(src): + """{name: (decorator_block, first_param)} for module-level defs.""" + out = {} + bounds = sorted(x.start() for x in re.finditer(r'^(?:def|class)[ \t]', src, re.M)) + for m in DEF_RE.finditer(src): + decs, name, params = active_decorators(m.group(1)), m.group(2), m.group(3) + first = params.split(",")[0].strip().split(":")[0].strip() if params.strip() else "" + defkw = m.start() + len(m.group(1)) + nxt = next((b for b in bounds if b > defkw), len(src)) + out.setdefault(name, (decs, first, src[defkw:nxt])) # (decs, first_param, body) + return out + + +def parse_classes(src): + """{name: (decorator_block, body)} for top-level classes.""" + out = {} + M = list(CLASS_RE.finditer(src)) + # boundaries = all top-level class/def starts + bounds = sorted([m.start() for m in CLASS_RE.finditer(src)] + + [m.start() for m in re.finditer(r'^(?:def|class)[ \t]', src, re.M)]) + for m in M: + decs, name = active_decorators(m.group(1)), m.group(2) + nxt = next((b for b in bounds if b > m.start()), len(src)) + out.setdefault(name, (decs, src[m.start():nxt])) + return out + + +def routed_refs(app): + """{view_name: file_or_None} for every route in this app's urls files.""" + routed = {} + for urls in (f"{APP_DIR}/{app}/urls.py", f"{APP_DIR}/{app}/api/urls.py"): + if not os.path.exists(urls): + continue + d = os.path.dirname(urls) + src = open(urls, encoding="utf-8", errors="ignore").read() + for ref in ROUTE_RE.findall(src): + ref = ref[:-len(".as_view")] if ref.endswith(".as_view") else ref + parts = ref.split(".") + if len(parts) >= 2: # module.View (e.g. views.foo) + module, name = parts[0], parts[1] + else: # directly-imported View + module, name = "views", parts[0] + if name in ("as_view", "site"): + continue + cand = f"{d}/{module}.py" + routed[name] = cand if os.path.exists(cand) else None + return routed + + +def fn_gated(decs): + if "AllowAny" in decs: + return False + if any(("@" + k) in decs for k in AUTH_DECORATORS): + return True + if "@api_view" in decs: # DRF default permission = IsAuthenticated + return True + return "IsAuthenticated" in decs or "permission_classes" in decs + + +def class_gated(decs, body): + head = decs + body[:600] + if "AllowAny" in head: + return False + # APIView with no explicit permission still gets DRF default IsAuthenticated + return True + + +def main(): + guard_a, guard_b, guard_c = [], [], [] + for app in APPS: + # Per-file maps so a route resolves to its ACTUAL file (names like + # verify_registration exist in both views.py and api/views.py). + per_defs, per_classes = {}, {} + all_defs = {} + for f in view_files(app): + src = open(f, encoding="utf-8", errors="ignore").read() + per_defs[f] = parse_defs(src) + per_classes[f] = parse_classes(src) + for n, info in per_defs[f].items(): + all_defs.setdefault(n, (f, *info)) # (file, decs, first) for A/B + routed = routed_refs(app) + + # GUARD A/B: any mis-decorated / routed helper in any file of the app + for name, (f, decs, first, _body) in all_defs.items(): + is_helper = first not in ("request", "self", "cls") + if is_helper and any(("@" + k) in decs for k in AUTH_DECORATORS): + guard_a.append(f"{app}: {name}( first='{first or ''}' ) [{f}]") + if is_helper and name in routed: + guard_b.append(f"{app}: helper {name} is routed as a view") + + # GUARD C: check the def/class in the file the route actually resolves to + for name, fpath in routed.items(): + d = per_defs.get(fpath, {}) + c = per_classes.get(fpath, {}) + if name in d: + decs, _first, body = d[name] + gated = (name in PUBLIC_ALLOWLIST or fn_gated(decs) + or any(tok in body for tok in INBODY_AUTH)) + if not gated: + guard_c.append(f"{app}: {name}() [{fpath}]") + elif name in c: + decs, body = c[name] + if not class_gated(decs, body): + guard_c.append(f"{app}: {name} (class, AllowAny) [{fpath}]") + # unresolved file / imported elsewhere -> skip + + def section(title, items): + print(f"\n{title}: {'PASS' if not items else f'{len(items)} FOUND'}") + for it in sorted(set(items)): + print(f" - {it}") + + print("=" * 70) + print("Fusion endpoint-auth regression guard") + print("=" * 70) + section("GUARD A (hard) view-auth decorator on a helper", guard_a) + section("GUARD B (hard) helper routed as a view", guard_b) + section("GUARD C (review) routed view without a detectable auth gate", guard_c) + + hard = len(guard_a) + len(guard_b) + print("\n" + "-" * 70) + print(f"hard failures: {hard} review items: {len(guard_c)}") + if hard: + print("RESULT: FAIL (hard regression). Fix GUARD A / GUARD B above.") + return 1 + print("RESULT: PASS (no hard regressions).") + if guard_c: + print("Note: review GUARD C; some are intentional public/read endpoints.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/FusionIIIT/templates/email/invitation.html b/FusionIIIT/templates/email/invitation.html new file mode 100644 index 000000000..d020da0e9 --- /dev/null +++ b/FusionIIIT/templates/email/invitation.html @@ -0,0 +1,40 @@ + + + + + + Thesis Review Invitation + + +
+

Thesis Review Invitation

+ +

Dear {{ prof_name }},

+ +

You are invited to review the PhD thesis titled:

+

+ {{ thesis_title }} +

+ +

Please respond to this invitation by clicking one of the buttons below:

+ + + +
+

Important:

+
    +
  • This invitation expires on {{ expires_at }}
  • +
  • These links are unique to you - please do not share them
  • +
  • If you did not expect this invitation, please ignore this email
  • +
+
+ +

Thank you for your consideration,
+ Thesis Committee
+ PDPM IIITDM Jabalpur

+
+ + \ No newline at end of file diff --git a/FusionIIIT/templates/email/invitation.txt b/FusionIIIT/templates/email/invitation.txt new file mode 100644 index 000000000..8d6eb5d14 --- /dev/null +++ b/FusionIIIT/templates/email/invitation.txt @@ -0,0 +1,22 @@ +THESIS REVIEW INVITATION +======================== + +Dear {{ prof_name }}, + +You are invited to review the PhD thesis titled: + +"{{ thesis_title }}" + +Please respond to this invitation by clicking one of the links below: + +✓ Accept Review: {{ accept_url }} +✗ Decline Review: {{ reject_url }} + +IMPORTANT INFORMATION: +- This invitation expires on {{ expires_at }} +- These links are unique to you - please do not share them +- If you did not expect this invitation, please ignore this email + +Thank you for your consideration, +Thesis Committee +PDPM IIITDM Jabalpur diff --git a/FusionIIIT/templates/email/review_form.html b/FusionIIIT/templates/email/review_form.html new file mode 100644 index 000000000..77afe32f7 --- /dev/null +++ b/FusionIIIT/templates/email/review_form.html @@ -0,0 +1,39 @@ + + + + + + Thesis Review Form + + +
+

Thesis Review Form

+ +

Dear {{ prof_name }},

+ +

Thank you for accepting to review the PhD thesis:

+

+ {{ thesis_title }} +

+ +

Please submit your review using the secure link below:

+ + + +
+

Note:

+
    +
  • This link is unique to you and should not be shared
  • +
  • You can save this email and return to complete the review later
  • +
  • Your feedback is valuable and greatly appreciated
  • +
+
+ +

Thank you for your valuable contribution,
+ Thesis Committee
+ PDPM IIITDM Jabalpur

+
+ + diff --git a/FusionIIIT/templates/email/review_form.txt b/FusionIIIT/templates/email/review_form.txt new file mode 100644 index 000000000..8789afc80 --- /dev/null +++ b/FusionIIIT/templates/email/review_form.txt @@ -0,0 +1,21 @@ +THESIS REVIEW FORM +================== + +Dear {{ prof_name }}, + +Thank you for accepting to review the PhD thesis: + +"{{ thesis_title }}" + +Please submit your review using the secure link below: + +{{ review_url }} + +IMPORTANT NOTES: +- This link is unique to you and should not be shared +- You can save this email and return to complete the review later +- Your feedback is valuable and greatly appreciated + +Thank you for your valuable contribution, +Thesis Committee +PDPM IIITDM Jabalpur diff --git a/FusionIIIT/templates/email/thank_you.html b/FusionIIIT/templates/email/thank_you.html new file mode 100644 index 000000000..22b5c7f09 --- /dev/null +++ b/FusionIIIT/templates/email/thank_you.html @@ -0,0 +1,34 @@ + + + + + + Thank You - Review Submitted + + +
+

✓ Review Received - Thank You!

+ +

Dear {{ prof_name }},

+ +

Thank you for submitting your comprehensive review of the PhD thesis:

+

+ {{ thesis_title }} +

+ +

Your expert feedback and insights are invaluable to the academic process and will significantly contribute to the quality and rigor of this research work.

+ +
+

+ Your review has been successfully submitted and recorded. +

+
+ +

We deeply appreciate the time and effort you have dedicated to this evaluation. Your contribution helps maintain the high standards of academic excellence.

+ +

With sincere gratitude,
+ Thesis Committee
+ PDPM IIITDM Jabalpur

+
+ + diff --git a/FusionIIIT/templates/email/thank_you.txt b/FusionIIIT/templates/email/thank_you.txt new file mode 100644 index 000000000..2ae5e8087 --- /dev/null +++ b/FusionIIIT/templates/email/thank_you.txt @@ -0,0 +1,18 @@ +REVIEW RECEIVED - THANK YOU! +============================ + +Dear {{ prof_name }}, + +Thank you for submitting your comprehensive review of the PhD thesis: + +"{{ thesis_title }}" + +Your expert feedback and insights are invaluable to the academic process and will significantly contribute to the quality and rigor of this research work. + +✓ Your review has been successfully submitted and recorded. + +We deeply appreciate the time and effort you have dedicated to this evaluation. Your contribution helps maintain the high standards of academic excellence. + +With sincere gratitude, +Thesis Committee +PDPM IIITDM Jabalpur diff --git a/FusionIIIT/templates/examination/all_course_grade_filled.html b/FusionIIIT/templates/examination/all_course_grade_filled.html deleted file mode 100644 index d88fc919d..000000000 --- a/FusionIIIT/templates/examination/all_course_grade_filled.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - -{% endblock %} - - -{% block content %} -
-

Enter Student Marks

- -
-
- ALL GRADES ARE FILLED READY TO VERIFY -
-

Please check back later or contact support for assistance.

-
- -
-{% endblock %} diff --git a/FusionIIIT/templates/examination/announcement_req.html b/FusionIIIT/templates/examination/announcement_req.html deleted file mode 100644 index d8482bf62..000000000 --- a/FusionIIIT/templates/examination/announcement_req.html +++ /dev/null @@ -1,139 +0,0 @@ -{% extends 'globals/base.html' %} -{% load static %} - - -{% block title %} - Announcement - Faculty View -{% endblock %} - - -{% block body %} - {% block navBar %} - {% include 'dashboard/navbar.html' %} - {% endblock %} - - {% comment %}The grid starts here!{% endcomment %} -
- - {% comment %}The left-margin segment!{% endcomment %} -
- - {% comment %} - The left-rail segment starts here! - {% endcomment %} -
- - {% comment %}The user image card starts here!{% endcomment %} - {% block usercard %} - {% include 'globals/usercard.html' %} - {% endblock %} - {% comment %}The user image card ends here!{% endcomment %} - - - -
- - {% comment %}The Tab-Menu starts here!{% endcomment %} - - {% comment %}The Tab-Menu ends here!{% endcomment %} - -
- {% comment %} - The left-rail segment ends here! - {% endcomment %} - - - - {% comment %} - The central-rail segment starts here! - {% endcomment %} -
- - {% comment %}Make announcement{% endcomment %} -
- {% block make_announcement %} - {% if user_designation == "faculty" %} - {% include "examination/exam_make_announcement_fac.html" %} - {% elif user_designation == "staff" %} - {% include "examination/exam_make_announcement_staff.html" %} - {% endif %} - {% endblock %} -
- {% comment %}Make announcement{% endcomment %} - - {% comment %}See announcement{% endcomment %} -
- {% block browse_announcement %} - {% if user_designation == "faculty" %} - {% include "department/browse_announcements.html" %} - {% elif user_designation == "staff" %} - {% include "department/browse_announcements_staff.html" %} - {% endif %} - {% endblock %} -
- {% comment %}See announcement{% endcomment %} - - {% comment %}status of request!{% endcomment %} -
- {% block request_status %} - {% include "department/request_status.html" %} - {% endblock %} -
- {% comment %}status of request!{% endcomment %} - - - -
- -
-
- {% comment %} - TODO: the right rail! - {% endcomment %} -
-
- {% comment %}The right-rail segment ends here!{% endcomment %} - - {% comment %}The right-margin segment!{% endcomment %} -
- - -
- {% comment %}The grid ends here!{% endcomment %} - - -{% include 'department/alert.html' %} -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/authenticate.html b/FusionIIIT/templates/examination/authenticate.html deleted file mode 100644 index 3e3d41bea..000000000 --- a/FusionIIIT/templates/examination/authenticate.html +++ /dev/null @@ -1,152 +0,0 @@ -{% extends 'examination/base.html' %} -{% block sidetabmenu %} - - - - - - - - -{% endblock %} - -{% block content %} -

Authenticate Course

-
- -
-
-
-
- -
- -
-
-
- -
- -
- -
- -
- - - -
- -
- - - -{% endblock %} - - - diff --git a/FusionIIIT/templates/examination/authenticategrades.html b/FusionIIIT/templates/examination/authenticategrades.html deleted file mode 100644 index a397b56ec..000000000 --- a/FusionIIIT/templates/examination/authenticategrades.html +++ /dev/null @@ -1,76 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - -{% endblock %} - -{% block content %} -
-

Authenticate

- {% if registrations %} -
- {% csrf_token %} -
- {% for registration in registrations %} -
- - -

{{ registration.course_id.name }} - {{ registration.course_id.code }}- [v-{{ registration.course_id.version }}]-{{ registration.course_year}}

-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
- {% endfor %} -
- -
- {% else %} -
-
- NO STUDENTS REGISTERED IN THIS COURSE IN THIS YEAR -
-

Please check back later or contact support for assistance.

-
- {% endif %} -
- - - - - -{% endblock %} diff --git a/FusionIIIT/templates/examination/base.html b/FusionIIIT/templates/examination/base.html deleted file mode 100644 index 02caa8ef8..000000000 --- a/FusionIIIT/templates/examination/base.html +++ /dev/null @@ -1,83 +0,0 @@ -{% extends 'globals/base.html' %} -{% load static %} - -{% block title %} - examination -{% endblock %} - -{% block css %} - - -{% endblock %} - -{% block body %} - {% block navBar %} - {% include 'dashboard/navbar.html' %} - {% endblock %} - -
- - {% comment %}The left-margin segment!{% endcomment %} -
- - {% comment %}The left-rail segment starts here!{% endcomment %} -
- - {% comment %}The user image card starts here!{% endcomment %} - {% block usercard %} - {% include 'globals/usercard.html' %} - {% endblock %} - {% comment %}The user image card ends here!{% endcomment %} - -
- - {% comment %}The Tab-Menu starts here!{% endcomment %} - {% block sidetabmenu %} - {% endblock %} - {% comment %}The Tab-Menu ends here!{% endcomment %} -
- - {% comment %} - The left-rail segment ends here! - {% endcomment %} - - {% comment %} - The central-rail segment starts here! - {% endcomment %} - -
- - {% block content %} - {% endblock %} - -
- {% comment %}The central-rail segment ends here!{% endcomment %} - - {% comment %}The right-rail segment starts here!{% endcomment %} -
-
- {% comment %} - TODO: the right rail! - {% endcomment %} - {% block rightcontent %} - {% endblock %} - -
-
- {% comment %}The right-rail segment ends here!{% endcomment %} - - {% comment %}The right-margin segment!{% endcomment %} -
- -
- {% comment %}The grid ends here!{% endcomment %} -{% endblock %} - -{% block javascript %} - - - -{% endblock %} - - - diff --git a/FusionIIIT/templates/examination/calculate_spi.html b/FusionIIIT/templates/examination/calculate_spi.html deleted file mode 100644 index 8c4c6e6d2..000000000 --- a/FusionIIIT/templates/examination/calculate_spi.html +++ /dev/null @@ -1,4 +0,0 @@ -{% with total_credits=total_credits|add:course_credits %} - {% with total_grade_points=total_grade_points|add:((grade_points * course_credits)) %} - {% endwith %} -{% endwith %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/check_result.html b/FusionIIIT/templates/examination/check_result.html deleted file mode 100644 index 916d0b4c7..000000000 --- a/FusionIIIT/templates/examination/check_result.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends 'examination/base.html' %} -{% block sidetabmenu %} - -{% endblock %} -{% block content %} -
-

Student Grade Report

-
- {% csrf_token %} -
- - -
- -
-
- - -{% endblock %} diff --git a/FusionIIIT/templates/examination/download_resultProf.html b/FusionIIIT/templates/examination/download_resultProf.html deleted file mode 100644 index d3e3c3ad9..000000000 --- a/FusionIIIT/templates/examination/download_resultProf.html +++ /dev/null @@ -1,139 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - -{% endblock %} -{% block content %} -

Download Result

-
- -
-
-
- -
- -
-
-
- - -
-
- - - -
- -
-
- - - -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/entergrades.html b/FusionIIIT/templates/examination/entergrades.html deleted file mode 100644 index 9ea5ef430..000000000 --- a/FusionIIIT/templates/examination/entergrades.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - -{% endblock %} - - -{% block content %} -
-

Enter Student Marks

- - {% if registrations %} -
- {% csrf_token %} -
- - - - - - - - - - - {% for registration in registrations %} - - - - - - - {% endfor %} - -
Student IDSemester IDCourse IDGrade
{{ registration.student_id_id }}{{ registration.semester_id.id }}{{ registration.course_id.id }} - - - - -
-
- -
- {% else %} -
-
- NO STUDENTS REGISTERED IN THIS COURSE THIS SEMESTER -
-

Please check back later or contact support for assistance.

-
- {% endif %} -
-{% endblock %} diff --git a/FusionIIIT/templates/examination/exam_make_announcement_fac.html b/FusionIIIT/templates/examination/exam_make_announcement_fac.html deleted file mode 100644 index a449dc3d7..000000000 --- a/FusionIIIT/templates/examination/exam_make_announcement_fac.html +++ /dev/null @@ -1,164 +0,0 @@ -{% load static %} -{% block make_announcements %} - - {% comment %}The tab menu starts here!{% endcomment %} - - -
-
- -
- {% csrf_token %} -
- Make a new Announcement for Students: -
- -
- -
- -
- - -
- -
- - -
-
- -
- -
- - -
-
- -
- - -
- -
- - -
- - - -
- - - -
-
- -
- -
-
-
-
- -{% endblock %} - -{% block javascript %} - - - - -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/exam_make_announcement_staff.html b/FusionIIIT/templates/examination/exam_make_announcement_staff.html deleted file mode 100644 index 6d5873364..000000000 --- a/FusionIIIT/templates/examination/exam_make_announcement_staff.html +++ /dev/null @@ -1,165 +0,0 @@ -{% load static %} -{% block make_announcements %} - - {% comment %}The tab menu starts here!{% endcomment %} - - -
-
- -
- {% csrf_token %} -
- Make a new Announcement for Students: -
- -
- -
- -
- - -
- -
- - -
-
- -
- -
- - -
-
- -
- - -
- -
- - -
- - - -
- - - -
-
- -
- -
-
-
-
- -{% endblock %} - -{% block javascript %} - - - - -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/examination.html b/FusionIIIT/templates/examination/examination.html deleted file mode 100644 index fd8e643f9..000000000 --- a/FusionIIIT/templates/examination/examination.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'examination/base.html' %} -{% block sidetabmenu %} - -{% endblock %} diff --git a/FusionIIIT/templates/examination/generate_transcript.html b/FusionIIIT/templates/examination/generate_transcript.html deleted file mode 100644 index 89e28368d..000000000 --- a/FusionIIIT/templates/examination/generate_transcript.html +++ /dev/null @@ -1,210 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - - - -{% endblock %} - -{% block content %} -
-

Transcript

-
-
- {% for course, grade in courses_grades.items %} - {% if forloop.first %} -
Roll No: {{ grade.grade.roll_no }}
- {% endif %} - {% endfor %} -
-
- {% for course, grade in courses_grades.items %} - {% if forloop.first %} -
Semester: {{ grade.grade.semester }}
- {% endif %} - {% endfor %} -
-
- - {% if not courses_grades %} -
Marks not yet submitted.
- {% else %} - - - - - - - - - - - - {% for course_detail, data in courses_grades.items %} - - - - - - - {% endfor %} - -
Course NameCourse CodeCreditsGradeall_auth
{{ course_detail.name }}{{ course_detail.code }}{{ course_detail.credit }} - {{ data.grade.grade }} -
-
-

Semester Performance Index (SPI)

-
-
- -
-
SPI
-
-
-
-

Cumulative Performance Index (CPI)

-
-
- -
-
CPI
-
-
- - {% endif %} -
-{% endblock %} diff --git a/FusionIIIT/templates/examination/generate_transcript_form.html b/FusionIIIT/templates/examination/generate_transcript_form.html deleted file mode 100644 index c1ddb3619..000000000 --- a/FusionIIIT/templates/examination/generate_transcript_form.html +++ /dev/null @@ -1,130 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - -{% endblock %} - -{% block content %} -
-

Generate Transcript Form

-
- {% csrf_token %} -
- - -
- -
- - -
- -
- - -
-
- - -
- - -
- - - -
- - - {% endblock %} diff --git a/FusionIIIT/templates/examination/generate_transcript_students.html b/FusionIIIT/templates/examination/generate_transcript_students.html deleted file mode 100644 index f7beb443c..000000000 --- a/FusionIIIT/templates/examination/generate_transcript_students.html +++ /dev/null @@ -1,98 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - - - -{% endblock %} - -{% block content %} -
-

Show Result for {{ semester }} - semester

- -
- - - - - - - - - {% for student in students %} - - - - - {% empty %} - - - - {% endfor %} - -
- Student Roll No - - - Actions
{{ student.id_id }} - -
No students available
-
-
- - -{% endblock %} diff --git a/FusionIIIT/templates/examination/gradeSubmission.html b/FusionIIIT/templates/examination/gradeSubmission.html deleted file mode 100644 index 0fbf5adf6..000000000 --- a/FusionIIIT/templates/examination/gradeSubmission.html +++ /dev/null @@ -1,226 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - -{% endblock %} - -{% block content %} -

Submit Results

-
- -
-
-
- - -
-
- - -
-
- -
- - -
- -
- -
-
- - - -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/gradeSubmissionForm.html b/FusionIIIT/templates/examination/gradeSubmissionForm.html deleted file mode 100644 index 7de9e2788..000000000 --- a/FusionIIIT/templates/examination/gradeSubmissionForm.html +++ /dev/null @@ -1,226 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - - -{% endblock %} - -{% block content %} -
-

SUBMIT STUDENT MARKS

- {% if registrations %} -
- {% csrf_token %} -
- - - - - - - - - - - - - - {% for registration in registrations %} - - - - - - - - - - - - {% endfor %} - -
- Student ID - - - batchCourse IDsemester Academic Year - Total Marks - - - Grade
{{ registration.student_id_id}}{{ registration.student_id.batch }}{{ registration.course_id.name }}{{ registration.semester_id_id }}{{ year}} - - - - - - - - - -
-
- {% comment %} {% endcomment %} - - -
-
- -
- {% else %} -
-
- NO STUDENTS REGISTERED IN THIS COURSE THIS SEMESTER -
-

Please check back later or contact support for assistance.

-
- {% endif %} -
- - - - - - -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/grades_report.html b/FusionIIIT/templates/examination/grades_report.html deleted file mode 100644 index dbd1c2219..000000000 --- a/FusionIIIT/templates/examination/grades_report.html +++ /dev/null @@ -1,64 +0,0 @@ -{% extends 'examination/base.html' %} -{% block sidetabmenu %} - -{% endblock %} -{% block content %} -
-
-

Grade Report

-
-
-

Roll No: {{ student.id_id }}

-

Name: {{ student_user }}

-
-
-

Discipline: {{ student.specialization }}

-
-
- - - - - - - - - - - - {% for grade in grades %} - - - - - - - {% endfor %} - -
Course CodeCourse NameCreditsGrade
{{ grade.course_id.code }}{{ grade.course_id.name }}{{ grade.course_id.credit }}{{ grade.grade }}
- -
-
-
{{ spi }}
-
SPI
-
-
-
{{ semester_units }}
-
SU
-
-
-
{{ total_units }}
-
TU
-
-
- -

Note: This is system generated, there is no need for signature.

- - -
-
- {% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/grades_updated.html b/FusionIIIT/templates/examination/grades_updated.html deleted file mode 100644 index d2a4b3a85..000000000 --- a/FusionIIIT/templates/examination/grades_updated.html +++ /dev/null @@ -1,95 +0,0 @@ -{% extends 'examination/base.html' %} -{% block sidetabmenu %} - - - - - - - - -{% endblock %} - - -{% block content %} -
-

-
-
- successfully Grades UPDATED -
-

Please check back later or contact support for assistance.

-
- -
-{% endblock %} - diff --git a/FusionIIIT/templates/examination/message.html b/FusionIIIT/templates/examination/message.html deleted file mode 100644 index 13c2132e8..000000000 --- a/FusionIIIT/templates/examination/message.html +++ /dev/null @@ -1,65 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - - -{% endblock %} - -{% block content %} -
-
-
- {{message}} -
- -

Please check back later or contact support for assistance.

-
-
- -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/messageDean.html b/FusionIIIT/templates/examination/messageDean.html deleted file mode 100644 index 17f92f406..000000000 --- a/FusionIIIT/templates/examination/messageDean.html +++ /dev/null @@ -1,63 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - - -{% endblock %} - -{% block content %} -
-
-
- {{message}} -
- -

Please check back later or contact support for assistance.

-
-
- -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/messageProf.html b/FusionIIIT/templates/examination/messageProf.html deleted file mode 100644 index 61b9a7288..000000000 --- a/FusionIIIT/templates/examination/messageProf.html +++ /dev/null @@ -1,73 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - - -{% endblock %} - -{% block content %} -
-
-
- {{message}} -
- -

Please check back later or contact support for assistance.

-
-
- -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/notReady_publish.html b/FusionIIIT/templates/examination/notReady_publish.html deleted file mode 100644 index 85de66c85..000000000 --- a/FusionIIIT/templates/examination/notReady_publish.html +++ /dev/null @@ -1,151 +0,0 @@ -{% extends 'examination/base.html' %} -{% block sidetabmenu %} - -{% endblock %} - -{% block content %} - -

Publish Result

-
- -{% comment %} Combined {% endcomment %} -
- -{% comment %} First Row {% endcomment %} -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
- - -{% comment %} Second Row {% endcomment %} - -
- -
-
- -
- -
- - - -
- -
- - - -{% comment %} Warning {% endcomment %} - -
- -

Course Grades are not yet verified

-

Please verify all courses

- - - - - - -
- - - - - - - - - - - -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/publish.html b/FusionIIIT/templates/examination/publish.html deleted file mode 100644 index b4ed29319..000000000 --- a/FusionIIIT/templates/examination/publish.html +++ /dev/null @@ -1,135 +0,0 @@ -{% extends 'examination/base.html' %} -{% block sidetabmenu %} - -{% endblock %} - -{% block content %} - -

Publish Result

-
- -{% comment %} Combined {% endcomment %} -
- -{% comment %} First Row {% endcomment %} -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
- - -{% comment %} Second Row {% endcomment %} - -
- -
-
- -
- -
- - - - - - - - - -
- - - - - - - -
- -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/submit.html b/FusionIIIT/templates/examination/submit.html deleted file mode 100644 index e85d9fbd0..000000000 --- a/FusionIIIT/templates/examination/submit.html +++ /dev/null @@ -1,95 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - -{% endblock %} - -{% block content %} -

Submit Result

-
- -
-
-
-
- -
- -
-
-
- -
- -
-
- -
- - - -
- -
- - - -{% endblock %} - diff --git a/FusionIIIT/templates/examination/submitGrade.html b/FusionIIIT/templates/examination/submitGrade.html deleted file mode 100644 index e323a4d9f..000000000 --- a/FusionIIIT/templates/examination/submitGrade.html +++ /dev/null @@ -1,103 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - -{% endblock %} - -{% block content %} -

Update Result

-
- - -
- - -
- - -
- - -
- - -
-
- - -
- -
-
- - - - -{% endblock %} - diff --git a/FusionIIIT/templates/examination/submitGradeDean.html b/FusionIIIT/templates/examination/submitGradeDean.html deleted file mode 100644 index 7f77a329f..000000000 --- a/FusionIIIT/templates/examination/submitGradeDean.html +++ /dev/null @@ -1,93 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - -{% endblock %} - -{% block content %} -

Update Result

-
- -
-
-
-
- -
- -
- -
-
- -
- -
-
- -
- - - -
- -
- - - -{% endblock %} - diff --git a/FusionIIIT/templates/examination/submitGradesProf.html b/FusionIIIT/templates/examination/submitGradesProf.html deleted file mode 100644 index 4685da777..000000000 --- a/FusionIIIT/templates/examination/submitGradesProf.html +++ /dev/null @@ -1,188 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - -{% endblock %} - -{% block content %} -

Submit Results

-
- -
-
-
-
- -
- -
-
-
- -
- -
- {% comment %}
-
- -
- -
{% endcomment %} -
- -
- - -
- -
- - - -
-
- - - -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/timetable.html b/FusionIIIT/templates/examination/timetable.html deleted file mode 100644 index a3517c2af..000000000 --- a/FusionIIIT/templates/examination/timetable.html +++ /dev/null @@ -1,58 +0,0 @@ - -{% extends 'examination/base.html' %} -{% block sidetabmenu %} - -{% endblock %} - -{% block content %} - -

Time Table

-
- -
-

Show Time Table

- - - -
-{% comment %} - -
-
- -
- -
{% endcomment %} - - - -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/updateEntergrades.html b/FusionIIIT/templates/examination/updateEntergrades.html deleted file mode 100644 index 828b2ea62..000000000 --- a/FusionIIIT/templates/examination/updateEntergrades.html +++ /dev/null @@ -1,221 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - - -{% endblock %} - -{% block content %} -
-

VERIFY STUDENT MARKS

- {% if registrations %} -
- {% csrf_token %} -
- - - - - - - - - - - - - {% for registration in registrations %} - - - - - - - - - {% endfor %} - - -
- Student ID - - - batchCourse IDsemester - Remarks - Grade
{{ registration.roll_no }}{{ registration.batch }}{{ registration.course_id }}{{ registration.semester }} - - - - - - -
-
- {% comment %} {% endcomment %} - - -
-
- -
- {% else %} -
-
- NO STUDENTS REGISTERED IN THIS COURSE THIS SEMESTER -
-

Please check back later or contact support for assistance.

-
- {% endif %} -
- - - - - - -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/updateEntergradesDean.html b/FusionIIIT/templates/examination/updateEntergradesDean.html deleted file mode 100644 index 8f69f8309..000000000 --- a/FusionIIIT/templates/examination/updateEntergradesDean.html +++ /dev/null @@ -1,219 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - - -{% endblock %} - -{% block content %} -
-

VERIFY STUDENT MARKS

- {% if registrations %} -
- {% csrf_token %} -
- - - - - - - - - - - - - {% for registration in registrations %} - - - - - - - - - - - {% endfor %} - -
- Student ID - - - batchCourse IDsemester - Remarks - Grade
{{ registration.roll_no }}{{ registration.batch }}{{ registration.course_id }}{{ registration.semester }} - - - - - - -
-
-
- - -
- {% comment %} {% endcomment %} - - -
-
- -
- {% else %} -
-
- NO STUDENTS REGISTERED IN THIS COURSE THIS SEMESTER -
-

Please check back later or contact support for assistance.

-
- {% endif %} -
- - - - - - -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/validation.html b/FusionIIIT/templates/examination/validation.html deleted file mode 100644 index e8703e7e0..000000000 --- a/FusionIIIT/templates/examination/validation.html +++ /dev/null @@ -1,68 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - -{% endblock %} - -{% block content %} -

Validate Results

-
- -
- {% csrf_token %} -
-
-
-
- -
- -
-
-
- -
- -
-
- -
- - -
-
-
- -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/validationSubmit.html b/FusionIIIT/templates/examination/validationSubmit.html deleted file mode 100644 index 06e43c4aa..000000000 --- a/FusionIIIT/templates/examination/validationSubmit.html +++ /dev/null @@ -1,192 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - - -{% endblock %} - -{% block content %} -
-

MISMATCHES IN STUDENT MARKS

- {% if mismatch %} -
- {% csrf_token %} -
- - - - - - - - - - - - - - - {% for registration in mismatch %} - - - - - - - - - - {% endfor %} - -
- Student ID - - - batchCourse IDsemester - Remarks - Grade in DBGrade in CSV
{{ registration.roll_no }}{{ registration.batch }}{{ registration.course_id }}{{ registration.semester }}{{ registration.remarks }}{{ registration.db_grade }}{{ registration.csv_grade }}
-
- {% comment %} {% endcomment %} - {% else %} -
-
- NO STUDENTS REGISTERED IN THIS COURSE THIS SEMESTER -
-

Please check back later or contact support for assistance.

-
- {% endif %} -
- - - - - - -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/examination/verify.html b/FusionIIIT/templates/examination/verify.html deleted file mode 100644 index b05ae97ad..000000000 --- a/FusionIIIT/templates/examination/verify.html +++ /dev/null @@ -1,154 +0,0 @@ -{% extends 'examination/base.html' %} -{% block sidetabmenu %} - - - - - - - - -{% endblock %} - -{% block content %} -

Verify Result

-
- -
-
-
-
- -
- -
-
-
- -
- -
-
- -
- - - -
- -
- - - -{% endblock %} - - - diff --git a/FusionIIIT/templates/examination/verifygrades.html b/FusionIIIT/templates/examination/verifygrades.html deleted file mode 100644 index e340fd8fa..000000000 --- a/FusionIIIT/templates/examination/verifygrades.html +++ /dev/null @@ -1,170 +0,0 @@ -{% extends 'examination/base.html' %} - -{% block sidetabmenu %} - -{% endblock %} - -{% block content %} -
-

VERIFY STUDENT MARKS

- {% if registrations %} -
- {% csrf_token %} -
- - - - - - - - - - - {% for registration in registrations %} - - - - - - - {% endfor %} - -
Student IDSemester IDCourse IDGrade
{{ registration.student_id }}{{ registration.semester_id }}{{ registration.course_id }} - - - - -
-
- {% comment %} {% endcomment %} - - -
-
- -
- {% else %} -
-
- NO STUDENTS REGISTERED IN THIS COURSE THIS SEMESTER -
-

Please check back later or contact support for assistance.

-
- {% endif %} -
- - - - - - -{% endblock %} \ No newline at end of file diff --git a/FusionIIIT/templates/phcModule/publish.html b/FusionIIIT/templates/phcModule/publish.html index 988d82c4b..f224e67b1 100644 --- a/FusionIIIT/templates/phcModule/publish.html +++ b/FusionIIIT/templates/phcModule/publish.html @@ -8,10 +8,10 @@ Verify {% endcomment %} - Publish + Publish - Announcement + Announcement {% comment %} Time Table @@ -113,7 +113,7 @@

Publish Result

-
+
{% endblock %} {% comment %}The Tab-Menu ends here!{% endcomment %}