From 54668b6ffa4c00728c175967cc1038067d239f01 Mon Sep 17 00:00:00 2001 From: GAURAV KOTANGALE Date: Mon, 14 Sep 2026 17:00:03 +0530 Subject: [PATCH] Add thesis change request backend support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../academic_procedures/api/urls.py | 19 + .../academic_procedures/api/views.py | 875 +++++++++++++++++- .../0055_thesistopicchangerequest.py | 43 + .../0056_thesiscommitteechangerequest.py | 33 + .../academic_procedures/models.py | 121 +++ .../applications/examination/api/views.py | 85 +- .../applications/globals/api/serializers.py | 3 +- FusionIIIT/applications/globals/api/urls.py | 6 + FusionIIIT/applications/globals/api/views.py | 49 +- .../placement_cell/api/serializers.py | 8 + .../migrations/0016_studentbankdetails.py | 28 + .../applications/placement_cell/models.py | 18 + 12 files changed, 1281 insertions(+), 7 deletions(-) create mode 100644 FusionIIIT/applications/academic_procedures/migrations/0055_thesistopicchangerequest.py create mode 100644 FusionIIIT/applications/academic_procedures/migrations/0056_thesiscommitteechangerequest.py create mode 100644 FusionIIIT/applications/placement_cell/migrations/0016_studentbankdetails.py diff --git a/FusionIIIT/applications/academic_procedures/api/urls.py b/FusionIIIT/applications/academic_procedures/api/urls.py index 56e8d87f2..e86373450 100644 --- a/FusionIIIT/applications/academic_procedures/api/urls.py +++ b/FusionIIIT/applications/academic_procedures/api/urls.py @@ -190,6 +190,25 @@ 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'), + # Thesis Topic Change Request endpoints + url(r'^stu/thesis/change-requests/$', views.student_thesis_change_requests, name='student-thesis-change-requests'), + url(r'^stu/thesis/change-request/$', views.student_thesis_change_request_create, name='student-thesis-change-request-create'), + url(r'^faculty/thesis/change-requests/pending-consent/$', views.faculty_thesis_change_requests_pending_consent, name='faculty-thesis-change-requests-pending-consent'), + url(r'^faculty/thesis/change-request/(?P\d+)/consent/$', views.thesis_change_request_consent, name='thesis-change-request-consent'), + url(r'^hod/thesis/change-requests/$', views.hod_thesis_change_request_dashboard, name='hod-thesis-change-request-dashboard'), + url(r'^hod/thesis/change-request/(?P\d+)/review/$', views.hod_thesis_change_request_review, name='hod-thesis-change-request-review'), + url(r'^dean/thesis/change-requests/$', views.dean_thesis_change_request_dashboard, name='dean-thesis-change-request-dashboard'), + url(r'^dean/thesis/change-request/(?P\d+)/review/$', views.dean_thesis_change_request_review, name='dean-thesis-change-request-review'), + + # Thesis Committee (RPC) Change Request endpoints + url(r'^supervisor/thesis/committee-change-requests/$', views.supervisor_committee_change_requests, name='supervisor-committee-change-requests'), + url(r'^supervisor/thesis/(?P\d+)/committee-change/propose/$', views.supervisor_propose_committee_change, name='supervisor-propose-committee-change'), + url(r'^supervisor/thesis/committee-change/(?P\d+)/resubmit/$', views.supervisor_resubmit_committee_change, name='supervisor-resubmit-committee-change'), + url(r'^hod/thesis/committee-change-requests/$', views.hod_committee_change_dashboard, name='hod-committee-change-dashboard'), + url(r'^hod/thesis/committee-change/(?P\d+)/review/$', views.hod_committee_change_review, name='hod-committee-change-review'), + url(r'^dean/thesis/committee-change-requests/$', views.dean_committee_change_dashboard, name='dean-committee-change-dashboard'), + url(r'^dean/thesis/committee-change/(?P\d+)/review/$', views.dean_committee_change_review, name='dean-committee-change-review'), + # PhD Seminar endpoints # Student diff --git a/FusionIIIT/applications/academic_procedures/api/views.py b/FusionIIIT/applications/academic_procedures/api/views.py index 7c4a4afdd..541cbfe28 100644 --- a/FusionIIIT/applications/academic_procedures/api/views.py +++ b/FusionIIIT/applications/academic_procedures/api/views.py @@ -2936,9 +2936,11 @@ 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 + # PhD students who have course_registration rows (e.g. registered through + # the admin/course-registration flow) should still see those courses. + # Only skip to the empty-response shortcut when no such rows exist. is_phd_student = student.programme and student.programme.upper() == 'PHD' - if is_phd_student: + if is_phd_student and not course_registration.objects.filter(student_id=student).exists(): return Response({ "reg_data": [], "sem_no": 1, @@ -7925,6 +7927,873 @@ def dean_generate_pdf_api(request, pk): return response +# =========================================================================== +# Thesis Topic Change Request (post-approval amendment) +# =========================================================================== +# Workflow: once a ThesisTopic is dean_approved, the student may request a +# change to the topic (category/broad_area/research_theme) and/or +# supervisor/co-supervisor. The current supervisor (and current co-supervisor, +# if any) must always consent -- whatever is being changed, they're signing +# off on it, which also covers an outgoing supervisor/co-supervisor being +# replaced. If a NEW supervisor/co-supervisor is being proposed, they must +# also separately consent to take on the role. Once every required consent is +# in, it goes to the HOD, then Dean Academic for final approval, which applies +# the change directly to the live ThesisTopic (and keeps CommitteeMember in +# sync). A decline (at consent stage) or rejection (HOD/Dean) is terminal -- +# the student submits a fresh request to retry. + +def _thesis_change_request_to_dict(cr): + thesis = cr.thesis + return { + 'id': cr.id, + 'thesis_id': thesis.id, + 'student_roll': thesis.student.id.id, + 'student_name': thesis.student.id.user.get_full_name(), + 'student_discipline': thesis.student.specialization, + 'current_category': thesis.category, + 'current_broad_area': thesis.broad_area, + 'current_research_theme': thesis.research_theme, + 'current_supervisor': {'id': thesis.supervisor.id.id, 'name': str(thesis.supervisor)}, + 'current_co_supervisor': ( + {'id': thesis.co_supervisor.id.id, 'name': str(thesis.co_supervisor)} + if thesis.co_supervisor else None + ), + 'new_category': cr.new_category, + 'new_broad_area': cr.new_broad_area, + 'new_research_theme': cr.new_research_theme, + 'new_supervisor': ( + {'id': cr.new_supervisor.id.id, 'name': str(cr.new_supervisor)} + if cr.new_supervisor else None + ), + 'new_co_supervisor': ( + {'id': cr.new_co_supervisor.id.id, 'name': str(cr.new_co_supervisor)} + if cr.new_co_supervisor else None + ), + 'current_supervisor_consented': cr.current_supervisor_consented, + 'current_co_supervisor_consented': cr.current_co_supervisor_consented, + 'new_supervisor_consented': cr.new_supervisor_consented, + 'new_co_supervisor_consented': cr.new_co_supervisor_consented, + 'status': cr.status, + 'hod_remarks': cr.hod_remarks, + 'dean_remarks': cr.dean_remarks, + 'decline_remarks': cr.decline_remarks, + 'created_at': cr.created_at.isoformat(), + } + + +def _thesis_change_request_active_rpc_blocked(thesis): + """Mirrors supervisor_review_api's committee-edit freeze: block while any + Comprehensive Exam / Open Seminar / Progress Seminar attempt tied to this + student is actively awaiting RPC consent.""" + return ( + ComprehensiveExamAttempt.objects.filter(exam__student=thesis.student, status='rpc_pending').exists() + or OpenSeminarAttempt.objects.filter(open_seminar__student=thesis.student, status='rpc_pending').exists() + or ProgressSeminarEntry.objects.filter(thesis=thesis, status='rpc_pending').exists() + ) + + +def _thesis_change_request_role(cr, user): + """Which of the (up to 4) consent roles `user` plays for this change + request, or None. Checked in a fixed order; a user can only match one.""" + thesis = cr.thesis + user_ex = user.username + if thesis.supervisor_id == user_ex: + return 'current_supervisor' + if thesis.co_supervisor_id and thesis.co_supervisor_id == user_ex: + return 'current_co_supervisor' + if cr.new_supervisor_id and cr.new_supervisor_id == user_ex: + return 'new_supervisor' + if cr.new_co_supervisor_id and cr.new_co_supervisor_id == user_ex: + return 'new_co_supervisor' + return None + + +def _thesis_change_request_all_consents_given(cr): + thesis = cr.thesis + if not cr.current_supervisor_consented: + return False + if thesis.co_supervisor_id and not cr.current_co_supervisor_consented: + return False + if cr.new_supervisor_id and not cr.new_supervisor_consented: + return False + if cr.new_co_supervisor_id and not cr.new_co_supervisor_consented: + return False + return True + + +# 1. Student + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def student_thesis_change_requests(request): + """GET /stu/thesis/change-requests/ -> the requesting student's own thesis's change requests, most recent first.""" + try: + student = Student.objects.get(id=request.user.extrainfo) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) + + thesis = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + if not thesis: + return JsonResponse({'change_requests': []}, status=200) + + qs = ThesisTopicChangeRequest.objects.filter(thesis=thesis).order_by('-created_at') + return JsonResponse({ + 'change_requests': [_thesis_change_request_to_dict(cr) for cr in qs], + }, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def student_thesis_change_request_create(request): + """ + POST /stu/thesis/change-request/ + Body: { new_category, new_broad_area, new_research_theme, new_supervisor_id, new_co_supervisor_id } + All fields optional -- at least one must actually differ from the current + thesis. Only allowed once the thesis is fully dean_approved, with no other + change request already active, and not while an RPC review is in progress. + """ + try: + student = Student.objects.get(id=request.user.extrainfo) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) + + thesis = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + if not thesis or thesis.status != 'dean_approved': + return JsonResponse( + {'error': 'Thesis topic must be fully approved before requesting a change.'}, status=403, + ) + + if ThesisTopicChangeRequest.objects.filter( + thesis=thesis, status__in=['pending_consents', 'hod_pending', 'dean_pending'], + ).exists(): + return JsonResponse({'error': 'A change request is already in progress for this thesis.'}, status=400) + + if _thesis_change_request_active_rpc_blocked(thesis): + return JsonResponse({ + 'error': 'Cannot request a change while a Comprehensive Exam, Open Seminar, or ' + 'Progress Seminar is awaiting RPC consent.', + }, status=403) + + data = request.data + new_category = data.get('new_category') or '' + new_broad_area = data.get('new_broad_area') or '' + new_research_theme = data.get('new_research_theme') or '' + new_supervisor_id = data.get('new_supervisor_id') or None + new_co_supervisor_id = data.get('new_co_supervisor_id') or None + + if new_category and new_category == thesis.category: + new_category = '' + if new_broad_area and new_broad_area == thesis.broad_area: + new_broad_area = '' + if new_research_theme and new_research_theme == thesis.research_theme: + new_research_theme = '' + if new_supervisor_id and new_supervisor_id == thesis.supervisor_id: + new_supervisor_id = None + if new_co_supervisor_id and thesis.co_supervisor_id and new_co_supervisor_id == thesis.co_supervisor_id: + new_co_supervisor_id = None + + if not any([new_category, new_broad_area, new_research_theme, new_supervisor_id, new_co_supervisor_id]): + return JsonResponse({'error': 'No actual change requested.'}, status=400) + + new_supervisor = None + if new_supervisor_id: + try: + new_supervisor = Faculty.objects.get(pk=new_supervisor_id) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Invalid new supervisor'}, status=400) + if not new_supervisor.id.user.is_active: + return JsonResponse({'error': 'Selected new supervisor is not an active faculty member'}, status=400) + + new_co_supervisor = None + if new_co_supervisor_id: + try: + new_co_supervisor = Faculty.objects.get(pk=new_co_supervisor_id) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Invalid new co-supervisor'}, status=400) + if not new_co_supervisor.id.user.is_active: + return JsonResponse( + {'error': 'Selected new co-supervisor is not an active faculty member'}, status=400, + ) + + effective_supervisor_id = new_supervisor_id or thesis.supervisor_id + effective_co_supervisor_id = new_co_supervisor_id or thesis.co_supervisor_id + if effective_co_supervisor_id and effective_co_supervisor_id == effective_supervisor_id: + return JsonResponse({'error': 'Co-supervisor must be different from supervisor'}, status=400) + + cr = ThesisTopicChangeRequest.objects.create( + thesis=thesis, + new_category=new_category, + new_broad_area=new_broad_area, + new_research_theme=new_research_theme, + new_supervisor_id=new_supervisor_id, + new_co_supervisor_id=new_co_supervisor_id, + ) + + _thesis_notify( + sender=request.user, + recipient=thesis.supervisor.id.user, + verb='Thesis topic change request awaiting your consent', + description=f"{student.id.user.get_full_name()} has requested a change to their thesis " + f"topic/supervisor and needs your consent.", + ) + if thesis.co_supervisor: + _thesis_notify( + sender=request.user, + recipient=thesis.co_supervisor.id.user, + verb='Thesis topic change request awaiting your consent', + description=f"{student.id.user.get_full_name()} has requested a change to their thesis " + f"topic/supervisor and needs your consent.", + ) + if new_supervisor: + _thesis_notify( + sender=request.user, + recipient=new_supervisor.id.user, + verb='You have been proposed as a new thesis supervisor', + description=f"{student.id.user.get_full_name()} has proposed you as their new thesis " + f"supervisor and needs your consent.", + ) + if new_co_supervisor: + _thesis_notify( + sender=request.user, + recipient=new_co_supervisor.id.user, + verb='You have been proposed as a new thesis co-supervisor', + description=f"{student.id.user.get_full_name()} has proposed you as their new thesis " + f"co-supervisor and needs your consent.", + ) + + return JsonResponse(_thesis_change_request_to_dict(cr), status=201) + + +# 2. Consent (current supervisor, current co-supervisor, new supervisor, new co-supervisor) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def faculty_thesis_change_requests_pending_consent(request): + """ + GET /faculty/thesis/change-requests/pending-consent/ + Change requests (status='pending_consents') where the requester plays one + of the 4 consent roles and hasn't consented yet -- covers a newly-proposed + supervisor/co-supervisor too, who wouldn't otherwise see this thesis + anywhere (they aren't the supervisor of record yet). + """ + user_ex = request.user.username + qs = ThesisTopicChangeRequest.objects.filter(status='pending_consents').filter( + Q(thesis__supervisor_id=user_ex, current_supervisor_consented=False) + | Q(thesis__co_supervisor_id=user_ex, current_co_supervisor_consented=False) + | Q(new_supervisor_id=user_ex, new_supervisor_consented=False) + | Q(new_co_supervisor_id=user_ex, new_co_supervisor_consented=False) + ).select_related( + 'thesis__student__id__user', 'thesis__supervisor__id__user', 'thesis__co_supervisor__id__user', + 'new_supervisor__id__user', 'new_co_supervisor__id__user', + ).distinct() + + results = [] + for cr in qs: + payload = _thesis_change_request_to_dict(cr) + payload['my_role'] = _thesis_change_request_role(cr, request.user) + results.append(payload) + return JsonResponse({'pending': results}, status=200) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def thesis_change_request_consent(request, pk): + """ + GET /faculty/thesis/change-request// -> detail + which of the (up to + 4) consent roles the requester plays, if any. + POST /faculty/thesis/change-request//consent/ + Body: { consent: true|false, remarks (if declining) } + Any required consenter may call this. Declining is terminal for the whole + request (status='declined') -- the student must submit a fresh request. + """ + cr = get_object_or_404(ThesisTopicChangeRequest, pk=pk) + role = _thesis_change_request_role(cr, request.user) + + if request.method == 'GET': + if not role: + return JsonResponse({'error': 'Not authorized'}, status=403) + payload = _thesis_change_request_to_dict(cr) + payload['my_role'] = role + return JsonResponse(payload, status=200) + + if not role: + return JsonResponse({'error': 'Not authorized'}, status=403) + if cr.status != 'pending_consents': + return JsonResponse({'error': 'Not awaiting consent at this stage'}, status=403) + + consent_field = f'{role}_consented' + if getattr(cr, consent_field): + return JsonResponse({'error': 'Already consented'}, status=400) + + data = request.data + thesis = cr.thesis + student_name = thesis.student.id.user.get_full_name() + + if not data.get('consent'): + cr.status = 'declined' + cr.decline_remarks = data.get('remarks', '') + cr.declined_by = request.user + cr.save() + for recipient in filter(None, [thesis.student.id.user, thesis.supervisor.id.user]): + _thesis_notify( + sender=request.user, + recipient=recipient, + verb='Thesis topic change request declined', + description=f"{request.user.get_full_name()} declined the requested change to " + f"{student_name}'s thesis topic. Remarks: {cr.decline_remarks or '—'}", + ) + return JsonResponse({'status': cr.status}, status=200) + + setattr(cr, consent_field, True) + + if _thesis_change_request_all_consents_given(cr): + cr.status = 'hod_pending' + cr.save() + acronym = ( + thesis.student.batch_id.discipline.acronym + if thesis.student.batch_id and thesis.student.batch_id.discipline else None + ) + _thesis_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Thesis topic change request pending your review', + description=f"All required consents are in for {student_name}'s thesis topic change " + f"request; awaiting HOD review.", + ) + else: + cr.save() + + return JsonResponse(_thesis_change_request_to_dict(cr), status=200) + + +# 3. HOD + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def hod_thesis_change_request_dashboard(request): + """GET /hod/thesis/change-requests/ -> change requests pending HOD review, scoped to the HOD's own discipline.""" + hod_disciplines = get_hod_disciplines(request.user) + qs = ThesisTopicChangeRequest.objects.filter(status='hod_pending').select_related( + 'thesis__student__id__user', 'thesis__student__batch_id__discipline', 'thesis__supervisor__id__user', + ) + pending = [] + for cr in qs: + student = cr.thesis.student + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + if acronym and acronym in hod_disciplines: + pending.append(_thesis_change_request_to_dict(cr)) + return JsonResponse({'pending': pending}, status=200) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def hod_thesis_change_request_review(request, pk): + """ + GET /hod/thesis/change-request//review/ + POST /hod/thesis/change-request//review/ Body: { approve: true|false, remarks } + """ + cr = get_object_or_404(ThesisTopicChangeRequest, pk=pk) + thesis = cr.thesis + acronym = ( + thesis.student.batch_id.discipline.acronym + if thesis.student.batch_id and thesis.student.batch_id.discipline else None + ) + is_hod = is_hod_of_discipline(request.user, acronym) + + if request.method == 'GET': + if not is_hod: + return JsonResponse({'error': 'Not authorized'}, status=403) + return JsonResponse(_thesis_change_request_to_dict(cr), status=200) + + if not is_hod or cr.status != 'hod_pending': + return JsonResponse({'error': 'Forbidden or invalid stage'}, status=403) + + data = request.data + cr.hod_by = request.user + cr.hod_at = timezone.now() + student_name = thesis.student.id.user.get_full_name() + + if data.get('approve'): + cr.hod_remarks = '' + cr.status = 'dean_pending' + cr.save() + _thesis_notify( + sender=request.user, + recipient=_dean_academic_users(), + verb='Thesis topic change request pending your final approval', + description=f"{student_name}'s thesis topic change request has been approved by the " + f"HOD and is awaiting your final approval.", + ) + else: + cr.hod_remarks = data.get('remarks', '') + cr.status = 'hod_rejected' + cr.save() + for recipient in filter(None, [thesis.supervisor.id.user, thesis.student.id.user]): + _thesis_notify( + sender=request.user, + recipient=recipient, + verb='Thesis topic change request rejected by HOD', + description=f"The HOD rejected {student_name}'s thesis topic change request. " + f"Remarks: {cr.hod_remarks or '—'}", + ) + + return JsonResponse({'status': cr.status}, status=200) + + +# 4. Dean Academic + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_thesis_change_request_dashboard(request): + """GET /dean/thesis/change-requests/ -> change requests pending Dean Academic final approval.""" + qs = ThesisTopicChangeRequest.objects.filter(status='dean_pending').select_related( + 'thesis__student__id__user', 'thesis__supervisor__id__user', + ) + return JsonResponse({'pending': [_thesis_change_request_to_dict(cr) for cr in qs]}, status=200) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_thesis_change_request_review(request, pk): + """ + GET /dean/thesis/change-request//review/ + POST /dean/thesis/change-request//review/ Body: { approve: true|false, remarks } + Approving applies the change directly to the live ThesisTopic and syncs + CommitteeMember (only touching the supervisor/co-supervisor slots being + replaced, leaving the rest of the RPC untouched). + """ + cr = get_object_or_404(ThesisTopicChangeRequest, pk=pk) + thesis = cr.thesis + + if request.method == 'GET': + return JsonResponse(_thesis_change_request_to_dict(cr), status=200) + + if cr.status != 'dean_pending': + return JsonResponse({'error': 'Forbidden or invalid stage'}, status=403) + + data = request.data + cr.dean_by = request.user + cr.dean_at = timezone.now() + student_name = thesis.student.id.user.get_full_name() + acronym = ( + thesis.student.batch_id.discipline.acronym + if thesis.student.batch_id and thesis.student.batch_id.discipline else None + ) + + if data.get('approve'): + cr.dean_remarks = '' + cr.status = 'approved' + cr.save() + + if cr.new_category: + thesis.category = cr.new_category + if cr.new_broad_area: + thesis.broad_area = cr.new_broad_area + if cr.new_research_theme: + thesis.research_theme = cr.new_research_theme + + if cr.new_supervisor_id and cr.new_supervisor_id != thesis.supervisor_id: + CommitteeMember.objects.filter(thesis=thesis, member_id=thesis.supervisor_id).delete() + thesis.supervisor_id = cr.new_supervisor_id + CommitteeMember.objects.get_or_create(thesis=thesis, member_id=thesis.supervisor_id) + + if cr.new_co_supervisor_id and cr.new_co_supervisor_id != thesis.co_supervisor_id: + if thesis.co_supervisor_id: + CommitteeMember.objects.filter(thesis=thesis, member_id=thesis.co_supervisor_id).delete() + thesis.co_supervisor_id = cr.new_co_supervisor_id + CommitteeMember.objects.get_or_create(thesis=thesis, member_id=thesis.co_supervisor_id) + + thesis.save() + + recipients = [thesis.student.id.user, thesis.supervisor.id.user] + if thesis.co_supervisor: + recipients.append(thesis.co_supervisor.id.user) + for recipient in recipients: + _thesis_notify( + sender=request.user, + recipient=recipient, + verb='Thesis topic change approved', + description=f"Dean Academic approved the requested change to {student_name}'s " + f"thesis topic.", + ) + _thesis_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Thesis topic change approved', + description=f"Dean Academic approved the requested change to {student_name}'s " + f"thesis topic.", + ) + else: + cr.dean_remarks = data.get('remarks', '') + cr.status = 'dean_rejected' + cr.save() + _thesis_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Thesis topic change request rejected by Dean Academic', + description=f"Dean Academic rejected {student_name}'s thesis topic change request. " + f"Remarks: {cr.dean_remarks or '—'}", + ) + + return JsonResponse({'status': cr.status}, status=200) + + +# =========================================================================== +# Thesis Committee (RPC) Change Request +# =========================================================================== +# Workflow: the current supervisor (only -- not the co-supervisor, mirroring +# the original committee-edit permission in supervisor_review_api) proposes a +# complete new committee for an already dean_approved ThesisTopic -> HOD -> +# Dean Academic approval, which applies the new membership to CommitteeMember +# (supervisor/co-supervisor are always kept as members regardless of what's +# submitted). Rejection (HOD or Dean) sends it back to the supervisor to edit +# and resubmit the SAME record -- mirrors ThesisTopic's own resubmission +# semantics, unlike ThesisTopicChangeRequest's terminal-decline design. + +def _is_thesis_current_supervisor(user, thesis): + return thesis.supervisor_id == user.username + + +def _thesis_committee_change_to_dict(cr): + thesis = cr.thesis + return { + 'id': cr.id, + 'thesis_id': thesis.id, + 'student_roll': thesis.student.id.id, + 'student_name': thesis.student.id.user.get_full_name(), + 'student_discipline': thesis.student.specialization, + 'supervisor': {'id': thesis.supervisor.id.id, 'name': str(thesis.supervisor)}, + 'co_supervisor': ( + {'id': thesis.co_supervisor.id.id, 'name': str(thesis.co_supervisor)} + if thesis.co_supervisor else None + ), + 'current_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 thesis.committee.all() + ], + 'proposed_committee': [ + { + 'id': m.id.id, + 'name': str(m), + 'discipline': (m.id.department.name if m.id.department else ''), + } + for m in cr.members.all() + ], + 'status': cr.status, + 'hod_remarks': cr.hod_remarks, + 'dean_remarks': cr.dean_remarks, + 'created_at': cr.created_at.isoformat(), + } + + +def _validate_committee_members(thesis, member_ids): + """Returns (Faculty queryset, error_response_or_None). Always folds in + supervisor/co-supervisor and enforces the >=3 minimum, matching the + original committee-edit rule in supervisor_review_api.""" + member_ids = set(member_ids or []) + member_ids.add(thesis.supervisor_id) + if thesis.co_supervisor_id: + member_ids.add(thesis.co_supervisor_id) + + if len(member_ids) < 3: + return None, JsonResponse( + {'error': 'Need at least 3 RPC members (including supervisor/co-supervisor).'}, status=400, + ) + + members_qs = Faculty.objects.filter(pk__in=member_ids) + if members_qs.count() != len(member_ids): + return None, JsonResponse({'error': 'One or more selected members are invalid.'}, status=400) + + return members_qs, None + + +# 1. Supervisor + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_committee_change_requests(request): + """GET /supervisor/thesis/committee-change-requests/ -> committee change + requests for theses the requester currently supervises, most recent first.""" + try: + faculty = Faculty.objects.get(id__user=request.user) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) + + qs = ThesisCommitteeChangeRequest.objects.filter(thesis__supervisor=faculty).select_related( + 'thesis__student__id__user', 'thesis__supervisor__id__user', 'thesis__co_supervisor__id__user', + ).order_by('-created_at') + return JsonResponse({ + 'requests': [_thesis_committee_change_to_dict(cr) for cr in qs], + }, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_propose_committee_change(request, thesis_pk): + """ + POST /supervisor/thesis//committee-change/propose/ + Body: { members: [faculty_id, ...] } + Only the current supervisor may propose. Only allowed once the thesis is + fully dean_approved, when no other committee change request is currently + in progress (an 'approved' or nonexistent prior request is fine -- a + rejected one must be resubmitted instead, not re-proposed), and not while + an RPC review is in progress. + """ + thesis = get_object_or_404(ThesisTopic, pk=thesis_pk) + if not _is_thesis_current_supervisor(request.user, thesis): + return JsonResponse({'error': 'Not authorized'}, status=403) + if thesis.status != 'dean_approved': + return JsonResponse( + {'error': 'Thesis topic must be fully approved before requesting a committee change.'}, status=403, + ) + + latest = ThesisCommitteeChangeRequest.objects.filter(thesis=thesis).order_by('-created_at').first() + if latest and latest.status != 'approved': + return JsonResponse({ + 'error': 'A committee change request is already in progress for this thesis -- ' + 'edit and resubmit it instead of proposing a new one.', + }, status=400) + + if _thesis_change_request_active_rpc_blocked(thesis): + return JsonResponse({ + 'error': 'Cannot request a committee change while a Comprehensive Exam, Open Seminar, ' + 'or Progress Seminar is awaiting RPC consent.', + }, status=403) + + members_qs, err = _validate_committee_members(thesis, request.data.get('members')) + if err: + return err + + cr = ThesisCommitteeChangeRequest.objects.create(thesis=thesis) + cr.members.set(members_qs) + + acronym = ( + thesis.student.batch_id.discipline.acronym + if thesis.student.batch_id and thesis.student.batch_id.discipline else None + ) + _thesis_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Thesis committee change request pending your review', + description=f"{thesis.student.id.user.get_full_name()}'s supervisor has proposed a " + f"committee membership change, awaiting your review.", + ) + + return JsonResponse(_thesis_committee_change_to_dict(cr), status=201) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_resubmit_committee_change(request, pk): + """ + POST /supervisor/thesis/committee-change//resubmit/ + Body: { members: [faculty_id, ...] } + Only after a HOD/Dean rejection, only by the current supervisor. + """ + cr = get_object_or_404(ThesisCommitteeChangeRequest, pk=pk) + thesis = cr.thesis + if not _is_thesis_current_supervisor(request.user, thesis): + return JsonResponse({'error': 'Not authorized'}, status=403) + if cr.status not in ('hod_rejected', 'dean_rejected'): + return JsonResponse({'error': 'Cannot edit at this stage'}, status=403) + + if _thesis_change_request_active_rpc_blocked(thesis): + return JsonResponse({ + 'error': 'Cannot resubmit a committee change while a Comprehensive Exam, Open Seminar, ' + 'or Progress Seminar is awaiting RPC consent.', + }, status=403) + + members_qs, err = _validate_committee_members(thesis, request.data.get('members')) + if err: + return err + + cr.members.set(members_qs) + cr.status = 'hod_pending' + cr.hod_remarks = '' + cr.dean_remarks = '' + cr.save() + + acronym = ( + thesis.student.batch_id.discipline.acronym + if thesis.student.batch_id and thesis.student.batch_id.discipline else None + ) + _thesis_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Thesis committee change request resubmitted', + description=f"{thesis.student.id.user.get_full_name()}'s supervisor has resubmitted a " + f"committee membership change, awaiting your review.", + ) + + return JsonResponse(_thesis_committee_change_to_dict(cr), status=200) + + +# 2. HOD + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def hod_committee_change_dashboard(request): + """GET /hod/thesis/committee-change-requests/ -> requests pending HOD review, scoped to the HOD's own discipline.""" + hod_disciplines = get_hod_disciplines(request.user) + qs = ThesisCommitteeChangeRequest.objects.filter(status='hod_pending').select_related( + 'thesis__student__id__user', 'thesis__student__batch_id__discipline', 'thesis__supervisor__id__user', + ) + pending = [] + for cr in qs: + student = cr.thesis.student + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + if acronym and acronym in hod_disciplines: + pending.append(_thesis_committee_change_to_dict(cr)) + return JsonResponse({'pending': pending}, status=200) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def hod_committee_change_review(request, pk): + """ + GET /hod/thesis/committee-change//review/ + POST /hod/thesis/committee-change//review/ Body: { approve: true|false, remarks } + """ + cr = get_object_or_404(ThesisCommitteeChangeRequest, pk=pk) + thesis = cr.thesis + acronym = ( + thesis.student.batch_id.discipline.acronym + if thesis.student.batch_id and thesis.student.batch_id.discipline else None + ) + is_hod = is_hod_of_discipline(request.user, acronym) + + if request.method == 'GET': + if not is_hod: + return JsonResponse({'error': 'Not authorized'}, status=403) + return JsonResponse(_thesis_committee_change_to_dict(cr), status=200) + + if not is_hod or cr.status != 'hod_pending': + return JsonResponse({'error': 'Forbidden or invalid stage'}, status=403) + + data = request.data + cr.hod_by = request.user + cr.hod_at = timezone.now() + student_name = thesis.student.id.user.get_full_name() + + if data.get('approve'): + cr.hod_remarks = '' + cr.status = 'dean_pending' + cr.save() + _thesis_notify( + sender=request.user, + recipient=_dean_academic_users(), + verb='Thesis committee change request pending your final approval', + description=f"{student_name}'s committee change request has been approved by the " + f"HOD and is awaiting your final approval.", + ) + _thesis_notify( + sender=request.user, + recipient=thesis.supervisor.id.user, + verb='Thesis committee change request approved by HOD', + description=f"{student_name}'s committee change request has been approved by the " + f"HOD and forwarded to Dean Academic.", + ) + else: + cr.hod_remarks = data.get('remarks', '') + cr.status = 'hod_rejected' + cr.save() + _thesis_notify( + sender=request.user, + recipient=thesis.supervisor.id.user, + verb='Thesis committee change request rejected by HOD', + description=f"The HOD rejected {student_name}'s committee change request. " + f"Remarks: {cr.hod_remarks or '—'}", + ) + + return JsonResponse({'status': cr.status}, status=200) + + +# 3. Dean Academic + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_committee_change_dashboard(request): + """GET /dean/thesis/committee-change-requests/ -> requests pending Dean Academic final approval.""" + qs = ThesisCommitteeChangeRequest.objects.filter(status='dean_pending').select_related( + 'thesis__student__id__user', 'thesis__supervisor__id__user', + ) + return JsonResponse({'pending': [_thesis_committee_change_to_dict(cr) for cr in qs]}, status=200) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_committee_change_review(request, pk): + """ + GET /dean/thesis/committee-change//review/ + POST /dean/thesis/committee-change//review/ Body: { approve: true|false, remarks } + Approving replaces the thesis's CommitteeMember rows with the proposed + membership (supervisor/co-supervisor always included). + """ + cr = get_object_or_404(ThesisCommitteeChangeRequest, pk=pk) + thesis = cr.thesis + + if request.method == 'GET': + return JsonResponse(_thesis_committee_change_to_dict(cr), status=200) + + if cr.status != 'dean_pending': + return JsonResponse({'error': 'Forbidden or invalid stage'}, status=403) + + data = request.data + cr.dean_by = request.user + cr.dean_at = timezone.now() + student_name = thesis.student.id.user.get_full_name() + acronym = ( + thesis.student.batch_id.discipline.acronym + if thesis.student.batch_id and thesis.student.batch_id.discipline else None + ) + + if data.get('approve'): + cr.dean_remarks = '' + cr.status = 'approved' + cr.save() + + CommitteeMember.objects.filter(thesis=thesis).delete() + for member in cr.members.all(): + CommitteeMember.objects.get_or_create(thesis=thesis, member=member) + + for recipient in filter(None, [thesis.student.id.user, thesis.supervisor.id.user]): + _thesis_notify( + sender=request.user, + recipient=recipient, + verb='Thesis committee change approved', + description=f"Dean Academic approved the requested committee change for " + f"{student_name}'s thesis.", + ) + _thesis_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Thesis committee change approved', + description=f"Dean Academic approved the requested committee change for " + f"{student_name}'s thesis.", + ) + else: + cr.dean_remarks = data.get('remarks', '') + cr.status = 'dean_rejected' + cr.save() + _thesis_notify( + sender=request.user, + recipient=thesis.supervisor.id.user, + verb='Thesis committee change request rejected by Dean Academic', + description=f"Dean Academic rejected {student_name}'s committee change request. " + f"Remarks: {cr.dean_remarks or '—'}", + ) + + return JsonResponse({'status': cr.status}, status=200) + + # Seminar Views # 1. STUDENT @@ -9326,7 +10195,7 @@ def dean_final_review_action(request): # Thesis Slot Semester-Level Registration # =========================================================================== from applications.academic_procedures.models import ( - ThesisTopic, CommitteeMember, ProgressSeminarEntry, + ThesisTopic, ThesisTopicChangeRequest, ThesisCommitteeChangeRequest, CommitteeMember, ProgressSeminarEntry, ProgressSeminarConsent, ProgressSeminarComment, ThesisRegistration, ProgressSeminarRegistration, TeachingCreditRegistration, ThesisEvaluation, ProgressSeminarEvaluation, diff --git a/FusionIIIT/applications/academic_procedures/migrations/0055_thesistopicchangerequest.py b/FusionIIIT/applications/academic_procedures/migrations/0055_thesistopicchangerequest.py new file mode 100644 index 000000000..4fec88a76 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0055_thesistopicchangerequest.py @@ -0,0 +1,43 @@ +# Generated by Django 3.1.5 on 2026-09-07 15:38 + +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), + ('globals', '0012_acad_role_module_access'), + ('academic_procedures', '0054_merge_20260828_1758'), + ] + + operations = [ + migrations.CreateModel( + name='ThesisTopicChangeRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('new_category', models.CharField(blank=True, choices=[('Regular', 'Regular'), ('Sponsored', 'Sponsored'), ('External', 'External')], max_length=20)), + ('new_broad_area', models.CharField(blank=True, max_length=200)), + ('new_research_theme', models.TextField(blank=True)), + ('current_supervisor_consented', models.BooleanField(default=False)), + ('current_co_supervisor_consented', models.BooleanField(default=False)), + ('new_supervisor_consented', models.BooleanField(default=False)), + ('new_co_supervisor_consented', models.BooleanField(default=False)), + ('decline_remarks', models.TextField(blank=True)), + ('status', models.CharField(choices=[('pending_consents', 'Pending Consents'), ('declined', 'Declined by a Consenter'), ('hod_pending', 'Pending with HOD'), ('hod_rejected', 'Rejected by HOD'), ('dean_pending', 'Pending with Dean'), ('dean_rejected', 'Rejected by Dean'), ('approved', 'Approved')], default='pending_consents', max_length=20)), + ('hod_remarks', models.TextField(blank=True)), + ('hod_at', models.DateTimeField(blank=True, null=True)), + ('dean_remarks', models.TextField(blank=True)), + ('dean_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('dean_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_change_dean_reviews', to=settings.AUTH_USER_MODEL)), + ('declined_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_change_declines', to=settings.AUTH_USER_MODEL)), + ('hod_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_change_hod_reviews', to=settings.AUTH_USER_MODEL)), + ('new_co_supervisor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='thesis_change_requests_as_new_co_supervisor', to='globals.faculty')), + ('new_supervisor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='thesis_change_requests_as_new_supervisor', to='globals.faculty')), + ('thesis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='change_requests', to='academic_procedures.thesistopic')), + ], + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0056_thesiscommitteechangerequest.py b/FusionIIIT/applications/academic_procedures/migrations/0056_thesiscommitteechangerequest.py new file mode 100644 index 000000000..a2976cfac --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0056_thesiscommitteechangerequest.py @@ -0,0 +1,33 @@ +# Generated by Django 3.1.5 on 2026-09-08 09:08 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0012_acad_role_module_access'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('academic_procedures', '0055_thesistopicchangerequest'), + ] + + operations = [ + migrations.CreateModel( + name='ThesisCommitteeChangeRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('hod_pending', 'Pending with HOD'), ('hod_rejected', 'Rejected by HOD, Returned to Supervisor'), ('dean_pending', 'Pending with Dean'), ('dean_rejected', 'Rejected by Dean, Returned to Supervisor'), ('approved', 'Approved')], default='hod_pending', max_length=20)), + ('hod_remarks', models.TextField(blank=True)), + ('hod_at', models.DateTimeField(blank=True, null=True)), + ('dean_remarks', models.TextField(blank=True)), + ('dean_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('dean_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_committee_change_dean_reviews', to=settings.AUTH_USER_MODEL)), + ('hod_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_committee_change_hod_reviews', to=settings.AUTH_USER_MODEL)), + ('members', models.ManyToManyField(related_name='thesis_committee_change_requests', to='globals.Faculty')), + ('thesis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='committee_change_requests', to='academic_procedures.thesistopic')), + ], + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/models.py b/FusionIIIT/applications/academic_procedures/models.py index 0a7fa1908..65ebe2d25 100644 --- a/FusionIIIT/applications/academic_procedures/models.py +++ b/FusionIIIT/applications/academic_procedures/models.py @@ -1205,6 +1205,127 @@ def __str__(self): return f"{self.member} on {self.thesis}" +class ThesisTopicChangeRequest(models.Model): + """Student-initiated request to change an already dean_approved ThesisTopic's + topic (category/broad_area/research_theme) and/or supervisor/co-supervisor. + + Follows the same consent -> HOD -> Dean chain as the original ThesisTopic + submission, plus consent from any newly-proposed supervisor/co-supervisor + (the outgoing supervisor/co-supervisor, if any, is covered by the + current_* consent -- they're always asked to sign off on the change, + whatever it is). On Dean approval the changes are applied directly to the + live ThesisTopic (and its CommitteeMember rows kept in sync); this record + stays behind as the audit trail. Rejection at any stage is terminal -- the + student submits a fresh request to retry, rather than editing this one in + place (unlike ThesisTopic itself, which supports resubmission-in-place). + """ + STATUS_CHOICES = [ + ('pending_consents', 'Pending Consents'), + ('declined', 'Declined by a Consenter'), + ('hod_pending', 'Pending with HOD'), + ('hod_rejected', 'Rejected by HOD'), + ('dean_pending', 'Pending with Dean'), + ('dean_rejected', 'Rejected by Dean'), + ('approved', 'Approved'), + ] + + thesis = models.ForeignKey(ThesisTopic, related_name='change_requests', on_delete=models.CASCADE) + + # Topic-change fields -- blank means "no change requested" for that field. + new_category = models.CharField(max_length=20, blank=True, choices=[ + ('Regular', 'Regular'), + ('Sponsored', 'Sponsored'), + ('External', 'External'), + ]) + new_broad_area = models.CharField(max_length=200, blank=True) + new_research_theme = models.TextField(blank=True) + + # Supervisor-change fields -- null means "no change requested". + new_supervisor = models.ForeignKey( + Faculty, related_name='thesis_change_requests_as_new_supervisor', + on_delete=models.CASCADE, null=True, blank=True, + ) + new_co_supervisor = models.ForeignKey( + Faculty, related_name='thesis_change_requests_as_new_co_supervisor', + on_delete=models.CASCADE, null=True, blank=True, + ) + + # Consents. current_* apply to the thesis's supervisor/co-supervisor as of + # the request -- always required (current_co_supervisor_consented is only + # actually checked if the thesis has a co-supervisor). new_* are only + # checked if that field is actually being changed on this request. + current_supervisor_consented = models.BooleanField(default=False) + current_co_supervisor_consented = models.BooleanField(default=False) + new_supervisor_consented = models.BooleanField(default=False) + new_co_supervisor_consented = models.BooleanField(default=False) + decline_remarks = models.TextField(blank=True) + declined_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='thesis_change_declines', + ) + + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending_consents') + hod_remarks = models.TextField(blank=True) + hod_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='thesis_change_hod_reviews', + ) + hod_at = models.DateTimeField(null=True, blank=True) + dean_remarks = models.TextField(blank=True) + dean_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='thesis_change_dean_reviews', + ) + dean_at = models.DateTimeField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Change request for {self.thesis} ({self.status})" + + +class ThesisCommitteeChangeRequest(models.Model): + """Supervisor-initiated request to change the RPC committee membership + for an already dean_approved ThesisTopic. The supervisor proposes a + complete new committee (replacing the current one, supervisor/ + co-supervisor always kept as members regardless of what's submitted) -> + HOD -> Dean Academic approval, which applies the new membership to + CommitteeMember. Rejection (HOD or Dean) sends it back to the supervisor + to edit and resubmit the same record in place -- mirrors ThesisTopic's + own resubmission semantics, not a fresh request like + ThesisTopicChangeRequest. + """ + STATUS_CHOICES = [ + ('hod_pending', 'Pending with HOD'), + ('hod_rejected', 'Rejected by HOD, Returned to Supervisor'), + ('dean_pending', 'Pending with Dean'), + ('dean_rejected', 'Rejected by Dean, Returned to Supervisor'), + ('approved', 'Approved'), + ] + + thesis = models.ForeignKey(ThesisTopic, related_name='committee_change_requests', on_delete=models.CASCADE) + members = models.ManyToManyField(Faculty, related_name='thesis_committee_change_requests') + + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='hod_pending') + hod_remarks = models.TextField(blank=True) + hod_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='thesis_committee_change_hod_reviews', + ) + hod_at = models.DateTimeField(null=True, blank=True) + dean_remarks = models.TextField(blank=True) + dean_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='thesis_committee_change_dean_reviews', + ) + dean_at = models.DateTimeField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Committee change request for {self.thesis} ({self.status})" + + class ProgressSeminarEntry(models.Model): """PhD Seminar reports with versioning and RPC approval.""" thesis = models.ForeignKey(ThesisTopic, on_delete=models.CASCADE, related_name='seminars') diff --git a/FusionIIIT/applications/examination/api/views.py b/FusionIIIT/applications/examination/api/views.py index 3b2a2c25b..19cd36420 100644 --- a/FusionIIIT/applications/examination/api/views.py +++ b/FusionIIIT/applications/examination/api/views.py @@ -499,6 +499,41 @@ def is_valid_grade(grade: str, course_code: str) -> bool: return grade in ALLOWED_GRADES +SPECIAL_NUMERIC_GRADE_CODES = {"PR4001", "PR4002", "BTP4001"} +LETTER_GRADE_SCHEME = {"O", "A+", "A", "B+", "B", "C+", "C", "D+", "D", "F", "CD"} +PASS_FAIL_GRADE_SCHEME = {"S", "X"} +SCHEME_LABELS = { + "letter": "letter grades (O/A+/A/.../F/CD)", + "pass_fail": "pass/fail grades (S/X)", +} + + +def grade_scheme(grade): + """Classify a grade string as 'letter' or 'pass_fail'. Returns None for an + unrecognized value, or for a PR4001/PR4002/BTP4001 numeric grade -- those use + their own separate scheme (checked entirely by is_valid_grade's course-code + branch) and aren't subject to the letter-vs-pass/fail consistency check.""" + g = (grade or "").strip().upper() + if g in LETTER_GRADE_SCHEME: + return "letter" + if g in PASS_FAIL_GRADE_SCHEME: + return "pass_fail" + return None + + +def established_grade_scheme(course_id, academic_year, semester_type): + """The letter/pass-fail scheme already on file for this course+year+semester + (from any existing graded Student_grades row), or None if nothing is graded + yet -- in which case the first valid row of a new upload sets the scheme.""" + existing = ( + Student_grades.objects + .filter(course_id=course_id, academic_year=academic_year, semester_type=semester_type) + .exclude(grade__isnull=True).exclude(grade='') + .first() + ) + return grade_scheme(existing.grade) if existing else None + + # Remark shown next to a grade once a course has more than one linked attempt: # R = repeat of the same course, qualified by why -- BL after a fail, CD when the # earlier attempt was dropped, IM on an already-cleared course; S = substitute, @@ -2131,6 +2166,10 @@ def post(self, request): ) # 10) ATOMIC PROCESSING + is_special_numeric_course = course.code.strip().upper() in SPECIAL_NUMERIC_GRADE_CODES + csv_scheme = None if is_special_numeric_course else established_grade_scheme( + course_id, academic_year, semester_type + ) errors = [] with transaction.atomic(): # Reset reSubmit only for this submission's roster (already scoped @@ -2209,6 +2248,19 @@ def post(self, request): ) continue + # c2) SAME SCHEME AS THE REST OF THIS COURSE? (no mixing letter + # grades with pass/fail S/X within one course+year+semester) + if not is_special_numeric_course: + row_scheme = grade_scheme(grade) + if csv_scheme is None: + csv_scheme = row_scheme + elif row_scheme != csv_scheme: + errors.append( + f"Row {idx}: Grade scheme mismatch for {roll_no} — this course is using " + f"{SCHEME_LABELS[csv_scheme]}, but '{grade}' is a {SCHEME_LABELS[row_scheme]} value." + ) + continue + # d) DETERMINE SEMESTER & BATCH semester = sem_csv or stud.curr_semester_no batch = stud.batch @@ -3321,6 +3373,11 @@ def post(self, request): status=status.HTTP_400_BAD_REQUEST, ) + try: + course_obj = Courses.objects.get(id=course_id) + except Courses.DoesNotExist: + return Response({"error": "Invalid course ID."}, status=status.HTTP_400_BAD_REQUEST) + # Faculty may only preview grades for courses they teach; acadadmin/Dean: any. if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): try: @@ -3404,21 +3461,45 @@ def post(self, request): status=status.HTTP_400_BAD_REQUEST, ) + is_special_numeric_course = course_obj.code.strip().upper() in SPECIAL_NUMERIC_GRADE_CODES + seen_scheme = None if is_special_numeric_course else established_grade_scheme( + course_id, academic_year, semester_type + ) + preview_rows = [] for row in reader: roll_no = row["roll_no"] # Check if the current roll number is registered. is_registered = roll_no in registered_rollnos + grade_val = row["grade"] + + grade_valid = True + grade_error = None + if not is_valid_grade(grade_val, course_obj.code): + grade_valid = False + grade_error = f"Invalid grade '{grade_val}'." + elif not is_special_numeric_course: + row_scheme = grade_scheme(grade_val) + if seen_scheme is None: + seen_scheme = row_scheme + elif row_scheme != seen_scheme: + grade_valid = False + grade_error = ( + f"Grade scheme mismatch: this course is using {SCHEME_LABELS[seen_scheme]}, " + f"but '{grade_val}' is a {SCHEME_LABELS[row_scheme]} value." + ) # Add additional data (e.g. name, grades, remarks, semester, branch) as in CSV preview_rows.append({ "roll_no": roll_no, "name": row["name"], "branch": row.get("branch", ""), - "grades": row["grade"], + "grades": grade_val, "remarks": row["remarks"], "semester": row["semester"], - "is_registered": is_registered + "is_registered": is_registered, + "grade_valid": grade_valid, + "grade_error": grade_error, }) return Response({"preview": preview_rows}, status=status.HTTP_200_OK) diff --git a/FusionIIIT/applications/globals/api/serializers.py b/FusionIIIT/applications/globals/api/serializers.py index 559e7a47d..b6d845e0e 100644 --- a/FusionIIIT/applications/globals/api/serializers.py +++ b/FusionIIIT/applications/globals/api/serializers.py @@ -10,7 +10,8 @@ from applications.placement_cell.api.serializers import (SkillSerializer, HasSerializer, EducationSerializer, CourseSerializer, ExperienceSerializer, ProjectSerializer, AchievementSerializer, PublicationSerializer, - PatentSerializer, PlacementStatusSerializer, NotifyStudentSerializer) + PatentSerializer, PlacementStatusSerializer, NotifyStudentSerializer, + StudentBankDetailsSerializer) User = get_user_model() diff --git a/FusionIIIT/applications/globals/api/urls.py b/FusionIIIT/applications/globals/api/urls.py index 87e4f15f8..e2c54ee2f 100644 --- a/FusionIIIT/applications/globals/api/urls.py +++ b/FusionIIIT/applications/globals/api/urls.py @@ -18,6 +18,12 @@ re_path(r'^update-role/', views.update_last_selected_role, name='update_last_selected_role'), # Profile endpoints + # bank-details must be listed before the (?P.+) pattern below, + # since that pattern has no end anchor and would otherwise swallow this + # route as if "bank-details" were a username (same reasoning as + # notification/unread-count/ vs notification/ further down). + re_path(r'^profile/bank-details/(?P.+)/', views.acadadmin_student_bank_details, name='acadadmin-student-bank-details'), + re_path(r'^profile/bank-details/', views.student_bank_details, name='student-bank-details'), re_path(r'^profile/(?P.+)/', views.profile, name='profile-api'), re_path(r'^profile/', views.profile, name='profile-api'), re_path(r'^profile_update/', views.profile_update, name='update-profile-api'), diff --git a/FusionIIIT/applications/globals/api/views.py b/FusionIIIT/applications/globals/api/views.py index 41d484be3..73c40c3c2 100644 --- a/FusionIIIT/applications/globals/api/views.py +++ b/FusionIIIT/applications/globals/api/views.py @@ -2,7 +2,8 @@ from applications.academic_information.models import Student from applications.placement_cell.models import (Achievement, Course, Education, Experience, Has, Patent, - Project, Publication, Skill) + Project, Publication, Skill, + StudentBankDetails) from applications.programme_curriculum.models import ( Course as CurriculumCourse, CourseSlot, CourseInstructor ) @@ -418,6 +419,52 @@ def profile_delete(request, id): return Response({'message': 'Patent deleted successfully'}, status=status.HTTP_200_OK) return Response({'error': 'Wrong attribute'}, status=status.HTTP_400_BAD_REQUEST) + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def student_bank_details(request): + """ + GET /profile/bank-details/ -> the requesting student's own bank details ({} if none saved yet) + POST /profile/bank-details/ -> create/update (one record per student) + Kept as its own endpoint, deliberately separate from the general + profile()/profile_update() blob -- bank details are sensitive and must + never be exposed via the unauthenticated-for-other-users profile lookup. + """ + try: + student = Student.objects.get(id=request.user.extrainfo) + except Student.DoesNotExist: + return Response({'error': 'Only students have bank details on their profile'}, status=status.HTTP_403_FORBIDDEN) + + if request.method == 'GET': + details = StudentBankDetails.objects.filter(unique_id=student).first() + if not details: + return Response({}, status=status.HTTP_200_OK) + return Response(serializers.StudentBankDetailsSerializer(details).data, status=status.HTTP_200_OK) + + serializer_instance = StudentBankDetails.objects.filter(unique_id=student).first() + serializer = serializers.StudentBankDetailsSerializer( + instance=serializer_instance, data=request.data, partial=True, + ) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + serializer.save(unique_id=student) + return Response(serializer.data, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +@role_required(['acadadmin']) +def acadadmin_student_bank_details(request, roll_no): + """GET /profile/bank-details// -> Academic Office view of a specific student's bank details.""" + student = get_object_or_404(Student, id=roll_no) + details = StudentBankDetails.objects.filter(unique_id=student).first() + if not details: + return Response({}, status=status.HTTP_200_OK) + return Response(serializers.StudentBankDetailsSerializer(details).data, status=status.HTTP_200_OK) + + @api_view(['POST']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) diff --git a/FusionIIIT/applications/placement_cell/api/serializers.py b/FusionIIIT/applications/placement_cell/api/serializers.py index 13ccc8cd9..2309a583b 100644 --- a/FusionIIIT/applications/placement_cell/api/serializers.py +++ b/FusionIIIT/applications/placement_cell/api/serializers.py @@ -4,6 +4,7 @@ from applications.placement_cell.models import (Achievement, Course, Education, Experience, Has, Patent, Project, Publication, Skill, + StudentBankDetails, PlacementAppeal, PlacementStatus, NotifyStudent, PlacementAnnouncement, OffCampusPlacement, @@ -95,6 +96,13 @@ class Meta: model = Patent fields = ('__all__') +class StudentBankDetailsSerializer(serializers.ModelSerializer): + + class Meta: + model = StudentBankDetails + fields = ('id', 'account_holder_name', 'bank_name', 'branch_name', + 'account_number', 'ifsc_code', 'updated_at') + class NotifyStudentSerializer(serializers.ModelSerializer): class Meta: diff --git a/FusionIIIT/applications/placement_cell/migrations/0016_studentbankdetails.py b/FusionIIIT/applications/placement_cell/migrations/0016_studentbankdetails.py new file mode 100644 index 000000000..919828c4b --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0016_studentbankdetails.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.5 on 2026-09-08 09:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0010_normalize_phd_programme'), + ('placement_cell', '0015_placementcalendarevent'), + ] + + operations = [ + migrations.CreateModel( + name='StudentBankDetails', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('account_holder_name', models.CharField(default='', max_length=200)), + ('bank_name', models.CharField(default='', max_length=200)), + ('branch_name', models.CharField(blank=True, default='', max_length=200)), + ('account_number', models.CharField(default='', max_length=30)), + ('ifsc_code', models.CharField(default='', max_length=20)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('unique_id', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='bank_details', to='academic_information.student')), + ], + ), + ] diff --git a/FusionIIIT/applications/placement_cell/models.py b/FusionIIIT/applications/placement_cell/models.py index 793fb555c..11ccf9ad1 100644 --- a/FusionIIIT/applications/placement_cell/models.py +++ b/FusionIIIT/applications/placement_cell/models.py @@ -280,6 +280,24 @@ def __str__(self): return '{} - {}'.format(self.unique_id.id, self.event_name) +class StudentBankDetails(models.Model): + """A student's own bank account details, for stipend/scholarship/refund + disbursement. One record per student -- kept as its own model (like + academic_procedures.ExaminerBankDetails) rather than folded into a list + of profile sub-records, since it's a single set of sensitive fields, not + a many-entries-over-time thing like Education/Experience/etc.""" + unique_id = models.OneToOneField(Student, on_delete=models.CASCADE, related_name='bank_details') + account_holder_name = models.CharField(max_length=200, default='') + bank_name = models.CharField(max_length=200, default='') + branch_name = models.CharField(max_length=200, default='', blank=True) + account_number = models.CharField(max_length=30, default='') + ifsc_code = models.CharField(max_length=20, default='') + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Bank details for {self.unique_id.id}" + + class MessageOfficer(models.Model): message = models.CharField(max_length=100, default='') timestamp = models.DateTimeField(auto_now=True)