diff --git a/FusionIIIT/applications/academic_procedures/api/urls.py b/FusionIIIT/applications/academic_procedures/api/urls.py index c2fa5d1b4..bfefd44a8 100644 --- a/FusionIIIT/applications/academic_procedures/api/urls.py +++ b/FusionIIIT/applications/academic_procedures/api/urls.py @@ -92,6 +92,7 @@ url(r'^acad/batch_change/apply/$', views.apply_batch_changes, name='batch-apply'), url(r'^acad/promote/students/$', views.list_students_in_batch_semester_promotion, name='promote-batch-students'), url(r'^acad/promote/apply/$', views.apply_promotion, name='promote-apply'), + url(r'^acad/demote/apply/$', views.apply_demotion, name='demote-apply'), url(r'^get_next_sem_courses' , views.get_next_sem_courses , name= 'get_next_sem_courses'), @@ -182,6 +183,48 @@ 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'), + # ======================================================================== + # Progress Seminar Slot Semester-Level Registration (Enrollment) + # ======================================================================== + # Student + url(r'^stu/progress-seminar-enrollment/$', views.student_progress_seminar_enrollment_api, + name='student-progress-seminar-enrollment'), + # Acad Admin + url(r'^acadadmin/progress-seminar-enrollments/$', views.admin_progress_seminar_enrollment_list, + name='admin-progress-seminar-enrollment-list'), + url(r'^acadadmin/progress-seminar-enrollments/verify/$', views.admin_verify_progress_seminar_enrollments, + name='admin-verify-progress-seminar-enrollments'), + url(r'^acadadmin/progress-seminar-enrollments/reject/$', views.admin_reject_progress_seminar_enrollments, + name='admin-reject-progress-seminar-enrollments'), + + # ======================================================================== + # Teaching Credit Slot Semester-Level Registration (Enrollment) + # ======================================================================== + # Student + url(r'^stu/teaching-credit-enrollment/$', views.student_teaching_credit_enrollment_api, + name='student-teaching-credit-enrollment'), + # Acad Admin + url(r'^acadadmin/teaching-credit-enrollments/$', views.admin_teaching_credit_enrollment_list, + name='admin-teaching-credit-enrollment-list'), + url(r'^acadadmin/teaching-credit-enrollments/verify/$', views.admin_verify_teaching_credit_enrollments, + name='admin-verify-teaching-credit-enrollments'), + url(r'^acadadmin/teaching-credit-enrollments/reject/$', views.admin_reject_teaching_credit_enrollments, + name='admin-reject-teaching-credit-enrollments'), + + # ======================================================================== + # PhD Course (Coursework) Registration — standalone workflow, independent + # of the UG/PG backlog add-course flow (add_course / CourseAddRequest) + # ======================================================================== + # Student + url(r'^stu/phd/status/$', views.phd_student_status, name='phd-student-status'), + url(r'^stu/phd/course-slots/$', views.phd_course_slots, name='phd-course-slots'), + url(r'^stu/phd/course-slots/courses/$', views.phd_course_slot_courses, name='phd-course-slot-courses'), + url(r'^stu/phd/course-request/$', views.phd_submit_course_request, name='phd-submit-course-request'), + url(r'^stu/phd/my-course-requests/$', views.phd_my_course_requests, name='phd-my-course-requests'), + # Acad Admin + url(r'^acadadmin/phd/course-requests/$', views.phd_admin_list_requests, name='phd-admin-list-requests'), + url(r'^acadadmin/phd/course-requests/process/$', views.phd_admin_process_requests, name='phd-admin-process-requests'), + # 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'), @@ -219,4 +262,120 @@ # Review Form (External reviewers) url(r'^review/(?P[0-9a-f-]+)/$', views.review_detail, name='review_detail'), + + # ======================================================================== + # PhD Comprehensive Examination + # ======================================================================== + + # Student + url(r'^stu/comprehensive-exam/$', views.student_comprehensive_exam_api, name='student-comprehensive-exam'), + url(r'^stu/comprehensive-exam/attempt/(?P\d+)/opt-subjects/$', + views.student_opt_subjects_api, name='student-comprehensive-exam-opt-subjects'), + + # Supervisor + url(r'^supervisor/comprehensive-exam/dashboard/$', + views.supervisor_comprehensive_exam_dashboard, name='supervisor-comprehensive-exam-dashboard'), + url(r'^supervisor/comprehensive-exam/student-info/(?P[^/]+)/$', + views.supervisor_student_academic_info, name='supervisor-comprehensive-exam-student-info'), + url(r'^supervisor/comprehensive-exam/propose/$', + views.supervisor_propose_comprehensive_exam, name='supervisor-comprehensive-exam-propose'), + url(r'^supervisor/comprehensive-exam/(?P\d+)/$', + views.supervisor_comprehensive_exam_detail, name='supervisor-comprehensive-exam-detail'), + url(r'^supervisor/comprehensive-exam/(?P\d+)/resubmit/$', + views.supervisor_resubmit_proposal, name='supervisor-comprehensive-exam-resubmit'), + url(r'^supervisor/comprehensive-exam/(?P\d+)/float-subjects/$', + views.supervisor_float_subjects, name='supervisor-comprehensive-exam-float-subjects'), + url(r'^supervisor/comprehensive-exam/attempt/(?P\d+)/confirm-subjects/$', + views.supervisor_confirm_opted_subjects, name='supervisor-comprehensive-exam-confirm-subjects'), + url(r'^courses/dropdown/$', views.list_courses_for_dropdown, name='list-courses-dropdown'), + + # Academic Office (acadadmin) + url(r'^acadadmin/comprehensive-exam/$', + views.academic_office_comprehensive_exam_list, name='academic-office-comprehensive-exam-list'), + url(r'^acadadmin/comprehensive-exam/(?P\d+)/verify/$', + views.academic_office_verify_comprehensive_exam, name='academic-office-comprehensive-exam-verify'), + + # Convener (Dean Academic stands in for DPGC/PGCS for now) + url(r'^dean/comprehensive-exam/dashboard/$', + views.convener_comprehensive_exam_dashboard, name='convener-comprehensive-exam-dashboard'), + url(r'^dean/comprehensive-exam/(?P\d+)/approve-committee/$', + views.convener_approve_committee, name='convener-comprehensive-exam-approve-committee'), + url(r'^dean/comprehensive-exam/attempt/(?P\d+)/report/$', + views.convener_submit_result, name='convener-comprehensive-exam-report'), + + # HOD (as discipline coordinator) + url(r'^hod/comprehensive-exam/dashboard/$', + views.hod_comprehensive_exam_dashboard, name='hod-comprehensive-exam-dashboard'), + url(r'^hod/comprehensive-exam/attempt/(?P\d+)/review-subjects/$', + views.hod_review_subjects, name='hod-comprehensive-exam-review-subjects'), + + # ======================================================================== + # PhD Open Seminar + # ======================================================================== + + # Shared + url(r'^supervisor/open-seminar/eligibility/(?P[^/]+)/$', + views.open_seminar_eligibility_preview, name='open-seminar-eligibility-preview'), + + # Student + url(r'^stu/open-seminar/$', views.student_open_seminar_api, name='student-open-seminar'), + + # Supervisor + url(r'^supervisor/open-seminar/dashboard/$', + views.supervisor_open_seminar_dashboard, name='supervisor-open-seminar-dashboard'), + url(r'^supervisor/open-seminar/propose/$', + views.supervisor_propose_open_seminar, name='supervisor-open-seminar-propose'), + url(r'^supervisor/open-seminar/(?P\d+)/$', + views.supervisor_open_seminar_detail, name='supervisor-open-seminar-detail'), + url(r'^supervisor/open-seminar/(?P\d+)/resubmit/$', + views.supervisor_resubmit_open_seminar, name='supervisor-open-seminar-resubmit'), + url(r'^supervisor/open-seminar/(?P\d+)/retry/$', + views.supervisor_retry_open_seminar, name='supervisor-open-seminar-retry'), + + # Convener (Dean Academic stands in for DPGC/PGCS for now) + url(r'^dean/open-seminar/dashboard/$', + views.convener_open_seminar_dashboard, name='convener-open-seminar-dashboard'), + url(r'^dean/open-seminar/attempt/(?P\d+)/approve-committee/$', + views.convener_approve_open_seminar_committee, name='convener-open-seminar-approve-committee'), + url(r'^dean/open-seminar/attempt/(?P\d+)/report/$', + views.convener_submit_open_seminar_report, name='convener-open-seminar-report'), + + # Dean Nominee (ad-hoc faculty appointment) + url(r'^faculty/open-seminar-nominee/dashboard/$', + views.dean_nominee_open_seminar_dashboard, name='dean-nominee-open-seminar-dashboard'), + url(r'^faculty/open-seminar-nominee/attempt/(?P\d+)/report/$', + views.dean_nominee_submit_open_seminar_report, name='dean-nominee-open-seminar-report'), + + # ======================================================================== + # PhD Teaching Credit + # ======================================================================== + + # Student + url(r'^stu/teaching-credit/$', views.student_teaching_credit_api, name='student-teaching-credit'), + url(r'^stu/teaching-credit/propose/$', + views.student_propose_teaching_credit, name='student-teaching-credit-propose'), + url(r'^stu/teaching-credit/(?P\d+)/$', + views.student_teaching_credit_detail, name='student-teaching-credit-detail'), + url(r'^stu/teaching-credit/(?P\d+)/resubmit/$', + views.student_resubmit_teaching_credit, name='student-teaching-credit-resubmit'), + url(r'^stu/teaching-credit/evaluation-targets/$', + views.student_teaching_credit_evaluation_targets, name='student-teaching-credit-evaluation-targets'), + url(r'^stu/teaching-credit/(?P\d+)/evaluate/$', + views.student_submit_teaching_credit_evaluation, name='student-teaching-credit-evaluate'), + + # HOD + url(r'^hod/teaching-credit/dashboard/$', + views.hod_teaching_credit_dashboard, name='hod-teaching-credit-dashboard'), + url(r'^hod/teaching-credit/(?P\d+)/decide/$', + views.hod_decide_teaching_credit, name='hod-teaching-credit-decide'), + url(r'^hod/teaching-credit/(?P\d+)/complete/$', + views.hod_complete_teaching_credit, name='hod-teaching-credit-complete'), + + # Supervisor (read-only) + url(r'^supervisor/teaching-credit/$', + views.supervisor_teaching_credit_list, name='supervisor-teaching-credit-list'), + + # Academic Office (read-only) + url(r'^acadadmin/teaching-credit/$', + views.academic_office_teaching_credit_list, name='academic-office-teaching-credit-list'), ] \ 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 20b8e10a7..35afd5e16 100644 --- a/FusionIIIT/applications/academic_procedures/api/views.py +++ b/FusionIIIT/applications/academic_procedures/api/views.py @@ -14,7 +14,7 @@ from django.db import transaction from django.db.models import Prefetch from django.db.models.functions import Concat,ExtractYear,ExtractMonth,ExtractDay,Cast -from django.db.models import Max,Value,IntegerField,CharField,F,Sum, Case, When +from django.db.models import Max,Value,IntegerField,CharField,F,Sum, Case, When, Count from io import BytesIO import json import xlrd @@ -41,7 +41,7 @@ from applications.academic_procedures.models import ( MTechGraduateSeminarReport, PhDProgressExamination, Student, Curriculum , ThesisTopicProcess, InitialRegistrations, FinalRegistration, SemesterMarks,backlog_course, - BranchChange , StudentRegistrationChecks, Semester , FeePayments , course_registration, course_replacement, AssistantshipClaim, Assignment, StipendRequest, CourseReplacementRequest, SwayamReplacementRequest, CourseDropRequest, CourseAddRequest, BatchChangeHistory, FeedbackQuestion, FeedbackResponse, FeedbackFilled, FeedbackOption) + BranchChange , StudentRegistrationChecks, Semester , FeePayments , course_registration, course_replacement, AssistantshipClaim, Assignment, StipendRequest, CourseReplacementRequest, SwayamReplacementRequest, CourseDropRequest, CourseAddRequest, BatchChangeHistory, FeedbackQuestion, FeedbackResponse, FeedbackFilled, FeedbackOption, PhDCourseRegistrationRequest) from applications.academic_information.models import (Curriculum_Instructor , Calendar) from applications.online_cms.models import Student_grades @@ -5814,6 +5814,35 @@ def apply_promotion(request): return Response({"detail": "Promotion applied."}, status=status.HTTP_200_OK) +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def apply_demotion(request): + """Move selected students one semester back (correction for over-promotion). + + Only decrements curr_semester_no (floored at 1); it does not delete any + course registrations, so it is a safe inverse of an accidental promotion. + """ + data = request.data # list of student IDs + errors = [] + with transaction.atomic(): + for idx, sid in enumerate(data): + try: + student = Student.objects.get(id=sid) + except Student.DoesNotExist: + errors.append({"index": idx, "detail": f"Student {sid} not found."}) + continue + old_sem = student.curr_semester_no + if old_sem is None or old_sem <= 1: + errors.append({"index": idx, "detail": f"Student {student.id_id} is already in semester 1; cannot demote."}) + continue + student.curr_semester_no = old_sem - 1 + student.save() + if errors: + return Response({"errors": errors}, status=status.HTTP_207_MULTI_STATUS) + return Response({"detail": "Demotion applied."}, status=status.HTTP_200_OK) + + # @api_view(["GET"]) # @permission_classes([IsAuthenticated]) # def download_user_template(request): @@ -6164,16 +6193,28 @@ def supervisor_thesis_topic_dashboard(request): 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) + def serialize_for_viewer(t): + d = thesis_to_dict(t) + is_sup = t.supervisor_id == ex.username + is_co = bool(t.co_supervisor_id) and t.co_supervisor_id == ex.username + d['is_supervisor'] = is_sup + d['is_co_supervisor'] = is_co + d['my_consent_given'] = ( + t.supervisor_consented if is_sup + else t.co_supervisor_consented if is_co + else False + ) + return d + return JsonResponse({ - 'pending': [thesis_to_dict(t) for t in pending_qs], - 'forwarded': [thesis_to_dict(t) for t in forwarded_qs], + 'pending': [serialize_for_viewer(t) for t in pending_qs], + 'forwarded': [serialize_for_viewer(t) for t in forwarded_qs], }) @@ -6254,6 +6295,35 @@ def supervisor_review_api(request, pk): return JsonResponse({"error": "Not authorized."}, status=403) + +def get_hod_disciplines(user): + """Discipline acronyms this user is HOD of, parsed from designation + names like 'HOD (CSE)' -> 'CSE'. Used to scope a dashboard listing to + every discipline the user is HOD for.""" + hod_designations = HoldsDesignation.objects.filter( + working=user, + designation__name__icontains='HOD' + ).values_list('designation__name', flat=True) + + 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) + return hod_disciplines + + +def is_hod_of_discipline(user, discipline_acronym): + """True if `user` holds the exact 'HOD ()' designation. + Used to authorize a single action against one specific discipline.""" + if not discipline_acronym: + return False + return HoldsDesignation.objects.filter( + working=user, + designation__name=f"HOD ({discipline_acronym})" + ).exists() + + # 4. HOD endpoints @api_view(['GET']) @@ -6276,17 +6346,7 @@ def hod_dashboard(request): 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) + hod_disciplines = get_hod_disciplines(user) qs = ThesisTopic.objects.filter(status__in=all_statuses).select_related('student', 'student__batch_id', 'student__batch_id__discipline') @@ -6322,16 +6382,8 @@ def hod_review_api(request, pk): 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 + + is_hod = is_hod_of_discipline(user, student_discipline_acronym) if request.method == 'GET': data = thesis_to_dict(thesis) @@ -6570,16 +6622,37 @@ def dean_generate_pdf_api(request, pk): # Seminar Views # 1. STUDENT +def _progress_seminar_catalog_entry(semester): + """The catalog Seminar (code/name) tied to a semester's progress seminar + slot, if one has been configured — analogous to a thesis slot's catalog + Thesis. Returns (code, name), either possibly None.""" + if semester is None: + return None, None + slot = ProgressSeminarSlot.objects.filter(semester=semester).first() + if slot is None: + return None, None + catalog = slot.seminars.first() + if catalog is None: + return None, None + return catalog.code, catalog.name + + @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')] + data = [] + for s in thesis.seminars.order_by('version'): + seminar_code, seminar_name = _progress_seminar_catalog_entry(s.semester) + data.append({ + "id": s.id, + "version": s.version, + "semester_no": s.semester.semester_no if s.semester else None, + "seminar_code": seminar_code, + "seminar_name": seminar_name, + "status": s.status, + "created_at": s.created_at.isoformat(), + }) print(data) print(thesis.status) @@ -6596,9 +6669,19 @@ def create_report(request, thesis_pk): last = thesis.seminars.order_by('-version').first() version = (last.version + 1) if last else 1 - seminar = SeminarEntry.objects.create( + student = thesis.student + try: + semester = Semester.objects.get( + curriculum=student.batch_id.curriculum, + semester_no=student.curr_semester_no, + ) + except Semester.DoesNotExist: + semester = None + + seminar = ProgressSeminarEntry.objects.create( thesis=thesis, version=version, + semester=semester, status='rpc_pending', seminar_date = request.data.get('date') or None, seminar_time = request.data.get('time') or None, @@ -6607,31 +6690,11 @@ def create_report(request, thesis_pk): summary_curr = request.data.get('curr',''), future_plan = request.data.get('future',''), upload_doc = request.FILES.get('doc', None), + pub_published_or_accepted = int(request.data.get('pub_published_or_accepted', 0) or 0), + pub_presented_unpublished = int(request.data.get('pub_presented_unpublished', 0) or 0), + pub_submitted_under_review = int(request.data.get('pub_submitted_under_review', 0) or 0), ) - # 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." @@ -6640,11 +6703,12 @@ def create_report(request, thesis_pk): @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) + s = get_object_or_404(ProgressSeminarEntry, pk=pk, thesis__student_id=request.user.username) return JsonResponse({ - "id": s.id, - "version": s.version, - "status": s.status, + "id": s.id, + "version": s.version, + "semester_no": s.semester.semester_no if s.semester else None, + "status": s.status, "date": str(s.seminar_date or ""), "time": str(s.seminar_time or ""), "venue": s.seminar_venue, @@ -6652,15 +6716,9 @@ def detail_report(request, pk): "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() - ], + "pub_published_or_accepted": s.pub_published_or_accepted, + "pub_presented_unpublished": s.pub_presented_unpublished, + "pub_submitted_under_review": s.pub_submitted_under_review, }) # 2. RPC @@ -6669,18 +6727,23 @@ def detail_report(request, pk): @permission_classes([IsAuthenticated]) def rpc_seminar_list(request): faculty = get_object_or_404(Faculty, id__user=request.user) - all_entries = SeminarEntry.objects.filter( + all_entries = ProgressSeminarEntry.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, + "id": s.id, + "version": s.version, + "semester_no": s.semester.semester_no if s.semester else None, + "roll_number": s.thesis.student.id.id, + "student": s.thesis.student.id.user.get_full_name(), + "thesis": s.thesis.research_theme, + "status": s.status, + "my_consent_given": ProgressSeminarConsent.objects.filter( + seminar=s, member=faculty, consented=True + ).exists(), } for s in qs ] @@ -6695,25 +6758,15 @@ def serialize(qs): @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) + seminar = get_object_or_404(ProgressSeminarEntry, 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 + student_name = student_extra.user.get_full_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() - ] + thesis_title = seminar.thesis.research_theme panel = { f: getattr(seminar, f) for f in [ @@ -6726,7 +6779,7 @@ def rpc_detail(request, pk): 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() + consented = ProgressSeminarConsent.objects.filter(seminar=seminar, member=fac, consented=True).exists() committee.append({ "id": extra.id, "name": f"{extra.user.first_name} {extra.user.last_name}", @@ -6736,15 +6789,15 @@ def rpc_detail(request, pk): comments = [ { - "member": c.member.id.user.first_name + c.member.id.user.last_name, + "member": c.member.id.user.get_full_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() + my_comment = ProgressSeminarComment.objects.filter(seminar=seminar, member=faculty).first() + is_consented = ProgressSeminarConsent.objects.filter(seminar=seminar, member=faculty, consented=True).exists() payload = { "studentName": student_name, @@ -6753,6 +6806,7 @@ def rpc_detail(request, pk): "thesisTitle": thesis_title, "id": seminar.id, "version": seminar.version, + "semester_no": seminar.semester.semester_no if seminar.semester else None, "date": seminar.seminar_date.isoformat() if seminar.seminar_date else "", "time": seminar.seminar_time.isoformat() if seminar.seminar_time else "", "venue": seminar.seminar_venue, @@ -6760,7 +6814,9 @@ def rpc_detail(request, pk): "curr": seminar.summary_curr, "future": seminar.future_plan, "doc_url": seminar.upload_doc.url if seminar.upload_doc else None, - "publications": pubs, + "pub_published_or_accepted": seminar.pub_published_or_accepted, + "pub_presented_unpublished": seminar.pub_presented_unpublished, + "pub_submitted_under_review": seminar.pub_submitted_under_review, **panel, "committee": committee, "committeeSize": len(committee), @@ -6777,7 +6833,7 @@ def rpc_detail(request, pk): @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') + seminar = get_object_or_404(ProgressSeminarEntry, pk=pk, status='rpc_pending') if not CommitteeMember.objects.filter(thesis=seminar.thesis, member=faculty).exists(): return JsonResponse({"error": "Not on committee."}, status=403) @@ -6792,7 +6848,7 @@ def rpc_consent(request, pk): for field in panel_fields ) if changed: - SeminarConsent.objects.filter(seminar=seminar).update(consented=False) + ProgressSeminarConsent.objects.filter(seminar=seminar).update(consented=False) for field in panel_fields: if field in data: @@ -6800,13 +6856,13 @@ def rpc_consent(request, pk): seminar.save() if 'comment' in data: - SeminarComment.objects.update_or_create( + ProgressSeminarComment.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, _ = ProgressSeminarConsent.objects.get_or_create(seminar=seminar, member=faculty) consent_obj.consented = True consent_obj.save() @@ -6816,12 +6872,12 @@ def rpc_consent(request, pk): @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') + seminar = get_object_or_404(ProgressSeminarEntry, 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() + yes = ProgressSeminarConsent.objects.filter(seminar=seminar, consented=True).count() if yes < total: return JsonResponse({"error": "Not all consents recorded."}, status=400) @@ -6869,8 +6925,13 @@ def thesis_submit(request): 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') + """Return (indian_examiners, foreign_examiners) lists for a submission's panel. + + Reads via the `invitations` related manager rather than a fresh filter() + so that callers who prefetch_related('invitations', queryset=...ordered...) + get it from the prefetch cache instead of a query per submission. + """ + invites = sub.invitations.all() indian, foreign = [], [] for inv in invites: data = { @@ -6900,21 +6961,47 @@ def supervisor_dashboard(request): 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): + def serialize(sub, action=None, action_label=None, waiting_since=None): return { 'id': sub.id, 'title': sub.thesis.research_theme, + 'student_name': sub.thesis.student.id.user.get_full_name(), + 'student_roll': sub.thesis.student.id.id, + 'status': sub.status, + 'action': action, + 'action_label': action_label, + 'waiting_since': waiting_since, 'submitted_at': sub.submitted_at, 'supervisor_approved_at': sub.supervisor_approved_at, - 'status': sub.status + 'dean_panel_remarks': sub.dean_panel_remarks, } + # status='submitted' covers two different situations: a brand new + # submission the panel has never been assigned for, or one the Dean just + # sent back with remarks after rejecting the proposed panel. + action_required = [] + for sub in ThesisSubmission.objects.filter( + status='submitted', thesis__in=topics + ).select_related('thesis__student__id__user'): + if sub.dean_panel_remarks: + action, action_label = 'revise_panel', 'Revise Panel (Dean)' + else: + action, action_label = 'assign_examiners', 'Assign Examiners' + action_required.append(serialize(sub, action, action_label, sub.updated_at)) + # 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.filter(thesis__in=topics) + .exclude(status='submitted') + .select_related('thesis__student__id__user') + ] + return Response({ - 'pending': [serialize(s) for s in pending], - 'forwarded': [serialize(s) for s in forwarded], + 'action_required': action_required, + 'history': history, }) @@ -6922,14 +7009,16 @@ def serialize(sub): @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'. + Returns the already‐assigned examiners (and any Dean remarks from a + prior rejection) so the panel can pre-fill both the read-only view and + a resubmission after the Dean sends the panel back. """ sub = get_object_or_404(ThesisSubmission, id=submission_id) indian, foreign = _serialize_invitations(sub) return Response({ 'indian_examiners': indian, 'foreign_examiners': foreign, + 'dean_panel_remarks': sub.dean_panel_remarks, }) @@ -7002,9 +7091,12 @@ def supervisor_assign(request): ) # Update submission only after invitations are created successfully. + # Clear any earlier Dean rejection remark -- this resubmission is the + # response to it, so it shouldn't resurface as if still unaddressed. sub.supervisor = request.user sub.supervisor_approved_at = timezone.now() sub.status = 'dean_panel_review' + sub.dean_panel_remarks = '' sub.save() return Response({'detail': 'Examiners assigned successfully, forwarded to Dean for approval.'}, status=status.HTTP_200_OK) @@ -7013,10 +7105,22 @@ def supervisor_assign(request): # 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_panel_review': ('approve_panel', 'Forward Panel'), 'dean_invite_pending': ('send_invitations', 'Send Invitations'), } +# Friendly labels for the Dean's read-only History tab. 'submitted' only +# reaches history once it has been through dean_panel_review at least once +# (see the history query below), so here it always means "sent back". +STATUS_LABELS = { + 'submitted': 'Sent Back to Supervisor', + 'director_review': 'With Director', + 'in_review': 'In External Review', + 'approved': 'Approved', + 'rejected': 'Rejected', + 'completed': 'Completed', +} + @api_view(['GET']) @permission_classes([IsAuthenticated]) @@ -7027,30 +7131,53 @@ def serialize(sub, action=None, action_label=None, waiting_since=None): return { 'id': sub.id, 'title': sub.thesis.research_theme, + 'student_name': sub.thesis.student.id.user.get_full_name(), + 'student_roll': sub.thesis.student.id.id, 'status': sub.status, + 'status_label': STATUS_LABELS.get(sub.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, + 'dean_panel_remarks': sub.dean_panel_remarks, + 'director_remarks': sub.director_remarks, '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 - ) + for sub in ThesisSubmission.objects.filter( + status__in=ACTION_STATUSES.keys() + ).select_related('thesis__student__id__user').prefetch_related('invitations'): + # 'dean_panel_review' covers two different situations that both need + # a Dean decision: a fresh panel from the Supervisor, or one the + # Director just sent back with remarks. Tell them apart so the Dean + # isn't stuck re-reading the panel to figure out which one it is. + if sub.status == 'dean_panel_review' and sub.director_remarks: + action, action_label = 'reconsider_panel', 'Reconsider Panel (Director)' + waiting_since = sub.director_approved_at + elif sub.status == 'dean_invite_pending': + action, action_label = ACTION_STATUSES[sub.status] + waiting_since = sub.director_approved_at + else: + action, action_label = ACTION_STATUSES[sub.status] + waiting_since = 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()) + # 'submitted' normally means "not yet assigned by the supervisor" and + # doesn't belong in the Dean's history — except when it got there *after* + # a panel review (supervisor_approved_at is set), i.e. the Dean sent it + # back. That case should still show up, with a clear status label. history = [ serialize(s) for s in - ThesisSubmission.objects.exclude(status__in=['submitted', *ACTION_STATUSES.keys()]) + ThesisSubmission.objects.exclude( + status__in=ACTION_STATUSES.keys() + ).exclude( + Q(status='submitted') & Q(supervisor_approved_at__isnull=True) + ).select_related('thesis__student__id__user').prefetch_related('invitations') ] return Response({ @@ -7079,7 +7206,21 @@ def dean_panel_approve(request): return Response({'detail': 'Panel approved, forwarded to Director for prioritization.'}) if action == 'reject': + remarks = (data.get('remarks') or '').strip() + if not remarks: + return Response( + {'error': 'A remark is required when sending the panel back to the Supervisor.'}, + status=status.HTTP_400_BAD_REQUEST + ) sub.status = 'submitted' + # Supervisor's dashboard tells "fresh submission" apart from "sent + # back by Dean" by checking whether dean_panel_remarks is non-empty, + # so this must always be non-empty for a rejection to be recognized. + sub.dean_panel_remarks = remarks + # Starting a fresh Supervisor cycle -- any earlier Director remark no + # longer applies and would otherwise look like a stale "sent back by + # Director" marker on the resubmitted panel. + sub.director_remarks = '' sub.save() return Response({'detail': 'Panel rejected, sent back to Supervisor.'}) @@ -7092,34 +7233,36 @@ def dean_panel_approve(request): @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') + indian, foreign = _serialize_invitations(sub) return { 'id': sub.id, 'title': sub.thesis.research_theme, + 'student_name': sub.thesis.student.id.user.get_full_name(), + 'student_roll': sub.thesis.student.id.id, '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 - ], + 'indian_examiners': indian, + 'foreign_examiners': foreign, } action_required = [ serialize(sub, 'prioritize', 'Set Priorities', sub.dean_approved_at) - for sub in ThesisSubmission.objects.filter(status='director_review') + for sub in ThesisSubmission.objects.filter( + status='director_review' + ).select_related('thesis__student__id__user').prefetch_related('invitations') ] # 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']) + ThesisSubmission.objects.exclude( + status__in=['submitted', 'dean_panel_review', 'director_review'] + ).select_related('thesis__student__id__user').prefetch_related('invitations') ] return Response({ @@ -7143,46 +7286,72 @@ def director_approve(request): 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) - } + action = data.get('action', 'approve') + if action not in ('approve', 'send_back'): + return Response({'error': 'Unknown action.'}, status=status.HTTP_400_BAD_REQUEST) - # 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) + 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 + ) + + remarks = (data.get('remarks') or '').strip() + if action == 'send_back' and not remarks: + return Response( + {'error': 'A remark is required when sending the panel back to the Dean.'}, + status=status.HTTP_400_BAD_REQUEST + ) + # The Director can add, remove, or edit examiners (not just re-rank the + # ones the Supervisor originally nominated), so the panel is rebuilt from + # the submitted lists the same way supervisor_assign builds it initially. + # Rank is simply the row's position within its category. 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']) + 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, + ) sub.director = request.user sub.director_approved_at = timezone.now() - sub.status = 'dean_invite_pending' + sub.director_remarks = remarks + + if action == 'send_back': + sub.status = 'dean_panel_review' + detail = 'Panel sent back to the Dean with your remarks.' + else: + sub.status = 'dean_invite_pending' + detail = 'Priorities approved, sent to Dean to send invitations.' sub.save() - return Response({'detail': 'Priorities set, sent back to Dean to send invitations.'}) + return Response({'detail': detail}) # 7b) Dean sends the invitation to the Rank-1 Indian and Rank-1 Foreign examiners. @@ -7338,15 +7507,38 @@ def review_detail(request, token): # Thesis Slot Semester-Level Registration # =========================================================================== from applications.academic_procedures.models import ( - ThesisTopic, CommitteeMember, SeminarEntry, - SeminarConsent, SeminarComment, PublicationCount, - ThesisRegistration, ProgressSeminarRegistration, + ThesisTopic, CommitteeMember, ProgressSeminarEntry, + ProgressSeminarConsent, ProgressSeminarComment, + ThesisRegistration, ProgressSeminarRegistration, TeachingCreditRegistration, ThesisEvaluation, ProgressSeminarEvaluation, + ComprehensiveExam, ComprehensiveExamCommitteeMember, + ComprehensiveExamAttempt, FloatedSubject, + OpenSeminar, OpenSeminarAttempt, OpenSeminarCommitteeMember, + TeachingCreditAllocation, TeachingCreditEvaluationResponse, + resolve_progress_seminar_credit, +) +from applications.programme_curriculum.models import ( + ThesisSlot, SeminarSlot as ProgressSeminarSlot, TeachingCreditSlot, ) -from applications.programme_curriculum.models import ThesisSlot, ProgressSeminarSlot import datetime as _dt +def _resolve_discipline_matched_entry(manager, student): + """Pick the catalog entry (thesis/seminar/teaching-credit) matching the + student's own discipline when a slot links entries from more than one + discipline's catalog rows, falling back to the first entry otherwise. + Mirrors resolve_progress_seminar_catalog_entry's discipline-preference rule + -- a slot is allowed to serve multiple disciplines with different + code/name/credit per discipline, so callers must not just take "the first + linked entry" as if a slot only ever served one.""" + discipline = getattr(getattr(student, 'batch_id', None), 'discipline', None) + return (manager.filter(discipline=discipline).first() if discipline else None) or manager.first() + + +def _catalog_entry_to_dict(entry): + return {'id': entry.id, 'code': entry.code, 'name': entry.name, 'credit': entry.credit} if entry else None + + def _thesis_reg_to_dict(reg): """Serialize a ThesisRegistration instance to a plain dict.""" if reg is None: @@ -7356,6 +7548,7 @@ def _thesis_reg_to_dict(reg): {'id': t.id, 'code': t.code, 'name': t.name, 'credit': t.credit} for t in slot.theses.all() ] + resolved_thesis = _catalog_entry_to_dict(_resolve_discipline_matched_entry(slot.theses, reg.student)) return { 'id': reg.id, 'status': reg.status, @@ -7370,6 +7563,7 @@ def _thesis_reg_to_dict(reg): 'info': slot.thesis_slot_info or '', 'duration': slot.duration, 'theses': theses_list, + 'resolved_thesis': resolved_thesis, }, 'student': { 'id': reg.student.id.id, @@ -7440,6 +7634,9 @@ def student_thesis_enrollment_api(request): {'id': t.id, 'code': t.code, 'name': t.name, 'credit': t.credit} for t in thesis_slot.theses.all() ], + 'resolved_thesis': _catalog_entry_to_dict( + _resolve_discipline_matched_entry(thesis_slot.theses, student) + ), } # Include announced evaluation blocks so student can see grades eval_blocks = [] @@ -7612,137 +7809,207 @@ def admin_reject_enrollments(request): # =========================================================================== -# Thesis Grade Evaluation +# Progress Seminar Slot Semester-Level Registration +# +# Gated the same way as thesis enrollment: the student's ThesisTopic must be +# dean_approved. This is only the enrollment step -- the substantive report +# submission and RPC review (ProgressSeminarEntry) is separate and unaffected. # =========================================================================== -def _eval_to_dict(ev): - """Serialize a ThesisEvaluation block to a plain dict.""" - reg = ev.registration +def _progress_seminar_reg_to_dict(reg): + """Serialize a ProgressSeminarRegistration instance to a plain dict.""" + if reg is None: + return None + slot = reg.progress_seminar_slot + seminars_list = [ + {'id': s.id, 'code': s.code, 'name': s.name, 'credit': s.credit} + for s in slot.seminars.all() + ] + resolved_seminar = _catalog_entry_to_dict(_resolve_discipline_matched_entry(slot.seminars, reg.student)) 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(), - }, + 'id': reg.id, + 'status': reg.status, + 'remarks': reg.remarks, + 'registered_on': reg.registered_on.isoformat(), + 'progress_seminar_slot': { + 'id': slot.id, + 'name': slot.name, + 'info': slot.seminar_slot_info or '', + 'duration': slot.duration, + 'seminars': seminars_list, + 'resolved_seminar': resolved_seminar, + }, + 'student': { + 'id': reg.student.id.id, + 'name': reg.student.id.user.get_full_name(), }, + 'semester_no': reg.semester.semester_no, } -@api_view(['GET']) +@api_view(['GET', 'POST']) @permission_classes([IsAuthenticated]) -def supervisor_thesis_grades(request): +def student_progress_seminar_enrollment_api(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 + GET /stu/progress-seminar-enrollment/ + Returns the current semester's SeminarSlot, the student's + ThesisTopic approval status, and any existing registration. + + POST /stu/progress-seminar-enrollment/ + Creates a new ProgressSeminarRegistration for the current semester. + Requires thesis_topic to be dean_approved. """ 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') + student = Student.objects.get(id=user.extrainfo) + 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) - # Filters - sem_no = request.GET.get('semester') - if sem_no: - qs = qs.filter(registration__semester__semester_no=sem_no) + try: + 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) - 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) + topic = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + topic_approved = topic is not None and topic.status == 'dean_approved' - return JsonResponse({'evaluations': [_eval_to_dict(ev) for ev in qs]}, status=200) + slot = ProgressSeminarSlot.objects.filter(semester=semester).first() + try: + reg = ProgressSeminarRegistration.objects.get(student=student, semester=semester) + reg_data = _progress_seminar_reg_to_dict(reg) + except ProgressSeminarRegistration.DoesNotExist: + reg = None + reg_data = None -@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 + if request.method == 'GET': + slot_data = None + if slot: + slot_data = { + 'id': slot.id, + 'name': slot.name, + 'info': slot.seminar_slot_info or '', + 'duration': slot.duration, + 'seminars': [ + {'id': s.id, 'code': s.code, 'name': s.name, 'credit': s.credit} + for s in slot.seminars.all() + ], + 'resolved_seminar': _catalog_entry_to_dict( + _resolve_discipline_matched_entry(slot.seminars, student) + ), + } + return JsonResponse({ + 'thesis_topic_approved': topic_approved, + 'current_semester_no': student.curr_semester_no, + 'progress_seminar_slot': slot_data, + 'registration': reg_data, + }, 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 not topic_approved: + return JsonResponse( + {'error': 'Thesis topic must be dean-approved before registering for progress seminar'}, + status=403, + ) + if slot is None: + return JsonResponse( + {'error': 'No progress seminar slot configured for your current semester'}, + status=400, + ) + + current_count = ProgressSeminarRegistration.objects.filter( + progress_seminar_slot=slot, status__in=['pending', 'verified'] + ).count() + if current_count >= slot.max_registration_limit: + return JsonResponse( + {'error': 'Progress seminar slot has reached maximum capacity'}, + status=400, + ) + + now = _dt.datetime.now() + year, month = now.year, now.month + session = f"{year}-{str(year + 1)[2:]}" if month >= 7 else f"{year - 1}-{str(year)[2:]}" + + reg = ProgressSeminarRegistration.objects.create( + student=student, + progress_seminar_slot=slot, + semester=semester, + working_year=year, + status='pending', + ) + return JsonResponse(_progress_seminar_reg_to_dict(reg), status=201) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_progress_seminar_enrollment_list(request): """ - 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') + GET /acadadmin/progress-seminar-enrollments/?semester=&status= + Lists all ProgressSeminarRegistration entries. Supports optional filters: + ?semester= filter by semester number + ?status=pending|verified|rejected + """ + qs = ProgressSeminarRegistration.objects.select_related( + 'student__id__user', 'progress_seminar_slot', 'semester' + ).all().order_by('-registered_on') sem_no = request.GET.get('semester') if sem_no: - qs = qs.filter(registration__semester__semester_no=sem_no) + qs = qs.filter(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) + status_filter = request.GET.get('status') + if status_filter: + qs = qs.filter(status=status_filter) - return JsonResponse({'evaluations': [_eval_to_dict(ev) for ev in qs]}, status=200) + data = [] + for reg in qs: + entry = _progress_seminar_reg_to_dict(reg) + topic = ThesisTopic.objects.filter(student=reg.student).order_by('-created_at').first() + entry['topic_status'] = topic.status if topic else None + data.append(entry) + + return JsonResponse({'registrations': data}, status=200) @api_view(['POST']) @permission_classes([IsAuthenticated]) @role_required(['acadadmin']) -def admin_verify_thesis_grades(request): +def admin_verify_progress_seminar_enrollments(request): """ - POST /acadadmin/thesis-grades/verify/ + POST /acadadmin/progress-seminar-enrollments/verify/ Body: { "ids": [1, 2, 3] } - Verifies submitted grades (grade must already be set by supervisor). + Marks the given ProgressSeminarRegistration records as 'verified' and + auto-creates the single grade block (progress seminars are fixed at 3 + credits, unlike thesis's variable 3/6/9/12). """ ids = request.data.get('ids', []) if not ids: - return JsonResponse({'error': 'No evaluation IDs provided'}, status=400) + return JsonResponse({'error': 'No registration IDs provided'}, status=400) now = _dt.datetime.now(_dt.timezone.utc) + regs = ProgressSeminarRegistration.objects.filter(id__in=ids, status='pending') 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']) + for reg in regs: + reg.status = 'verified' + reg.save(update_fields=['status']) + ProgressSeminarEvaluation.objects.get_or_create(registration=reg) count += 1 return JsonResponse({'verified_count': count}, status=200) @@ -7750,331 +8017,2501 @@ def admin_verify_thesis_grades(request): @api_view(['POST']) @permission_classes([IsAuthenticated]) @role_required(['acadadmin']) -def admin_announce_thesis_grades(request): +def admin_reject_progress_seminar_enrollments(request): """ - POST /acadadmin/thesis-grades/announce/ - Body: { "ids": [1, 2, 3] } - Announces grades — makes them visible to students. - Only verified grades can be announced. + POST /acadadmin/progress-seminar-enrollments/reject/ + Body: { "ids": [1, 2], "remarks": "Reason for rejection" } + Marks the given ProgressSeminarRegistration records as 'rejected'. """ ids = request.data.get('ids', []) + remarks = request.data.get('remarks', '') if not ids: - return JsonResponse({'error': 'No evaluation IDs provided'}, status=400) + return JsonResponse({'error': 'No registration 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) + updated = ProgressSeminarRegistration.objects.filter(id__in=ids, status='pending').update( + status='rejected', + remarks=remarks, + ) + return JsonResponse({'rejected_count': updated}, status=200) -@api_view(['GET']) +# =========================================================================== +# Teaching Credit Slot Semester-Level Registration +# +# Gated on ComprehensiveExam.status == 'passed', same precondition already +# enforced by the substantive TeachingCreditAllocation flow. This is only +# the enrollment step -- the choice-and-allocation process is separate and +# unaffected. +# =========================================================================== + +def _teaching_credit_enrollment_to_dict(reg): + """Serialize a TeachingCreditRegistration instance to a plain dict.""" + if reg is None: + return None + slot = reg.teaching_credit_slot + credits_list = [ + {'id': t.id, 'code': t.code, 'name': t.name, 'credit': t.credit} + for t in slot.teaching_credits.all() + ] + resolved_teaching_credit = _catalog_entry_to_dict(_resolve_discipline_matched_entry(slot.teaching_credits, reg.student)) + return { + 'id': reg.id, + 'status': reg.status, + 'remarks': reg.remarks, + 'registered_on': reg.registered_on.isoformat(), + 'academic_session': reg.academic_session, + 'teaching_credit_slot': { + 'id': slot.id, + 'name': slot.name, + 'info': slot.teaching_credit_slot_info or '', + 'duration': slot.duration, + 'teaching_credits': credits_list, + 'resolved_teaching_credit': resolved_teaching_credit, + }, + '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 supervisor_download_all_thesis_grades_template(request): +def student_teaching_credit_enrollment_api(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. + GET /stu/teaching-credit-enrollment/ + Returns the current semester's TeachingCreditSlot, the student's + Comprehensive Exam status, and any existing registration. + + POST /stu/teaching-credit-enrollment/ + Creates a new TeachingCreditRegistration for the current semester. + Requires ComprehensiveExam.status == 'passed'. """ 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) + student = Student.objects.get(id=user.extrainfo) + 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) - # 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 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) - if not evals.exists(): - return JsonResponse({'error': 'No ungraded evaluations found'}, status=400) + comprehensive_exam_passed = ComprehensiveExam.objects.filter( + student=student, status='passed' + ).exists() - # Group by student to get unique students and their blocks - from collections import defaultdict - student_blocks = defaultdict(lambda: {'name': '', 'blocks': {}}) + slot = TeachingCreditSlot.objects.filter(semester=semester).first() - for eval in evals: - student = eval.registration.student - roll_no = student.id.id + try: + reg = TeachingCreditRegistration.objects.get(student=student, semester=semester) + reg_data = _teaching_credit_enrollment_to_dict(reg) + except TeachingCreditRegistration.DoesNotExist: + reg = None + reg_data = None - if roll_no not in student_blocks: - student_blocks[roll_no]['name'] = student.id.user.get_full_name() + if request.method == 'GET': + slot_data = None + if slot: + slot_data = { + 'id': slot.id, + 'name': slot.name, + 'info': slot.teaching_credit_slot_info or '', + 'duration': slot.duration, + 'teaching_credits': [ + {'id': t.id, 'code': t.code, 'name': t.name, 'credit': t.credit} + for t in slot.teaching_credits.all() + ], + 'resolved_teaching_credit': _catalog_entry_to_dict( + _resolve_discipline_matched_entry(slot.teaching_credits, student) + ), + } + return JsonResponse({ + 'comprehensive_exam_passed': comprehensive_exam_passed, + 'current_semester_no': student.curr_semester_no, + 'teaching_credit_slot': slot_data, + 'registration': reg_data, + }, status=200) - student_blocks[roll_no]['blocks'][eval.block_number] = eval.id + except Exception as e: + return JsonResponse({'error': f'Internal error: {type(e).__name__}: {e}'}, status=500) - # 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)) + # POST: create registration + if reg is not None: + return JsonResponse( + {'error': 'Already registered for this semester', 'registration': reg_data}, + status=400, + ) + if not comprehensive_exam_passed: + return JsonResponse( + {'error': 'Comprehensive Examination must be passed before registering for teaching credit'}, + status=403, + ) + if slot is None: + return JsonResponse( + {'error': 'No teaching credit slot configured for your current semester'}, + status=400, + ) - # Generate Excel template - import openpyxl - output = BytesIO() - workbook = openpyxl.Workbook() - worksheet = workbook.active - worksheet.title = 'All Blocks' + current_count = TeachingCreditRegistration.objects.filter( + teaching_credit_slot=slot, status__in=['pending', 'verified'] + ).count() + if current_count >= slot.max_registration_limit: + return JsonResponse( + {'error': 'Teaching credit slot has reached maximum capacity'}, + status=400, + ) - # 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') + now = _dt.datetime.now() + year, month = now.year, now.month + session = f"{year}-{str(year + 1)[2:]}" if month >= 7 else f"{year - 1}-{str(year)[2:]}" + + reg = TeachingCreditRegistration.objects.create( + student=student, + teaching_credit_slot=slot, + semester=semester, + working_year=year, + academic_session=session, + status='pending', + ) + return JsonResponse(_teaching_credit_enrollment_to_dict(reg), status=201) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_teaching_credit_enrollment_list(request): + """ + GET /acadadmin/teaching-credit-enrollments/?semester=&status= + Lists all TeachingCreditRegistration entries. Supports optional filters: + ?semester= filter by semester number + ?status=pending|verified|rejected + """ + qs = TeachingCreditRegistration.objects.select_related( + 'student__id__user', 'teaching_credit_slot', '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 = [_teaching_credit_enrollment_to_dict(reg) for reg in qs] + return JsonResponse({'registrations': data}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_verify_teaching_credit_enrollments(request): + """ + POST /acadadmin/teaching-credit-enrollments/verify/ + Body: { "ids": [1, 2, 3] } + Marks the given TeachingCreditRegistration records as 'verified'. Unlike + thesis/seminar, no grade-block is created here -- the substantive + satisfactory/not_satisfactory result is recorded on the separate + TeachingCreditAllocation once that process completes. + """ + ids = request.data.get('ids', []) + if not ids: + return JsonResponse({'error': 'No registration IDs provided'}, status=400) + + updated = TeachingCreditRegistration.objects.filter(id__in=ids, status='pending').update( + status='verified', + ) + return JsonResponse({'verified_count': updated}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_reject_teaching_credit_enrollments(request): + """ + POST /acadadmin/teaching-credit-enrollments/reject/ + Body: { "ids": [1, 2], "remarks": "Reason for rejection" } + Marks the given TeachingCreditRegistration 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 = TeachingCreditRegistration.objects.filter(id__in=ids, status='pending').update( + status='rejected', + remarks=remarks, + ) + return JsonResponse({'rejected_count': updated}, status=200) + + +# =========================================================================== +# PhD Course (Coursework) Registration +# +# Standalone request-and-verify workflow, independent of the UG/PG backlog +# add-course flow (add_course / CourseAddRequest). PhD students don't go +# through pre-registration/final-registration or the backlog Add/Drop tab — +# they self-submit a request per curriculum course slot for their current +# semester, and acadadmin verifies it here. +# =========================================================================== + +def _is_phd_student(student): + """True if `student` is enrolled in a PhD programme. + programme is stored inconsistently across seeded data ('PhD' vs 'Ph.D'), + so normalize it; also fall back to the batch name (e.g. 'PhD (Odd)').""" + programme_norm = (student.programme or '').upper().replace('.', '') + batch_name = student.batch_id.name if student.batch_id else '' + return programme_norm == 'PHD' or batch_name.upper().startswith('PHD') + + +def _resolve_phd_student(request): + """Returns (student, error_response). error_response is a JsonResponse + if the requester isn't a valid PhD student, else None.""" + try: + student = Student.objects.select_related('batch_id__curriculum').get( + id__user=request.user + ) + except Student.DoesNotExist: + return None, JsonResponse({'error': 'Student record not found'}, status=404) + + if not _is_phd_student(student): + return None, JsonResponse( + {'error': 'This section is for PhD students only'}, status=403 + ) + + if not student.batch_id or not student.batch_id.curriculum: + return None, JsonResponse( + {'error': 'Student batch or curriculum is not configured'}, status=400 + ) + + return student, None + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['student']) +def phd_student_status(request): + """ + GET /stu/phd/status/ + Lightweight check used by the frontend to decide whether to show the + "PhD Course Registration" tab at all, before fetching any curriculum data. + """ + try: + student = Student.objects.select_related('batch_id').get(id__user=request.user) + except Student.DoesNotExist: + return JsonResponse({'is_phd': False}, status=200) + + return JsonResponse({'is_phd': _is_phd_student(student)}, status=200) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['student']) +def phd_course_slots(request): + """ + GET /stu/phd/course-slots/ + Returns the CourseSlots in the PhD student's current-semester curriculum, + excluding slots already registered or requested (pending/approved). + """ + student, err = _resolve_phd_student(request) + if err: + return err + + 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) + + taken_slot_ids = set( + PhDCourseRegistrationRequest.objects.filter( + student=student, semester=semester, status__in=['Pending', 'Approved'] + ).values_list('course_slot_id', flat=True) + ) + + slots = CourseSlot.objects.filter(semester=semester).annotate(course_count=Count('courses')) + data = [ + {'id': s.id, 'name': s.name, 'course_count': s.course_count} + for s in slots if s.id not in taken_slot_ids + ] + return JsonResponse({'semester_no': semester.semester_no, 'slots': data}, status=200) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['student']) +def phd_course_slot_courses(request): + """ + GET /stu/phd/course-slots/courses/?slot_id= + Returns the courses within a slot in the student's current semester. + """ + student, err = _resolve_phd_student(request) + if err: + return err + + slot_id = request.query_params.get('slot_id') + if not slot_id: + return JsonResponse({'error': 'slot_id query parameter is required'}, status=400) + + try: + semester = Semester.objects.get( + curriculum=student.batch_id.curriculum, + semester_no=student.curr_semester_no, + ) + slot = CourseSlot.objects.get(id=slot_id, semester=semester) + except (Semester.DoesNotExist, CourseSlot.DoesNotExist): + return JsonResponse({'error': 'Course slot not found in current semester'}, status=404) + + courses = slot.courses.all().values('id', 'code', 'name', 'credit') + return JsonResponse({'courses': list(courses)}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['student']) +def phd_submit_course_request(request): + """ + POST /stu/phd/course-request/ + Body: { "slot_id": , "course_id": } + Creates a Pending PhDCourseRegistrationRequest for the student's current semester. + One request per slot per semester. + """ + student, err = _resolve_phd_student(request) + if err: + return err + + slot_id = request.data.get('slot_id') + course_id = request.data.get('course_id') + if not slot_id or not course_id: + return JsonResponse({'error': 'slot_id and course_id are required'}, status=400) + + try: + semester = Semester.objects.get( + curriculum=student.batch_id.curriculum, + semester_no=student.curr_semester_no, + ) + slot = CourseSlot.objects.get(id=slot_id, semester=semester) + course = slot.courses.get(id=course_id) + except Semester.DoesNotExist: + return JsonResponse({'error': 'Current semester not found in curriculum'}, status=400) + except CourseSlot.DoesNotExist: + return JsonResponse({'error': 'Course slot not found in current semester'}, status=404) + except Courses.DoesNotExist: + return JsonResponse({'error': 'Course not found in this slot'}, status=404) + + if PhDCourseRegistrationRequest.objects.filter( + student=student, semester=semester, course_slot=slot, status__in=['Pending', 'Approved'] + ).exists(): + return JsonResponse({'error': 'You already have a request for this slot'}, status=400) + + academic_year, semester_type = generate_current_session( + datetime.datetime.now().year, student.curr_semester_no + ) + + # unique_together is (student, semester, course_slot) regardless of status, + # so a prior Rejected request for this slot must be reused, not re-created. + req, _created = PhDCourseRegistrationRequest.objects.update_or_create( + student=student, semester=semester, course_slot=slot, + defaults={ + 'academic_year': academic_year, + 'semester_type': semester_type, + 'course': course, + 'status': 'Pending', + 'remarks': '', + 'requested_at': timezone.now(), + 'processed_at': None, + 'processed_by': None, + }, + ) + return JsonResponse({ + 'id': req.id, + 'slot': slot.name, + 'course': course.code, + 'course_name': course.name, + 'status': req.status, + }, status=201) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['student']) +def phd_my_course_requests(request): + """ + GET /stu/phd/my-course-requests/ + Returns the PhD student's own course requests, most recent first. + """ + student, err = _resolve_phd_student(request) + if err: + return err + + qs = PhDCourseRegistrationRequest.objects.filter(student=student) \ + .select_related('course', 'course_slot', 'semester').order_by('-requested_at') + + data = [{ + 'id': r.id, + 'slot': r.course_slot.name, + 'course': r.course.code, + 'course_name': r.course.name, + 'credit': r.course.credit, + 'semester_no': r.semester.semester_no, + 'academic_year': r.academic_year, + 'semester_type': r.semester_type, + 'status': r.status, + 'remarks': r.remarks, + 'requested_at': r.requested_at.isoformat(), + 'processed_at': r.processed_at.isoformat() if r.processed_at else None, + } for r in qs] + + return JsonResponse({'requests': data}, status=200) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def phd_admin_list_requests(request): + """ + GET /acadadmin/phd/course-requests/?academic_year=&semester_type=&semester=&status= + Lists all PhDCourseRegistrationRequest entries, filterable. `semester` is + the semester number, the common filter axis with the thesis/progress-seminar/ + teaching-credit enrollment list endpoints (used by the merged admin view). + """ + qs = PhDCourseRegistrationRequest.objects.select_related( + 'student__id__user', 'course', 'course_slot', 'semester' + ).all().order_by('-requested_at') + + year = request.GET.get('academic_year', '').strip() + sem_type = request.GET.get('semester_type', '').strip() + sem_no = request.GET.get('semester', '').strip() + status_filter = request.GET.get('status', '').strip() + + if year: + qs = qs.filter(academic_year=year) + if sem_type: + qs = qs.filter(semester_type=sem_type) + if sem_no: + qs = qs.filter(semester__semester_no=sem_no) + if status_filter: + qs = qs.filter(status=status_filter) + + qs = qs[:500] + + data = [{ + 'id': r.id, + 'student': r.student.id.user.username, + 'student_name': f"{r.student.id.user.first_name} {r.student.id.user.last_name}".strip(), + 'slot': r.course_slot.name, + 'course': r.course.code, + 'course_name': r.course.name, + 'credit': r.course.credit, + 'semester_no': r.semester.semester_no, + 'academic_year': r.academic_year, + 'semester_type': r.semester_type, + 'status': r.status, + 'remarks': r.remarks, + 'requested_at': r.requested_at.isoformat(), + 'processed_at': r.processed_at.isoformat() if r.processed_at else None, + } for r in qs] + + return JsonResponse({'requests': data}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@transaction.atomic +@role_required(['acadadmin']) +def phd_admin_process_requests(request): + """ + POST /acadadmin/phd/course-requests/process/ + Body: { "request_ids": [1, 2, 3], "action": "approve"|"reject", "remarks": "..." } + Approving creates the real course_registration row; rejecting just marks status. + """ + request_ids = request.data.get('request_ids', []) + action = str(request.data.get('action', 'approve')).lower().strip() + remarks = request.data.get('remarks', '') + + if not request_ids or not isinstance(request_ids, list): + return JsonResponse({'error': 'request_ids must be a non-empty array'}, status=400) + if action not in ['approve', 'reject']: + return JsonResponse({'error': 'action must be either "approve" or "reject"'}, status=400) + + admin_extrainfo = getattr(request.user, 'extrainfo', None) + results = [] + now = timezone.now() + + for req_id in request_ids: + try: + req_id = int(req_id) + req = PhDCourseRegistrationRequest.objects.select_related( + 'student', 'course', 'course_slot', 'semester', 'student__batch_id' + ).select_for_update(of=('self',)).get(id=req_id) + except (ValueError, TypeError): + results.append({'id': req_id, 'status': 'error', 'detail': 'Invalid ID format'}) + continue + except PhDCourseRegistrationRequest.DoesNotExist: + results.append({'id': req_id, 'status': 'not_found'}) + continue + + if req.status != 'Pending': + results.append({'id': req_id, 'status': 'already_processed', 'current_status': req.status}) + continue + + if action == 'reject': + req.status = 'Rejected' + req.remarks = remarks + req.processed_at = now + req.processed_by = admin_extrainfo + req.save(update_fields=['status', 'remarks', 'processed_at', 'processed_by']) + results.append({'id': req_id, 'status': 'rejected'}) + continue + + # approve + already_registered = course_registration.objects.filter( + student_id=req.student, + course_id=req.course, + session=req.academic_year, + semester_type=req.semester_type, + ).exists() + if already_registered: + req.status = 'Rejected' + req.remarks = 'Already registered for this course' + req.processed_at = now + req.processed_by = admin_extrainfo + req.save(update_fields=['status', 'remarks', 'processed_at', 'processed_by']) + results.append({'id': req_id, 'status': 'error', 'detail': 'Already registered'}) + continue + + course_registration.objects.create( + student_id=req.student, + course_id=req.course, + course_slot_id=req.course_slot, + semester_id=req.semester, + session=req.academic_year, + semester_type=req.semester_type, + working_year=datetime.datetime.now().year, + registration_type='Regular', + ) + req.status = 'Approved' + req.remarks = remarks + req.processed_at = now + req.processed_by = admin_extrainfo + req.save(update_fields=['status', 'remarks', 'processed_at', 'processed_by']) + results.append({'id': req_id, 'status': 'approved'}) + + return JsonResponse({'results': results}, status=200) + + +# =========================================================================== +# Thesis Grade Evaluation +# =========================================================================== + +def _eval_to_dict(ev): + """Serialize a ThesisEvaluation block to a plain dict.""" + reg = ev.registration + catalog_thesis = reg.thesis_slot.theses.first() + 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, + 'thesis_code': catalog_thesis.code if catalog_thesis else reg.thesis_slot.name, + 'thesis_title': reg.thesis_topic.research_theme if reg.thesis_topic else None, + '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) + + +# =========================================================================== +# Comprehensive Examination +# =========================================================================== +# Workflow: Supervisor proposes committee/eligibility -> Academic Office +# verifies -> Convener (Dean Academic stands in for DPGC/PGCS for now) +# approves -> Supervisor floats subjects -> HOD (as discipline coordinator) +# approves subjects -> Student opts 2 subjects -> Supervisor confirms -> +# [offline written+oral exam] -> Convener records result. On failure the +# whole cycle (from floating subjects) repeats for a 2nd attempt. + +def _floated_subject_to_dict(s): + return { + 'id': s.id, + 'subject_name': s.subject_name, + 'selected_by_student': s.selected_by_student, + } + + +def _comprehensive_exam_attempt_to_dict(a): + return { + 'id': a.id, + 'attempt_number': a.attempt_number, + 'status': a.status, + 'written_exam_date': a.written_exam_date.isoformat() if a.written_exam_date else None, + 'oral_exam_date': a.oral_exam_date.isoformat() if a.oral_exam_date else None, + 'hod_remarks': a.hod_remarks, + 'supervisor_confirmation_remarks': a.supervisor_confirmation_remarks, + 'result': a.result, + 'fundamentals_comment': a.fundamentals_comment, + 'problem_identification_comment': a.problem_identification_comment, + 'plan_of_work_comment': a.plan_of_work_comment, + 'suggestions_comment': a.suggestions_comment, + 'additional_literature_comment': a.additional_literature_comment, + 'milestone_plan_url': a.milestone_plan_upload.url if a.milestone_plan_upload else None, + 'reported_at': a.reported_at.isoformat() if a.reported_at else None, + 'subjects': [_floated_subject_to_dict(s) for s in a.subjects.all()], + } + + +def _is_exam_supervisor_or_co(request, exam): + """True if request.user is the exam's supervisor or co-supervisor. + + Mirrors the ownership check in supervisor_assign (ThesisSubmission flow): + compare Django auth User pks directly instead of going through Faculty, + which avoids Faculty.id resolving to the related ExtraInfo object rather + than its raw pk. + """ + allowed_users = {exam.supervisor.id.user_id} + if exam.co_supervisor: + allowed_users.add(exam.co_supervisor.id.user_id) + return request.user.id in allowed_users + + +def _student_completed_credits(student): + """Sum of credits for courses the student has a passing grade for (SemesterMarks).""" + total = SemesterMarks.objects.filter(student_id=student).exclude( + grade__isnull=True + ).exclude(grade__in=['F', 'X']).aggregate(total=Sum('curr_id__credit'))['total'] + return total or 0 + + +def comprehensive_exam_to_dict(exam): + """Serialize a ComprehensiveExam (with committee & attempts) for JSON responses.""" + return { + 'id': exam.id, + 'student_roll': exam.student.id.id, + 'student_name': exam.student.id.user.get_full_name(), + 'student_discipline': exam.student.specialization, + 'supervisor': { + 'id': exam.supervisor.id.id, + 'name': str(exam.supervisor), + 'discipline': exam.supervisor.id.department.name if exam.supervisor.id.department else '', + }, + 'co_supervisor': ( + { + 'id': exam.co_supervisor.id.id, + 'name': str(exam.co_supervisor), + 'discipline': exam.co_supervisor.id.department.name if exam.co_supervisor.id.department else '', + } + if exam.co_supervisor else None + ), + 'possible_thesis_title': exam.possible_thesis_title, + 'entry_qualification': exam.entry_qualification, + 'required_credits': exam.required_credits, + 'credits_completed': exam.credits_completed, + 'current_cpi': str(exam.current_cpi) if exam.current_cpi is not None else None, + 'research_methodology_completed': exam.research_methodology_completed, + 'credits_verified': exam.credits_verified, + 'cpi_verified': exam.cpi_verified, + 'research_methodology_verified': exam.research_methodology_verified, + 'academic_office_remarks': exam.academic_office_remarks, + 'convener_remarks': exam.convener_remarks, + 'status': exam.status, + 'current_attempt_number': exam.current_attempt_number, + 'max_attempts': ComprehensiveExam.MAX_ATTEMPTS, + '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 exam.committee.all() + ], + 'attempts': [_comprehensive_exam_attempt_to_dict(a) for a in exam.attempts.order_by('attempt_number')], + } + + +# 1. Student + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def student_comprehensive_exam_api(request): + """GET /stu/comprehensive-exam/ -> fetch the requesting student's exam ({} if none).""" + try: + student = Student.objects.get(id=request.user.extrainfo) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) + + exam = ComprehensiveExam.objects.filter(student=student).first() + return JsonResponse(comprehensive_exam_to_dict(exam) if exam else {}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def student_opt_subjects_api(request, attempt_pk): + """ + POST /stu/comprehensive-exam/attempt//opt-subjects/ + Body: { subject_ids: [id1, id2] } -- must pick exactly 2 from the floated list. + """ + try: + student = Student.objects.get(id=request.user.extrainfo) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) + + attempt = get_object_or_404( + ComprehensiveExamAttempt, pk=attempt_pk, exam__student=student, status='subjects_ready', + ) + + subject_ids = request.data.get('subject_ids', []) + if len(subject_ids) != 2: + return JsonResponse({'error': 'Select exactly 2 subjects'}, status=400) + + valid_ids = set(attempt.subjects.values_list('id', flat=True)) + if not set(subject_ids).issubset(valid_ids): + return JsonResponse({'error': 'Invalid subject selection'}, status=400) + + FloatedSubject.objects.filter(attempt=attempt).update(selected_by_student=False) + FloatedSubject.objects.filter(attempt=attempt, id__in=subject_ids).update(selected_by_student=True) + attempt.status = 'subjects_opted' + attempt.save() + + return JsonResponse(comprehensive_exam_to_dict(attempt.exam), status=200) + + +# 2. Supervisor + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_comprehensive_exam_dashboard(request): + """GET /supervisor/comprehensive-exam/dashboard/ -> exams supervised or co-supervised by the requester.""" + try: + faculty = Faculty.objects.get(id__user=request.user) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) + + qs = ComprehensiveExam.objects.filter( + Q(supervisor=faculty) | Q(co_supervisor=faculty) + ).select_related('student__id__user', 'supervisor__id__user').prefetch_related( + 'committee__member__id__department', 'attempts__subjects' + ) + + return JsonResponse({'exams': [comprehensive_exam_to_dict(e) for e in qs]}, status=200) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_student_academic_info(request, roll_no): + """ + GET /supervisor/comprehensive-exam/student-info// + Read-only credits-completed & CPI, computed from the student's own + academic records -- never manually entered. + """ + try: + student = Student.objects.get(id=roll_no) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student not found'}, status=404) + + return JsonResponse({ + 'credits_completed': _student_completed_credits(student), + 'current_cpi': str(student.cpi) if student.cpi is not None else None, + }, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_propose_comprehensive_exam(request): + """ + POST /supervisor/comprehensive-exam/propose/ + Body: { roll_no, co_supervisor_id, possible_thesis_title, entry_qualification, + committee: [ids] } + credits_completed / current_cpi are computed server-side from the + student's own records, not accepted from the client. Research Methodology + completion is Academic Office's call (set via the verify endpoint), not + the supervisor's -- not accepted here either. + """ + try: + faculty = Faculty.objects.get(id__user=request.user) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) + + data = request.data + roll_no = data.get('roll_no') + if not roll_no: + return JsonResponse({'error': 'roll_no is required'}, status=400) + + try: + student = Student.objects.get(id=roll_no) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student not found'}, status=404) + + if ComprehensiveExam.objects.filter(student=student).exists(): + return JsonResponse({'error': 'Comprehensive exam already exists for this student'}, status=400) + + entry_qualification = data.get('entry_qualification') + if entry_qualification not in dict(ComprehensiveExam.ENTRY_QUALIFICATION_CHOICES): + return JsonResponse({'error': 'Invalid entry_qualification'}, status=400) + + committee_ids = data.get('committee', []) + if len(committee_ids) > 5: + return JsonResponse({'error': 'At most 5 committee members allowed'}, status=400) + + with transaction.atomic(): + exam = ComprehensiveExam.objects.create( + student=student, + supervisor=faculty, + co_supervisor_id=data.get('co_supervisor_id') or None, + possible_thesis_title=data.get('possible_thesis_title', ''), + entry_qualification=entry_qualification, + credits_completed=_student_completed_credits(student), + current_cpi=student.cpi, + ) + for member_id in committee_ids: + ComprehensiveExamCommitteeMember.objects.get_or_create(exam=exam, member_id=member_id) + + return JsonResponse(comprehensive_exam_to_dict(exam), status=201) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_comprehensive_exam_detail(request, pk): + """GET /supervisor/comprehensive-exam// -> full detail (also used to prefill a resubmission).""" + exam = get_object_or_404(ComprehensiveExam, pk=pk) + return JsonResponse(comprehensive_exam_to_dict(exam), status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_resubmit_proposal(request, pk): + """ + POST /supervisor/comprehensive-exam//resubmit/ + Edits committee/eligibility fields after an Academic Office or Convener + rejection, and resends for Academic Office verification. + """ + exam = get_object_or_404(ComprehensiveExam, pk=pk) + if not _is_exam_supervisor_or_co(request, exam): + return JsonResponse({'error': 'Not authorized'}, status=403) + if exam.status not in ('academic_office_rejected', 'convener_rejected'): + return JsonResponse({'error': 'Cannot edit at this stage'}, status=403) + + data = request.data + if 'possible_thesis_title' in data: + exam.possible_thesis_title = data['possible_thesis_title'] + if 'entry_qualification' in data: + exam.entry_qualification = data['entry_qualification'] + if 'co_supervisor_id' in data: + exam.co_supervisor_id = data['co_supervisor_id'] or None + + # Re-derive from the student's own records rather than trusting client input. + exam.credits_completed = _student_completed_credits(exam.student) + exam.current_cpi = exam.student.cpi + + if 'committee' in data: + committee_ids = data['committee'] + if len(committee_ids) > 5: + return JsonResponse({'error': 'At most 5 committee members allowed'}, status=400) + with transaction.atomic(): + ComprehensiveExamCommitteeMember.objects.filter(exam=exam).delete() + for member_id in committee_ids: + ComprehensiveExamCommitteeMember.objects.get_or_create(exam=exam, member_id=member_id) + + exam.status = 'academic_office_pending' + exam.credits_verified = False + exam.cpi_verified = False + exam.research_methodology_verified = False + exam.academic_office_remarks = '' + exam.convener_remarks = '' + exam.save() + + return JsonResponse(comprehensive_exam_to_dict(exam), status=200) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def list_courses_for_dropdown(request): + """ + GET /courses/dropdown/?search= + Lightweight {id, code, name} course list for populating dropdowns (e.g. + floating comprehensive-exam subjects from the actual curriculum instead + of free text). Deliberately not acadadmin-gated -- faculty need this too. + """ + qs = Courses.objects.filter(working_course=True, latest_version=True) + search = request.GET.get('search', '').strip() + if search: + qs = qs.filter(Q(code__icontains=search) | Q(name__icontains=search)) + qs = qs.order_by('code')[:100] + return JsonResponse({ + 'courses': [{'id': c.id, 'code': c.code, 'name': c.name} for c in qs], + }, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_float_subjects(request, pk): + """ + POST /supervisor/comprehensive-exam//float-subjects/ + Body: { subjects: ["Subject A", ...] (<=6), written_exam_date, oral_exam_date } + Creates/updates the current attempt's floated subjects and sends them to + the HOD (as discipline coordinator) for approval. + """ + exam = get_object_or_404(ComprehensiveExam, pk=pk) + if not _is_exam_supervisor_or_co(request, exam): + return JsonResponse({'error': 'Not authorized'}, status=403) + if exam.status != 'in_progress': + return JsonResponse({'error': 'Exam is not in progress'}, status=403) + + subjects = [s.strip() for s in request.data.get('subjects', []) if s and s.strip()] + if len(subjects) < 2 or len(subjects) > 6: + return JsonResponse({'error': 'Provide between 2 and 6 subjects'}, status=400) + + attempt, created = ComprehensiveExamAttempt.objects.get_or_create( + exam=exam, attempt_number=exam.current_attempt_number, + ) + # Once floated, subjects are locked while pending HOD review -- only + # editable again if HOD sends them back (a brand-new attempt is always + # editable on its first float). + if not created and attempt.status != 'hod_rejected': + return JsonResponse({'error': 'Subjects cannot be edited at this stage'}, status=403) + + attempt.written_exam_date = request.data.get('written_exam_date') or attempt.written_exam_date + attempt.oral_exam_date = request.data.get('oral_exam_date') or attempt.oral_exam_date + attempt.status = 'subjects_floated' + attempt.hod_remarks = '' + + with transaction.atomic(): + attempt.save() + FloatedSubject.objects.filter(attempt=attempt).delete() + for name in subjects: + FloatedSubject.objects.create(attempt=attempt, subject_name=name) + + return JsonResponse(comprehensive_exam_to_dict(exam), status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_confirm_opted_subjects(request, attempt_pk): + """ + POST /supervisor/comprehensive-exam/attempt//confirm-subjects/ + Body: { confirm: true|false, remarks } + Confirms (or sends back to the student) the 2 subjects opted for. + """ + attempt = get_object_or_404(ComprehensiveExamAttempt, pk=attempt_pk, status='subjects_opted') + exam = attempt.exam + if not _is_exam_supervisor_or_co(request, exam): + return JsonResponse({'error': 'Not authorized'}, status=403) + + if request.data.get('confirm'): + attempt.status = 'result_pending' + attempt.supervisor_confirmation_remarks = '' + else: + attempt.status = 'subjects_ready' + attempt.supervisor_confirmation_remarks = request.data.get('remarks', '') + FloatedSubject.objects.filter(attempt=attempt).update(selected_by_student=False) + attempt.save() + + return JsonResponse(comprehensive_exam_to_dict(exam), status=200) + + +# 3. Academic Office (acadadmin) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def academic_office_comprehensive_exam_list(request): + """GET /acadadmin/comprehensive-exam/?status=""" + qs = ComprehensiveExam.objects.select_related('student__id__user', 'supervisor__id__user').prefetch_related( + 'committee__member__id__department', 'attempts__subjects' + ).all() + status_param = request.GET.get('status') + if status_param: + qs = qs.filter(status=status_param) + return JsonResponse({'exams': [comprehensive_exam_to_dict(e) for e in qs]}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def academic_office_verify_comprehensive_exam(request, pk): + """ + POST /acadadmin/comprehensive-exam//verify/ + Body: { approve: true|false, credits_verified, cpi_verified, + research_methodology_verified, remarks } + """ + exam = get_object_or_404(ComprehensiveExam, pk=pk, status='academic_office_pending') + data = request.data + + exam.credits_verified = bool(data.get('credits_verified', False)) + exam.cpi_verified = bool(data.get('cpi_verified', False)) + exam.research_methodology_verified = bool(data.get('research_methodology_verified', False)) + exam.academic_office_remarks = data.get('remarks', '') + exam.academic_office_verified_by = request.user + exam.academic_office_verified_at = timezone.now() + + if data.get('approve'): + if not (exam.credits_verified and exam.cpi_verified and exam.research_methodology_verified): + return JsonResponse({ + 'error': 'All three eligibility checks (credits, CPI, Research Methodology) ' + 'must be confirmed before approving.', + }, status=400) + exam.status = 'convener_pending' + else: + exam.status = 'academic_office_rejected' + + exam.save() + return JsonResponse(comprehensive_exam_to_dict(exam), status=200) + + +# 4. Convener (Dean Academic stands in for DPGC/PGCS for now) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def convener_comprehensive_exam_dashboard(request): + """GET /dean/comprehensive-exam/dashboard/ -> committee approvals + result reports pending.""" + pending_committee = ComprehensiveExam.objects.filter(status='convener_pending').select_related( + 'student__id__user', 'supervisor__id__user' + ).prefetch_related('committee__member__id__department', 'attempts__subjects') + pending_reports = ComprehensiveExamAttempt.objects.filter(status='result_pending').select_related( + 'exam__student__id__user', 'exam__supervisor__id__user' + ).prefetch_related('exam__committee__member__id__department', 'exam__attempts__subjects') + + return JsonResponse({ + 'pending_committee': [comprehensive_exam_to_dict(e) for e in pending_committee], + 'pending_reports': [comprehensive_exam_to_dict(a.exam) for a in pending_reports], + }, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def convener_approve_committee(request, pk): + """ + POST /dean/comprehensive-exam//approve-committee/ + Body: { approve: true|false, remarks } + """ + exam = get_object_or_404(ComprehensiveExam, pk=pk, status='convener_pending') + data = request.data + exam.convener_remarks = data.get('remarks', '') + exam.convener_by = request.user + exam.convener_at = timezone.now() + exam.status = 'in_progress' if data.get('approve') else 'convener_rejected' + exam.save() + return JsonResponse(comprehensive_exam_to_dict(exam), status=200) + + +@api_view(['POST']) +@parser_classes([MultiPartParser, FormParser]) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def convener_submit_result(request, attempt_pk): + """ + POST /dean/comprehensive-exam/attempt//report/ (multipart) + Body: result, *_comment fields, milestone_plan (file) + Records the exam outcome; on failure, advances to the next attempt (up to + ComprehensiveExam.MAX_ATTEMPTS) or finalizes the exam as failed. + """ + attempt = get_object_or_404(ComprehensiveExamAttempt, pk=attempt_pk, status='result_pending') + exam = attempt.exam + data = request.data + + result = data.get('result') + if result not in ('passed', 'failed'): + return JsonResponse({'error': 'result must be passed or failed'}, status=400) + + attempt.result = result + attempt.status = result + attempt.fundamentals_comment = data.get('fundamentals_comment', '') + attempt.problem_identification_comment = data.get('problem_identification_comment', '') + attempt.plan_of_work_comment = data.get('plan_of_work_comment', '') + attempt.suggestions_comment = data.get('suggestions_comment', '') + attempt.additional_literature_comment = data.get('additional_literature_comment', '') + if request.FILES.get('milestone_plan'): + attempt.milestone_plan_upload = request.FILES['milestone_plan'] + attempt.reported_by = request.user + attempt.reported_at = timezone.now() + attempt.save() + + if result == 'passed': + exam.status = 'passed' + elif exam.current_attempt_number < ComprehensiveExam.MAX_ATTEMPTS: + exam.current_attempt_number += 1 + else: + exam.status = 'failed_final' + exam.save() + + return JsonResponse(comprehensive_exam_to_dict(exam), status=200) + + +# 5. HOD (as discipline coordinator) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def hod_comprehensive_exam_dashboard(request): + """ + GET /hod/comprehensive-exam/dashboard/ + Lists attempts with floated subjects pending approval, scoped to the HOD's + own discipline (HOD stands in as discipline coordinator). + """ + user = request.user + hod_disciplines = get_hod_disciplines(user) + + qs = ComprehensiveExamAttempt.objects.filter( + status='subjects_floated' + ).select_related( + 'exam__student__id__user', 'exam__student__batch_id__discipline', 'exam__supervisor__id__user' + ).prefetch_related('exam__committee__member__id__department', 'exam__attempts__subjects') + + pending = [] + for attempt in qs: + student = attempt.exam.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(comprehensive_exam_to_dict(attempt.exam)) + + return JsonResponse({'pending': pending}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def hod_review_subjects(request, attempt_pk): + """ + POST /hod/comprehensive-exam/attempt//review-subjects/ + Body: { approve: true|false, remarks } + """ + attempt = get_object_or_404(ComprehensiveExamAttempt, pk=attempt_pk, status='subjects_floated') + user = request.user + + student = attempt.exam.student + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + is_hod = is_hod_of_discipline(user, acronym) + if not is_hod: + return JsonResponse({'error': 'Not authorized'}, status=403) + + data = request.data + attempt.hod_reviewed_by = user + attempt.hod_reviewed_at = timezone.now() + if data.get('approve'): + attempt.status = 'subjects_ready' + attempt.hod_remarks = '' + else: + attempt.status = 'hod_rejected' + attempt.hod_remarks = data.get('remarks', '') + attempt.save() + + return JsonResponse(comprehensive_exam_to_dict(attempt.exam), status=200) + + +# =========================================================================== +# Open Seminar +# =========================================================================== +# Workflow: Supervisor proposes a fresh committee+eligibility snapshot -> +# Convener (Dean Academic stands in for DPGC/PGCS for now) approves the +# committee and appoints a Dean Nominee -> [offline seminar] -> Convener +# records the committee's authoritative verdict, while the Dean Nominee +# independently submits their own confidential report (does not gate the +# result). On 'not_satisfactory', unlimited retries -- each one constitutes a +# brand-new committee, since eligibility/credits can change between attempts. + +def _is_open_seminar_supervisor_or_co(request, seminar): + """Mirrors _is_exam_supervisor_or_co (Comprehensive Exam) for OpenSeminar.""" + allowed_users = {seminar.supervisor.id.user_id} + if seminar.co_supervisor: + allowed_users.add(seminar.co_supervisor.id.user_id) + return request.user.id in allowed_users + + +def _compute_open_seminar_eligibility(student): + """Auto-derive the Constitution form's credit breakdown + RPC recommendation. + + course_work_credits reuses the same SemesterMarks-based helper as + Comprehensive Exam; progress_seminar_credits sums each of the student's + rpc_approved ProgressSeminarEntry records at its own catalog credit value (see + resolve_progress_seminar_credit -- do not hardcode this number, it varies by the + Seminar catalog row); thesis_research_credits sums verified ThesisRegistration + credits; rpc_recommended_open_seminar reads the latest approved seminar's + rec_open field. teaching_credits has no numeric source anywhere in Fusion and + stays manual (not included here). + """ + course_work_credits = _student_completed_credits(student) + + thesis_topic = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + progress_seminar_credits = 0 + rpc_recommended_open_seminar = False + if thesis_topic: + approved_seminars = thesis_topic.seminars.filter(status='rpc_approved') + progress_seminar_credits = sum( + resolve_progress_seminar_credit(student, s.semester) for s in approved_seminars + ) + latest_approved = approved_seminars.order_by('-version').first() + if latest_approved: + rpc_recommended_open_seminar = (latest_approved.rec_open == 'Yes') + + thesis_research_credits = ThesisRegistration.objects.filter( + student=student, status='verified' + ).aggregate(total=Sum('credits'))['total'] or 0 + + return { + 'course_work_credits': course_work_credits, + 'progress_seminar_credits': progress_seminar_credits, + 'thesis_research_credits': thesis_research_credits, + 'semesters_completed': student.curr_semester_no or 0, + 'rpc_recommended_open_seminar': rpc_recommended_open_seminar, + } + + +def _open_seminar_attempt_to_dict(a, include_confidential=False): + d = { + 'id': a.id, + 'attempt_number': a.attempt_number, + 'status': a.status, + 'proposed_date': a.proposed_date.isoformat() if a.proposed_date else None, + 'course_work_credits': a.course_work_credits, + 'progress_seminar_credits': a.progress_seminar_credits, + 'thesis_research_credits': a.thesis_research_credits, + 'teaching_credits': a.teaching_credits, + 'total_credits': a.total_credits, + 'semesters_completed': a.semesters_completed, + 'rpc_recommended_open_seminar': a.rpc_recommended_open_seminar, + 'first_draft_sent_to_dean': a.first_draft_sent_to_dean, + 'convener_remarks': a.convener_remarks, + 'dean_nominee': ( + {'id': a.dean_nominee.id.id, 'name': str(a.dean_nominee)} + if a.dean_nominee else None + ), + 'result': a.result, + 'committee_comments': a.committee_comments, + 'reported_at': a.reported_at.isoformat() if a.reported_at else None, + 'dn_submitted_at': a.dn_submitted_at.isoformat() if a.dn_submitted_at else None, + '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 a.committee.all() + ], + } + if include_confidential: + d.update({ + 'dn_quality': a.dn_quality, + 'dn_quantity': a.dn_quantity, + 'dn_publications': a.dn_publications, + 'dn_overall': a.dn_overall, + 'dn_comments': a.dn_comments, + }) + return d + + +def open_seminar_to_dict(seminar, include_confidential=False): + """Serialize an OpenSeminar (with attempts). Confidential Dean-Nominee + fields are only included for Convener/Dean-Nominee-facing endpoints.""" + return { + 'id': seminar.id, + 'student_roll': seminar.student.id.id, + 'student_name': seminar.student.id.user.get_full_name(), + 'student_discipline': seminar.student.specialization, + 'supervisor': { + 'id': seminar.supervisor.id.id, + 'name': str(seminar.supervisor), + 'discipline': seminar.supervisor.id.department.name if seminar.supervisor.id.department else '', + }, + 'co_supervisor': ( + {'id': seminar.co_supervisor.id.id, 'name': str(seminar.co_supervisor)} + if seminar.co_supervisor else None + ), + 'possible_thesis_title': seminar.possible_thesis_title, + 'status': seminar.status, + 'current_attempt_number': seminar.current_attempt_number, + 'attempts': [ + _open_seminar_attempt_to_dict(a, include_confidential) + for a in seminar.attempts.order_by('attempt_number') + ], + } + + +def _build_open_seminar_attempt(seminar, attempt_number, data, student): + """Shared builder for creating an OpenSeminarAttempt from request data + (used by both the initial propose and later retries).""" + eligibility = _compute_open_seminar_eligibility(student) + committee_ids = data.get('committee', []) + if len(committee_ids) > 5: + raise ValueError('At most 5 committee members allowed') + + attempt = OpenSeminarAttempt.objects.create( + open_seminar=seminar, + attempt_number=attempt_number, + proposed_date=data.get('proposed_date') or None, + teaching_credits=int(data.get('teaching_credits', 0) or 0), + first_draft_sent_to_dean=bool(data.get('first_draft_sent_to_dean', False)), + **eligibility, + ) + for member_id in committee_ids: + OpenSeminarCommitteeMember.objects.get_or_create(attempt=attempt, member_id=member_id) + return attempt + + +# 0. Shared + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def open_seminar_eligibility_preview(request, roll_no): + """ + GET /supervisor/open-seminar/eligibility// + Read-only preview of the auto-computed credit breakdown + RPC + recommendation, so the supervisor can see them before proposing/retrying + -- never manually entered. + """ + try: + student = Student.objects.get(id=roll_no) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student not found'}, status=404) + + return JsonResponse(_compute_open_seminar_eligibility(student), status=200) + + +# 1. Student + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def student_open_seminar_api(request): + """GET /stu/open-seminar/ -> fetch the requesting student's Open Seminar ({} if none).""" + try: + student = Student.objects.get(id=request.user.extrainfo) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) + + seminar = OpenSeminar.objects.filter(student=student).first() + return JsonResponse(open_seminar_to_dict(seminar) if seminar else {}, status=200) + + +# 2. Supervisor + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_open_seminar_dashboard(request): + """GET /supervisor/open-seminar/dashboard/ -> Open Seminars supervised or co-supervised by the requester.""" + try: + faculty = Faculty.objects.get(id__user=request.user) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) + + qs = OpenSeminar.objects.filter( + Q(supervisor=faculty) | Q(co_supervisor=faculty) + ).select_related('student__id__user', 'supervisor__id__user') + + return JsonResponse({'seminars': [open_seminar_to_dict(s) for s in qs]}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_propose_open_seminar(request): + """ + POST /supervisor/open-seminar/propose/ + Body: { roll_no, possible_thesis_title, co_supervisor_id, proposed_date, + teaching_credits, first_draft_sent_to_dean, committee: [ids] } + course_work/progress_seminar/thesis_research credits, semesters_completed, + and rpc_recommended_open_seminar are computed server-side. + """ + try: + faculty = Faculty.objects.get(id__user=request.user) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) + + data = request.data + roll_no = data.get('roll_no') + if not roll_no: + return JsonResponse({'error': 'roll_no is required'}, status=400) + + try: + student = Student.objects.get(id=roll_no) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student not found'}, status=404) + + if OpenSeminar.objects.filter(student=student).exists(): + return JsonResponse({'error': 'Open Seminar already exists for this student'}, status=400) + + try: + with transaction.atomic(): + seminar = OpenSeminar.objects.create( + student=student, + supervisor=faculty, + co_supervisor_id=data.get('co_supervisor_id') or None, + possible_thesis_title=data.get('possible_thesis_title', ''), + ) + _build_open_seminar_attempt(seminar, 1, data, student) + except ValueError as e: + return JsonResponse({'error': str(e)}, status=400) + + return JsonResponse(open_seminar_to_dict(seminar), status=201) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_open_seminar_detail(request, pk): + """GET /supervisor/open-seminar// -> full detail (also used to prefill a resubmission).""" + seminar = get_object_or_404(OpenSeminar, pk=pk) + return JsonResponse(open_seminar_to_dict(seminar), status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_resubmit_open_seminar(request, pk): + """ + POST /supervisor/open-seminar//resubmit/ + Edits the CURRENT attempt after a Convener rejection and resends for + approval (same attempt number -- the seminar hasn't happened yet). + """ + seminar = get_object_or_404(OpenSeminar, pk=pk) + if not _is_open_seminar_supervisor_or_co(request, seminar): + return JsonResponse({'error': 'Not authorized'}, status=403) + + attempt = seminar.attempts.order_by('-attempt_number').first() + if not attempt or attempt.status != 'convener_rejected': + return JsonResponse({'error': 'Cannot edit at this stage'}, status=403) + + data = request.data + if 'possible_thesis_title' in data: + seminar.possible_thesis_title = data['possible_thesis_title'] + if 'co_supervisor_id' in data: + seminar.co_supervisor_id = data['co_supervisor_id'] or None + seminar.save() + + eligibility = _compute_open_seminar_eligibility(seminar.student) + for field, value in eligibility.items(): + setattr(attempt, field, value) + attempt.proposed_date = data.get('proposed_date') or attempt.proposed_date + if 'teaching_credits' in data: + attempt.teaching_credits = int(data['teaching_credits'] or 0) + if 'first_draft_sent_to_dean' in data: + attempt.first_draft_sent_to_dean = bool(data['first_draft_sent_to_dean']) + attempt.status = 'convener_pending' + attempt.convener_remarks = '' + + if 'committee' in data: + committee_ids = data['committee'] + if len(committee_ids) > 5: + return JsonResponse({'error': 'At most 5 committee members allowed'}, status=400) + with transaction.atomic(): + attempt.save() + OpenSeminarCommitteeMember.objects.filter(attempt=attempt).delete() + for member_id in committee_ids: + OpenSeminarCommitteeMember.objects.get_or_create(attempt=attempt, member_id=member_id) + else: + attempt.save() + + return JsonResponse(open_seminar_to_dict(seminar), status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_retry_open_seminar(request, pk): + """ + POST /supervisor/open-seminar//retry/ + Body: same shape as propose (fresh committee, date, teaching_credits, etc.) + After a 'not_satisfactory' result, starts a brand-new attempt (unlimited + retries, no cap). + """ + seminar = get_object_or_404(OpenSeminar, pk=pk) + if not _is_open_seminar_supervisor_or_co(request, seminar): + return JsonResponse({'error': 'Not authorized'}, status=403) + + latest = seminar.attempts.order_by('-attempt_number').first() + if not latest or latest.status != 'not_satisfactory': + return JsonResponse({'error': 'Cannot start a new attempt at this stage'}, status=403) + + try: + with transaction.atomic(): + next_number = latest.attempt_number + 1 + _build_open_seminar_attempt(seminar, next_number, request.data, seminar.student) + seminar.current_attempt_number = next_number + seminar.status = 'in_progress' + seminar.save(update_fields=['current_attempt_number', 'status']) + except ValueError as e: + return JsonResponse({'error': str(e)}, status=400) + + return JsonResponse(open_seminar_to_dict(seminar), status=200) + + +# 3. Convener (Dean Academic stands in for DPGC/PGCS for now) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def convener_open_seminar_dashboard(request): + """GET /dean/open-seminar/dashboard/ -> committee approvals + results pending.""" + pending_committee = OpenSeminarAttempt.objects.filter(status='convener_pending').select_related('open_seminar') + pending_reports = OpenSeminarAttempt.objects.filter(status='result_pending').select_related('open_seminar') + + return JsonResponse({ + 'pending_committee': [open_seminar_to_dict(a.open_seminar, include_confidential=True) for a in pending_committee], + 'pending_reports': [open_seminar_to_dict(a.open_seminar, include_confidential=True) for a in pending_reports], + }, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def convener_approve_open_seminar_committee(request, attempt_pk): + """ + POST /dean/open-seminar/attempt//approve-committee/ + Body: { approve: true|false, dean_nominee_id, remarks } + Approving requires appointing a Dean Nominee. + """ + attempt = get_object_or_404(OpenSeminarAttempt, pk=attempt_pk, status='convener_pending') + data = request.data + + attempt.convener_remarks = data.get('remarks', '') + attempt.convener_by = request.user + attempt.convener_at = timezone.now() + + if data.get('approve'): + dean_nominee_id = data.get('dean_nominee_id') + if not dean_nominee_id: + return JsonResponse({'error': 'A Dean Nominee must be appointed to approve.'}, status=400) + attempt.dean_nominee_id = dean_nominee_id + attempt.status = 'result_pending' + else: + attempt.status = 'convener_rejected' + + attempt.save() + return JsonResponse(open_seminar_to_dict(attempt.open_seminar, include_confidential=True), status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def convener_submit_open_seminar_report(request, attempt_pk): + """ + POST /dean/open-seminar/attempt//report/ + Body: { result: satisfactory|not_satisfactory, comments } + Records the committee's authoritative verdict (the Dean Nominee's report + is independent and doesn't gate this). + """ + attempt = get_object_or_404(OpenSeminarAttempt, pk=attempt_pk, status='result_pending') + data = request.data + + result = data.get('result') + if result not in ('satisfactory', 'not_satisfactory'): + return JsonResponse({'error': 'result must be satisfactory or not_satisfactory'}, status=400) + + attempt.result = result + attempt.status = result + attempt.committee_comments = data.get('comments', '') + attempt.reported_by = request.user + attempt.reported_at = timezone.now() + attempt.save() + + seminar = attempt.open_seminar + if result == 'satisfactory': + seminar.status = 'satisfactory' + seminar.save(update_fields=['status']) + + return JsonResponse(open_seminar_to_dict(seminar, include_confidential=True), status=200) + + +# 4. Dean Nominee (ad-hoc faculty appointment) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def dean_nominee_open_seminar_dashboard(request): + """ + GET /faculty/open-seminar-nominee/dashboard/ + Attempts where the requester is the appointed Dean Nominee, pending their + own confidential report. + """ + try: + faculty = Faculty.objects.get(id__user=request.user) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) + + qs = OpenSeminarAttempt.objects.filter( + dean_nominee=faculty, dn_submitted_at__isnull=True, + ).select_related('open_seminar') + + # Include the specific attempt id the nominee was appointed to and still + # owes a report for -- the seminar's *current* attempt may have moved on + # (e.g. a retry) since this nominee was appointed, so the report must + # target this attempt, not whichever one is current now. + return JsonResponse({ + 'pending': [ + {**open_seminar_to_dict(a.open_seminar, include_confidential=True), 'nominee_attempt_id': a.id} + for a in qs + ], + }, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def dean_nominee_submit_open_seminar_report(request, attempt_pk): + """ + POST /faculty/open-seminar-nominee/attempt//report/ + Body: { quality, quantity, publications, overall, comments } + Only the appointed Dean Nominee can submit this -- confidential, kept + separate from (and not gating) the committee's own verdict. + """ + attempt = get_object_or_404(OpenSeminarAttempt, pk=attempt_pk) + try: + faculty = Faculty.objects.get(id__user=request.user) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) + + if attempt.dean_nominee_id != faculty.pk: + return JsonResponse({'error': 'Not authorized'}, status=403) + + data = request.data + attempt.dn_quality = data.get('quality', '') + attempt.dn_quantity = data.get('quantity', '') + attempt.dn_publications = data.get('publications', '') + attempt.dn_overall = data.get('overall', '') + attempt.dn_comments = data.get('comments', '') + attempt.dn_submitted_at = timezone.now() + attempt.save() + + return JsonResponse({'message': 'Report submitted.'}, status=200) + + +# =========================================================================== +# Teaching Credit +# =========================================================================== +# Workflow: [Precondition: ComprehensiveExam.status == 'passed'] -> Student +# submits 4 course choices for a semester -> HOD allocates one of the 4 (or +# sends it back with remarks, student edits and resubmits) -> [offline +# teaching] -> any student registered for the allocated course that semester +# submits one anonymous evaluation -> HOD reviews the aggregated (anonymized) +# evaluations and marks the registration completed with a satisfactory/ +# not_satisfactory result. Not_satisfactory is terminal -- no retry, a fresh +# attempt would just be a new semester's registration. + +def _teaching_credit_choice_dict(course): + if not course: + return None + return {'id': course.id, 'code': course.code, 'name': course.name} + + +def teaching_credit_to_dict(reg, include_evaluations=False): + """Serialize a TeachingCreditAllocation. Evaluation respondents are + never included -- only aggregated/anonymized responses, and only when + include_evaluations is explicitly requested (HOD-facing endpoints).""" + d = { + 'id': reg.id, + 'student_roll': reg.student.id.id, + 'student_name': reg.student.id.user.get_full_name(), + 'student_discipline': reg.student.specialization, + 'semester_no': reg.semester.semester_no, + 'choices': [ + _teaching_credit_choice_dict(reg.choice_1), + _teaching_credit_choice_dict(reg.choice_2), + _teaching_credit_choice_dict(reg.choice_3), + _teaching_credit_choice_dict(reg.choice_4), + ], + 'status': reg.status, + 'allocated_course': _teaching_credit_choice_dict(reg.allocated_course), + 'hod_remarks': reg.hod_remarks, + 'result': reg.result, + 'evaluation_count': reg.evaluations.count(), + } + if include_evaluations: + d['evaluations'] = [ + { + 'punctuality_band': e.punctuality_band, + 'schedule_adherence_band': e.schedule_adherence_band, + 'topics_sequence': e.topics_sequence, + 'teaching_aids': e.teaching_aids, + 'questions_answered': e.questions_answered, + 'overall_effectiveness': e.overall_effectiveness, + 'strengths_weaknesses': e.strengths_weaknesses, + } + for e in reg.evaluations.all() + ] + return d + + +def _hod_discipline_acronyms(user): + """Mirrors get_hod_disciplines used elsewhere -- discipline acronyms this user is HOD of.""" + hod_designations = HoldsDesignation.objects.filter( + working=user, designation__name__icontains='HOD' + ).values_list('designation__name', flat=True) + acronyms = [] + for des_name in hod_designations: + if '(' in des_name and ')' in des_name: + acronyms.append(des_name[des_name.index('(') + 1:des_name.index(')')].strip()) + return acronyms + + +def _is_hod_of_student(user, student): + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + if not acronym: + return False + return HoldsDesignation.objects.filter(working=user, designation__name=f"HOD ({acronym})").exists() + + +# 1. Student + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def student_teaching_credit_api(request): + """GET /stu/teaching-credit/ -> this student's own registrations (all semesters).""" + try: + student = Student.objects.get(id=request.user.extrainfo) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) + + regs = TeachingCreditAllocation.objects.filter(student=student).order_by('-semester__semester_no') + return JsonResponse({ + 'registrations': [teaching_credit_to_dict(r) for r in regs], + 'comprehensive_exam_passed': ComprehensiveExam.objects.filter(student=student, status='passed').exists(), + }, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def student_propose_teaching_credit(request): + """ + POST /stu/teaching-credit/propose/ + Body: { choice_1, choice_2, choice_3, choice_4 } + Precondition: ComprehensiveExam.status == 'passed'. The semester is + resolved server-side from the student's current curriculum position + (same pattern as student_thesis_enrollment_api), not taken from the client. + """ + try: + student = Student.objects.get(id=request.user.extrainfo) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) + + if not ComprehensiveExam.objects.filter(student=student, status='passed').exists(): + return JsonResponse( + {'error': 'Comprehensive Examination must be passed before registering for teaching credit.'}, + status=403, + ) - for col, header in enumerate(headers, 1): - worksheet.cell(row=1, column=col, value=header) + 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) - # 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) + data = request.data + choice_1 = data.get('choice_1') + if not choice_1: + return JsonResponse({'error': 'choice_1 is required'}, status=400) - workbook.save(output) - output.seek(0) + if TeachingCreditAllocation.objects.filter(student=student, semester=semester).exists(): + return JsonResponse({'error': 'Already registered for teaching credit this semester'}, status=400) - 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 + reg = TeachingCreditAllocation.objects.create( + student=student, + semester=semester, + choice_1_id=choice_1, + choice_2_id=data.get('choice_2') or None, + choice_3_id=data.get('choice_3') or None, + choice_4_id=data.get('choice_4') or None, + ) + return JsonResponse(teaching_credit_to_dict(reg), status=201) - except Exception as e: - return JsonResponse({'error': f'Failed to generate template: {str(e)}'}, status=500) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def student_teaching_credit_detail(request, pk): + """GET /stu/teaching-credit// -> full detail (also used to prefill a resubmission).""" + reg = get_object_or_404(TeachingCreditAllocation, pk=pk) + return JsonResponse(teaching_credit_to_dict(reg), status=200) @api_view(['POST']) @permission_classes([IsAuthenticated]) -@parser_classes([MultiPartParser, FormParser]) -def supervisor_upload_all_thesis_grades(request): +def student_resubmit_teaching_credit(request, pk): """ - 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. + POST /stu/teaching-credit//resubmit/ + Edits choices after HOD sends it back, resends for HOD decision. """ - user = request.user - uploaded_file = request.FILES.get('file') + reg = get_object_or_404(TeachingCreditAllocation, pk=pk) + try: + student = Student.objects.get(id=request.user.extrainfo) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) - if not uploaded_file: - return JsonResponse({'error': 'file is required'}, status=400) + if reg.student_id != student.pk: + return JsonResponse({'error': 'Not authorized'}, status=403) + if reg.status != 'sent_back': + return JsonResponse({'error': 'Cannot edit at this stage'}, status=403) - try: - faculty = Faculty.objects.get(id__user=user) - except Faculty.DoesNotExist: - return JsonResponse({'error': 'Faculty record not found'}, status=404) + data = request.data + if 'choice_1' in data: + reg.choice_1_id = data['choice_1'] + if 'choice_2' in data: + reg.choice_2_id = data['choice_2'] or None + if 'choice_3' in data: + reg.choice_3_id = data['choice_3'] or None + if 'choice_4' in data: + reg.choice_4_id = data['choice_4'] or None + reg.status = 'pending' + reg.hod_remarks = '' + reg.save() - # Parse Excel file + return JsonResponse(teaching_credit_to_dict(reg), status=200) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def student_teaching_credit_evaluation_targets(request): + """ + GET /stu/teaching-credit/evaluation-targets/ + Allocated (or completed) registrations for courses the requesting + student is registered for this semester -- i.e. whose Research Scholar + they're eligible to evaluate -- excluding ones already submitted. + """ 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) + student = Student.objects.get(id=request.user.extrainfo) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) - # Normalize column names - df.columns = [col.strip().lower() for col in df.columns] + registered_course_ids = course_registration.objects.filter(student_id=student).values_list('course_id', flat=True) + already_evaluated = TeachingCreditEvaluationResponse.objects.filter( + respondent=student + ).values_list('registration_id', flat=True) - # Find roll number and remarks columns - roll_col = None - remarks_col = None - grade_cols = {} # {block_number: column_name} + qs = TeachingCreditAllocation.objects.filter( + status__in=['allocated', 'completed'], + allocated_course_id__in=registered_course_ids, + ).exclude(id__in=already_evaluated) - 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 + return JsonResponse({'targets': [teaching_credit_to_dict(r) for r in qs]}, status=200) - 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 +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def student_submit_teaching_credit_evaluation(request, pk): + """ + POST /stu/teaching-credit//evaluate/ + Body: { punctuality_band, schedule_adherence_band, topics_sequence, + teaching_aids, questions_answered, overall_effectiveness, + strengths_weaknesses } + Only a student registered for the allocated course may submit, once. + Anonymous -- respondent identity is never exposed via API. + + Note: eligibility is checked by course only, not by matching + `reg.semester` -- that field is the PhD registrant's own semester + (resolved from their curriculum), which is a different `Semester` row + than the respondent's `course_registration.semester_id` whenever the + two students are in different curricula (e.g. a UG respondent taking a + course a PhD scholar is teaching) -- `Semester` is scoped per-curriculum, + so exact FK matching across curricula can never succeed. + """ + reg = get_object_or_404(TeachingCreditAllocation, pk=pk, status__in=['allocated', 'completed']) + try: + student = Student.objects.get(id=request.user.extrainfo) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) + + is_registered = course_registration.objects.filter( + student_id=student, course_id=reg.allocated_course, + ).exists() + if not is_registered: + return JsonResponse({'error': 'You are not registered for this course'}, status=403) + + if TeachingCreditEvaluationResponse.objects.filter(registration=reg, respondent=student).exists(): + return JsonResponse({'error': 'You have already submitted an evaluation for this course'}, status=400) + + data = request.data + TeachingCreditEvaluationResponse.objects.create( + registration=reg, + respondent=student, + punctuality_band=data.get('punctuality_band', ''), + schedule_adherence_band=data.get('schedule_adherence_band', ''), + topics_sequence=data.get('topics_sequence', ''), + teaching_aids=data.get('teaching_aids', ''), + questions_answered=data.get('questions_answered', ''), + overall_effectiveness=data.get('overall_effectiveness', ''), + strengths_weaknesses=data.get('strengths_weaknesses', ''), ) + return JsonResponse({'message': 'Evaluation submitted.'}, status=201) - # 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 = [] +# 2. HOD - # 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 = [] +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def hod_teaching_credit_dashboard(request): + """ + GET /hod/teaching-credit/dashboard/ + Pending decisions + allocated-awaiting-completion, scoped to the HOD's + own discipline. + """ + user = request.user + hod_disciplines = _hod_discipline_acronyms(user) - 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 + qs = TeachingCreditAllocation.objects.filter( + status__in=['pending', 'allocated'] + ).select_related('student__id__user', 'student__batch_id__discipline') - 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'] - }) + pending, awaiting_completion = [], [] + for reg in qs: + student = reg.student + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + if not acronym or acronym not in hod_disciplines: continue + if reg.status == 'pending': + pending.append(teaching_credit_to_dict(reg)) + else: + awaiting_completion.append(teaching_credit_to_dict(reg, include_evaluations=True)) - # 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 '' + return JsonResponse({'pending': pending, 'awaiting_completion': awaiting_completion}, status=200) - # 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 +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def hod_decide_teaching_credit(request, pk): + """ + POST /hod/teaching-credit//decide/ + Body: { allocate: true|false, allocated_course (required if allocate), remarks } + allocated_course must be one of the student's 4 submitted choices. + """ + reg = get_object_or_404(TeachingCreditAllocation, pk=pk, status='pending') + user = request.user - # 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 + if not _is_hod_of_student(user, reg.student): + return JsonResponse({'error': 'Not authorized'}, status=403) - row_submissions.append({ - 'evaluation_id': eval_lookup[roll_no][block_num], - 'block_number': block_num, - 'grade': grade, - 'remarks': remarks - }) + data = request.data + reg.decided_by = user + reg.decided_at = timezone.now() - 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) + if data.get('allocate'): + allocated_course_id = data.get('allocated_course') + valid_choices = { + str(c) for c in (reg.choice_1_id, reg.choice_2_id, reg.choice_3_id, reg.choice_4_id) if c + } + if str(allocated_course_id) not in valid_choices: + return JsonResponse( + {'error': "Allocated course must be one of the student's 4 choices"}, status=400, + ) + reg.allocated_course_id = allocated_course_id + reg.hod_remarks = '' + reg.status = 'allocated' + else: + reg.hod_remarks = data.get('remarks', '') + reg.status = 'sent_back' - return JsonResponse({ - 'valid_rows': valid_rows, - 'invalid_rows': invalid_rows - }, status=200) + reg.save() + return JsonResponse(teaching_credit_to_dict(reg), status=200) @api_view(['POST']) @permission_classes([IsAuthenticated]) -def supervisor_bulk_submit_all_thesis_grades(request): +def hod_complete_teaching_credit(request, pk): """ - POST /supervisor/thesis-grades-all/bulk-submit/ - Submits multiple grades across multiple blocks in one request. - Body: { "submissions": [{"evaluation_id": 123, "grade": "S", "remarks": "..."}, ...] } + POST /hod/teaching-credit//complete/ + Body: { result: satisfactory|not_satisfactory } + Satisfactory awards the credit; not_satisfactory is terminal. """ + reg = get_object_or_404(TeachingCreditAllocation, pk=pk, status='allocated') 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') + if not _is_hod_of_student(user, reg.student): + return JsonResponse({'error': 'Not authorized'}, status=403) - success_count = 0 - errors = [] - evaluations_to_update = [] + result = request.data.get('result') + if result not in ('satisfactory', 'not_satisfactory'): + return JsonResponse({'error': 'result must be satisfactory or not_satisfactory'}, status=400) - # Process each submission - for idx, submission in enumerate(submissions): - eval_id = submission.get('evaluation_id') - grade = submission.get('grade', '').upper() - remarks = submission.get('remarks', '') + reg.result = result + reg.status = 'completed' + reg.completed_by = user + reg.completed_at = timezone.now() + reg.save() - 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 + return JsonResponse(teaching_credit_to_dict(reg, include_evaluations=True), status=200) - 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] +# 3. Supervisor (read-only) - # 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 +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_teaching_credit_list(request): + """GET /supervisor/teaching-credit/ -> read-only list for the requester's thesis students.""" + try: + faculty = Faculty.objects.get(id__user=request.user) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) - # Update evaluation - evaluation.grade = grade - evaluation.remarks = remarks - evaluation.submitted_by = faculty - evaluation.submitted_at = now + student_ids = ThesisTopic.objects.filter( + Q(supervisor=faculty) | Q(co_supervisor=faculty) + ).values_list('student_id', flat=True) - evaluations_to_update.append(evaluation) - success_count += 1 + qs = TeachingCreditAllocation.objects.filter(student_id__in=student_ids) + return JsonResponse({'registrations': [teaching_credit_to_dict(r) for r in qs]}, status=200) - 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 - ) +# 4. Academic Office (read-only) - return JsonResponse({ - 'success_count': success_count, - 'error_count': len(errors), - 'errors': errors if errors else None - }, status=200) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def academic_office_teaching_credit_list(request): + """GET /acadadmin/teaching-credit/ -> read-only list of all teaching-credit registrations.""" + qs = TeachingCreditAllocation.objects.select_related('student__id__user').all().order_by('-created_at') + return JsonResponse({'registrations': [teaching_credit_to_dict(r) for r in qs]}, status=200) diff --git a/FusionIIIT/applications/academic_procedures/migrations/0029_auto_20260712_1056.py b/FusionIIIT/applications/academic_procedures/migrations/0029_auto_20260712_1056.py new file mode 100644 index 000000000..88bb140c6 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0029_auto_20260712_1056.py @@ -0,0 +1,31 @@ +# Generated by Django 3.1.5 on 2026-07-12 10:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0028_examinerbankdetails_thesisreview'), + ] + + operations = [ + migrations.AddField( + model_name='seminarentry', + name='pub_presented_unpublished', + field=models.PositiveIntegerField(default=0, help_text='Papers presented in conferences/meetings/workshops (unpublished)'), + ), + migrations.AddField( + model_name='seminarentry', + name='pub_published_or_accepted', + field=models.PositiveIntegerField(default=0, help_text='Papers published/accepted in journals or conference proceedings'), + ), + migrations.AddField( + model_name='seminarentry', + name='pub_submitted_under_review', + field=models.PositiveIntegerField(default=0, help_text='Papers submitted (under review)'), + ), + migrations.DeleteModel( + name='PublicationCount', + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0030_thesissubmission_dean_panel_remarks.py b/FusionIIIT/applications/academic_procedures/migrations/0030_thesissubmission_dean_panel_remarks.py new file mode 100644 index 000000000..3f1770688 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0030_thesissubmission_dean_panel_remarks.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-07-12 16:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0029_auto_20260712_1056'), + ] + + operations = [ + migrations.AddField( + model_name='thesissubmission', + name='dean_panel_remarks', + field=models.TextField(blank=True), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0031_thesissubmission_director_remarks.py b/FusionIIIT/applications/academic_procedures/migrations/0031_thesissubmission_director_remarks.py new file mode 100644 index 000000000..a9b2ebae7 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0031_thesissubmission_director_remarks.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-07-12 19:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0030_thesissubmission_dean_panel_remarks'), + ] + + operations = [ + migrations.AddField( + model_name='thesissubmission', + name='director_remarks', + field=models.TextField(blank=True), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0032_comprehensiveexam_comprehensiveexamattempt_comprehensiveexamcommitteemember_floatedsubject.py b/FusionIIIT/applications/academic_procedures/migrations/0032_comprehensiveexam_comprehensiveexamattempt_comprehensiveexamcommitteemember_floatedsubject.py new file mode 100644 index 000000000..4a160c226 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0032_comprehensiveexam_comprehensiveexamattempt_comprehensiveexamcommitteemember_floatedsubject.py @@ -0,0 +1,94 @@ +# Generated by Django 3.1.5 on 2026-07-13 15:39 + +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', '0007_moduleaccess_thesis_research'), + ('academic_information', '0003_merge_20260314_1604'), + ('academic_procedures', '0031_thesissubmission_director_remarks'), + ] + + operations = [ + migrations.CreateModel( + name='ComprehensiveExam', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('possible_thesis_title', models.CharField(blank=True, max_length=300)), + ('entry_qualification', models.CharField(choices=[('masters', 'ME/M.Tech/M.Des/M.Phil (16 credits required)'), ('bachelors', 'B.Tech/B.E./M.Sc./MA (40 credits required)')], max_length=10)), + ('credits_completed', models.PositiveIntegerField(default=0)), + ('current_cpi', models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True)), + ('research_methodology_completed', models.BooleanField(default=False)), + ('credits_verified', models.BooleanField(default=False)), + ('cpi_verified', models.BooleanField(default=False)), + ('research_methodology_verified', models.BooleanField(default=False)), + ('academic_office_remarks', models.TextField(blank=True)), + ('academic_office_verified_at', models.DateTimeField(blank=True, null=True)), + ('convener_remarks', models.TextField(blank=True)), + ('convener_at', models.DateTimeField(blank=True, null=True)), + ('status', models.CharField(choices=[('academic_office_pending', 'Pending Academic Office Verification'), ('academic_office_rejected', 'Rejected by Academic Office'), ('convener_pending', 'Pending Convener Approval'), ('convener_rejected', 'Rejected by Convener'), ('in_progress', 'Committee Approved — In Progress'), ('passed', 'Passed'), ('failed_final', 'Failed — Attempts Exhausted')], default='academic_office_pending', max_length=30)), + ('current_attempt_number', models.PositiveSmallIntegerField(default=1)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('academic_office_verified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comprehensive_exams_office_verified', to=settings.AUTH_USER_MODEL)), + ('co_supervisor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comprehensive_exams_cosupervised', to='globals.faculty')), + ('convener_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comprehensive_exams_convened', to=settings.AUTH_USER_MODEL)), + ('student', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='comprehensive_exam', to='academic_information.student')), + ('supervisor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comprehensive_exams_supervised', to='globals.faculty')), + ], + ), + migrations.CreateModel( + name='ComprehensiveExamAttempt', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('attempt_number', models.PositiveSmallIntegerField()), + ('status', models.CharField(choices=[('subjects_floated', 'Subjects Floated, Pending HOD Approval'), ('hod_rejected', 'Subjects Rejected by HOD'), ('subjects_ready', 'Subjects Approved, Pending Student Selection'), ('subjects_opted', 'Subjects Opted, Pending Supervisor Confirmation'), ('confirmation_rejected', 'Supervisor Rejected Opted Subjects'), ('result_pending', 'Confirmed — Awaiting Result'), ('passed', 'Passed'), ('failed', 'Failed')], default='subjects_floated', max_length=25)), + ('written_exam_date', models.DateField(blank=True, null=True)), + ('oral_exam_date', models.DateField(blank=True, null=True)), + ('hod_remarks', models.TextField(blank=True)), + ('hod_reviewed_at', models.DateTimeField(blank=True, null=True)), + ('supervisor_confirmation_remarks', models.TextField(blank=True)), + ('result', models.CharField(blank=True, choices=[('passed', 'Passed'), ('failed', 'Failed')], max_length=10, null=True)), + ('fundamentals_comment', models.TextField(blank=True)), + ('problem_identification_comment', models.TextField(blank=True)), + ('plan_of_work_comment', models.TextField(blank=True)), + ('suggestions_comment', models.TextField(blank=True)), + ('additional_literature_comment', models.TextField(blank=True)), + ('milestone_plan_upload', models.FileField(blank=True, null=True, upload_to='comprehensive_exam_milestones/')), + ('reported_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('exam', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attempts', to='academic_procedures.comprehensiveexam')), + ('hod_reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comprehensive_attempts_hod_reviewed', to=settings.AUTH_USER_MODEL)), + ('reported_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comprehensive_attempts_reported', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['exam', 'attempt_number'], + 'unique_together': {('exam', 'attempt_number')}, + }, + ), + migrations.CreateModel( + name='FloatedSubject', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subject_name', models.CharField(max_length=200)), + ('selected_by_student', models.BooleanField(default=False)), + ('attempt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subjects', to='academic_procedures.comprehensiveexamattempt')), + ], + ), + migrations.CreateModel( + name='ComprehensiveExamCommitteeMember', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('exam', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='committee', to='academic_procedures.comprehensiveexam')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='globals.faculty')), + ], + options={ + 'unique_together': {('exam', 'member')}, + }, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0033_phdcourseregistrationrequest.py b/FusionIIIT/applications/academic_procedures/migrations/0033_phdcourseregistrationrequest.py new file mode 100644 index 000000000..6c5ec7c8a --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0033_phdcourseregistrationrequest.py @@ -0,0 +1,39 @@ +# Generated by Django 3.1.5 on 2026-07-14 12:28 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0003_merge_20260314_1604'), + ('programme_curriculum', '0045_rename_progress_seminar_to_seminar'), + ('globals', '0007_moduleaccess_thesis_research'), + ('academic_procedures', '0032_comprehensiveexam_comprehensiveexamattempt_comprehensiveexamcommitteemember_floatedsubject'), + ] + + operations = [ + migrations.CreateModel( + name='PhDCourseRegistrationRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('academic_year', models.CharField(max_length=9)), + ('semester_type', models.CharField(choices=[('Odd Semester', 'Odd Semester'), ('Even Semester', 'Even Semester'), ('Summer Semester', 'Summer Semester')], max_length=20)), + ('status', models.CharField(choices=[('Pending', 'Pending'), ('Approved', 'Approved'), ('Rejected', 'Rejected')], default='Pending', max_length=20)), + ('remarks', models.CharField(blank=True, max_length=500)), + ('requested_at', models.DateTimeField(default=django.utils.timezone.now)), + ('processed_at', models.DateTimeField(blank=True, null=True)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='phd_course_requests', to='programme_curriculum.course')), + ('course_slot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.courseslot')), + ('processed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='phd_course_requests_processed', to='globals.extrainfo')), + ('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='phd_course_requests', to='academic_information.student')), + ], + options={ + 'db_table': 'PhDCourseRegistrationRequest', + 'unique_together': {('student', 'semester', 'course_slot')}, + }, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0034_openseminar_openseminarattempt_openseminarcommitteemember.py b/FusionIIIT/applications/academic_procedures/migrations/0034_openseminar_openseminarattempt_openseminarcommitteemember.py new file mode 100644 index 000000000..dbc189bec --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0034_openseminar_openseminarattempt_openseminarcommitteemember.py @@ -0,0 +1,79 @@ +# Generated by Django 3.1.5 on 2026-07-15 17:21 + +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', '0007_moduleaccess_thesis_research'), + ('academic_information', '0003_merge_20260314_1604'), + ('academic_procedures', '0033_phdcourseregistrationrequest'), + ] + + operations = [ + migrations.CreateModel( + name='OpenSeminar', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('possible_thesis_title', models.CharField(blank=True, max_length=300)), + ('status', models.CharField(choices=[('in_progress', 'In Progress'), ('satisfactory', 'Satisfactory')], default='in_progress', max_length=20)), + ('current_attempt_number', models.PositiveSmallIntegerField(default=1)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('co_supervisor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='open_seminars_cosupervised', to='globals.faculty')), + ('student', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='open_seminar', to='academic_information.student')), + ('supervisor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='open_seminars_supervised', to='globals.faculty')), + ], + ), + migrations.CreateModel( + name='OpenSeminarAttempt', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('attempt_number', models.PositiveSmallIntegerField()), + ('status', models.CharField(choices=[('convener_pending', 'Pending Convener Approval'), ('convener_rejected', 'Rejected by Convener'), ('result_pending', 'Committee Approved — Awaiting Result'), ('satisfactory', 'Satisfactory'), ('not_satisfactory', 'Not Satisfactory')], default='convener_pending', max_length=20)), + ('proposed_date', models.DateField(blank=True, null=True)), + ('course_work_credits', models.PositiveIntegerField(default=0)), + ('progress_seminar_credits', models.PositiveIntegerField(default=0)), + ('thesis_research_credits', models.PositiveIntegerField(default=0)), + ('teaching_credits', models.PositiveIntegerField(default=0)), + ('semesters_completed', models.PositiveIntegerField(default=0)), + ('rpc_recommended_open_seminar', models.BooleanField(default=False)), + ('first_draft_sent_to_dean', models.BooleanField(default=False)), + ('convener_remarks', models.TextField(blank=True)), + ('convener_at', models.DateTimeField(blank=True, null=True)), + ('result', models.CharField(blank=True, choices=[('satisfactory', 'Satisfactory'), ('not_satisfactory', 'Not Satisfactory')], max_length=20, null=True)), + ('committee_comments', models.TextField(blank=True)), + ('reported_at', models.DateTimeField(blank=True, null=True)), + ('dn_quality', models.CharField(blank=True, choices=[('Excellent', 'Excellent'), ('Good', 'Good'), ('Sat', 'Satisfactory'), ('Unsat', 'Unsatisfactory')], max_length=20)), + ('dn_quantity', models.CharField(blank=True, choices=[('Enough', 'Enough'), ('Just', 'Just Sufficient'), ('Insuff', 'Insufficient')], max_length=10)), + ('dn_publications', models.CharField(blank=True, choices=[('Enough', 'Enough'), ('Just', 'Just Sufficient'), ('Insuff', 'Insufficient')], max_length=10)), + ('dn_overall', models.CharField(blank=True, choices=[('satisfactory', 'Satisfactory'), ('not_satisfactory', 'Not Satisfactory')], max_length=20)), + ('dn_comments', models.TextField(blank=True)), + ('dn_submitted_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('convener_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='open_seminar_attempts_convened', to=settings.AUTH_USER_MODEL)), + ('dean_nominee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='open_seminar_nominations', to='globals.faculty')), + ('open_seminar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attempts', to='academic_procedures.openseminar')), + ('reported_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='open_seminar_attempts_reported', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['open_seminar', 'attempt_number'], + 'unique_together': {('open_seminar', 'attempt_number')}, + }, + ), + migrations.CreateModel( + name='OpenSeminarCommitteeMember', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('attempt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='committee', to='academic_procedures.openseminarattempt')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='globals.faculty')), + ], + options={ + 'unique_together': {('attempt', 'member')}, + }, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0035_teachingcreditevaluationresponse_teachingcreditpreregistration.py b/FusionIIIT/applications/academic_procedures/migrations/0035_teachingcreditevaluationresponse_teachingcreditpreregistration.py new file mode 100644 index 000000000..d86dce342 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0035_teachingcreditevaluationresponse_teachingcreditpreregistration.py @@ -0,0 +1,62 @@ +# Generated by Django 3.1.5 on 2026-07-15 18:40 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0045_rename_progress_seminar_to_seminar'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('academic_information', '0003_merge_20260314_1604'), + ('academic_procedures', '0034_openseminar_openseminarattempt_openseminarcommitteemember'), + ] + + operations = [ + migrations.CreateModel( + name='TeachingCreditPreRegistration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('pending', 'Pending HOD Decision'), ('sent_back', 'Sent Back by HOD'), ('allocated', 'Course Allocated'), ('completed', 'Completed')], default='pending', max_length=20)), + ('hod_remarks', models.TextField(blank=True)), + ('decided_at', models.DateTimeField(blank=True, null=True)), + ('result', models.CharField(blank=True, choices=[('satisfactory', 'Satisfactory'), ('not_satisfactory', 'Not Satisfactory')], max_length=20, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('allocated_course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teaching_credit_allocations', to='programme_curriculum.course')), + ('choice_1', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teaching_credit_choice1', to='programme_curriculum.course')), + ('choice_2', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='teaching_credit_choice2', to='programme_curriculum.course')), + ('choice_3', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='teaching_credit_choice3', to='programme_curriculum.course')), + ('choice_4', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='teaching_credit_choice4', to='programme_curriculum.course')), + ('completed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teaching_credit_completions', to=settings.AUTH_USER_MODEL)), + ('decided_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teaching_credit_decisions', to=settings.AUTH_USER_MODEL)), + ('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='teaching_credit_registrations', to='academic_information.student')), + ], + options={ + 'unique_together': {('student', 'semester')}, + }, + ), + migrations.CreateModel( + name='TeachingCreditEvaluationResponse', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('punctuality_band', models.CharField(blank=True, choices=[('<80', '<80%'), ('80-90', '80%-90%'), ('90-95', '90%-95%'), ('95-100', '95%-100%')], max_length=10)), + ('schedule_adherence_band', models.CharField(blank=True, choices=[('<80', '<80%'), ('80-90', '80%-90%'), ('90-95', '90%-95%'), ('95-100', '95%-100%')], max_length=10)), + ('topics_sequence', models.CharField(blank=True, choices=[('Poor', 'Poor'), ('Average', 'Average'), ('Good', 'Good'), ('Excellent', 'Excellent')], max_length=10)), + ('teaching_aids', models.CharField(blank=True, choices=[('Poor', 'Poor'), ('Average', 'Average'), ('Good', 'Good'), ('Excellent', 'Excellent')], max_length=10)), + ('questions_answered', models.CharField(blank=True, choices=[('Poor', 'Poor'), ('Average', 'Average'), ('Good', 'Good'), ('Excellent', 'Excellent')], max_length=10)), + ('overall_effectiveness', models.CharField(blank=True, choices=[('Poor', 'Poor'), ('Average', 'Average'), ('Good', 'Good'), ('Excellent', 'Excellent')], max_length=10)), + ('strengths_weaknesses', models.TextField(blank=True)), + ('submitted_at', models.DateTimeField(auto_now_add=True)), + ('registration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='academic_procedures.teachingcreditpreregistration')), + ('respondent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teaching_credit_evaluations_given', to='academic_information.student')), + ], + options={ + 'unique_together': {('registration', 'respondent')}, + }, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0036_rename_seminar_and_teaching_credit_tables.py b/FusionIIIT/applications/academic_procedures/migrations/0036_rename_seminar_and_teaching_credit_tables.py new file mode 100644 index 000000000..a5265d1f1 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0036_rename_seminar_and_teaching_credit_tables.py @@ -0,0 +1,99 @@ +# Renames for consistency (see project decision log, 2026-07-16): +# SeminarEntry/SeminarConsent/SeminarComment -> ProgressSeminarEntry/Consent/Comment +# (these are unambiguously progress-seminar-only; the bare "Seminar" prefix +# was ambiguous against OpenSeminar in this same app) +# TeachingCreditPreRegistration -> TeachingCreditAllocation +# ("Pre-Registration" stopped making sense once the legacy +# TeachingCreditRegistration table below became the real enrollment step +# that happens *before* this one) +# and repurposing the legacy, dead TeachingCreditRegistration table into a +# semester-enrollment record mirroring ThesisRegistration/ProgressSeminarRegistration. + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0045_rename_progress_seminar_to_seminar'), + ('academic_procedures', '0035_teachingcreditevaluationresponse_teachingcreditpreregistration'), + ] + + operations = [ + migrations.RenameModel(old_name='SeminarEntry', new_name='ProgressSeminarEntry'), + migrations.RenameModel(old_name='SeminarConsent', new_name='ProgressSeminarConsent'), + migrations.RenameModel(old_name='SeminarComment', new_name='ProgressSeminarComment'), + migrations.RenameModel(old_name='TeachingCreditPreRegistration', new_name='TeachingCreditAllocation'), + migrations.AlterField( + model_name='teachingcreditallocation', + name='student', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teaching_credit_allocations', to='academic_information.student'), + ), + + # Repurpose the legacy TeachingCreditRegistration table (previously an + # admin-entered, 4-choice teaching-credit record predating + # TeachingCreditAllocation) into a lightweight semester-enrollment + # record, matching ThesisRegistration / ProgressSeminarRegistration. + # It was dead (only reachable via a retired Django-template admin + # page, never used by the React frontend), so its existing rows carry + # no data worth preserving under the new schema -- clear it first so + # the new NOT NULL fields below don't need throwaway defaults. + migrations.RunSQL( + sql='DELETE FROM "TeachingCreditRegistration";', + reverse_sql=migrations.RunSQL.noop, + ), + migrations.RemoveField(model_name='teachingcreditregistration', name='approved_course'), + migrations.RemoveField(model_name='teachingcreditregistration', name='course_completion'), + migrations.RemoveField(model_name='teachingcreditregistration', name='curr_1'), + migrations.RemoveField(model_name='teachingcreditregistration', name='curr_2'), + migrations.RemoveField(model_name='teachingcreditregistration', name='curr_3'), + migrations.RemoveField(model_name='teachingcreditregistration', name='curr_4'), + migrations.RemoveField(model_name='teachingcreditregistration', name='req_pending'), + migrations.RemoveField(model_name='teachingcreditregistration', name='supervisor_id'), + migrations.RemoveField(model_name='teachingcreditregistration', name='student_id'), + migrations.AddField( + model_name='teachingcreditregistration', + name='student', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teaching_credit_registrations', to='academic_information.student'), + ), + migrations.AddField( + model_name='teachingcreditregistration', + name='teaching_credit_slot', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registrations', to='programme_curriculum.teachingcreditslot'), + ), + migrations.AddField( + model_name='teachingcreditregistration', + name='semester', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.semester'), + ), + migrations.AddField( + model_name='teachingcreditregistration', + name='working_year', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='teachingcreditregistration', + name='academic_session', + field=models.CharField(blank=True, max_length=9, null=True), + ), + migrations.AddField( + model_name='teachingcreditregistration', + name='status', + field=models.CharField(choices=[('pending', 'Pending Verification'), ('verified', 'Verified'), ('rejected', 'Rejected')], default='pending', max_length=20), + ), + migrations.AddField( + model_name='teachingcreditregistration', + name='registered_on', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AddField( + model_name='teachingcreditregistration', + name='remarks', + field=models.CharField(blank=True, max_length=500), + ), + migrations.AlterUniqueTogether( + name='teachingcreditregistration', + unique_together={('student', 'semester')}, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0037_add_semester_to_progress_seminar_entry.py b/FusionIIIT/applications/academic_procedures/migrations/0037_add_semester_to_progress_seminar_entry.py new file mode 100644 index 000000000..1291aacb2 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0037_add_semester_to_progress_seminar_entry.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.5 on 2026-07-18 19:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0045_rename_progress_seminar_to_seminar'), + ('academic_procedures', '0036_rename_seminar_and_teaching_credit_tables'), + ] + + operations = [ + migrations.AddField( + model_name='progressseminarentry', + name='semester', + field=models.ForeignKey(blank=True, help_text="Student's semester when this report was created.", null=True, on_delete=django.db.models.deletion.SET_NULL, to='programme_curriculum.semester'), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/models.py b/FusionIIIT/applications/academic_procedures/models.py index b24a77030..41c8573d3 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, ThesisSlot, ProgressSeminarSlot +from applications.programme_curriculum.models import Course as Courses, Semester, CourseSlot, Batch, ThesisSlot, SeminarSlot as ProgressSeminarSlot, TeachingCreditSlot from applications.globals.models import DepartmentInfo, ExtraInfo, Faculty from django.utils import timezone @@ -241,22 +241,6 @@ class FeePayment(models.Model): mode = models.CharField(max_length = 20, choices=Constants.PaymentMode) transaction_id = models.CharField(max_length = 40) -class TeachingCreditRegistration(models.Model): - - - student_id = models.ForeignKey(Student, on_delete = models.CASCADE) - curr_1 = models.ForeignKey(Curriculum, on_delete = models.CASCADE, related_name='%(class)s_curr1') - curr_2 = models.ForeignKey(Curriculum, on_delete = models.CASCADE, related_name='%(class)s_curr2') - curr_3 = models.ForeignKey(Curriculum, on_delete = models.CASCADE, related_name='%(class)s_curr3') - curr_4 = models.ForeignKey(Curriculum, on_delete = models.CASCADE, related_name='%(class)s_curr4') - req_pending = models.BooleanField(default = True) - approved_course = models.ForeignKey(Curriculum, on_delete = models.CASCADE, related_name='%(class)s_approved_course', null = True) - course_completion = models.BooleanField(default=False) - supervisor_id = models.ForeignKey(Faculty, on_delete=models.CASCADE, related_name='%(class)s_supervisor_id',null = True) - class Meta: - db_table = 'TeachingCreditRegistration' - - class SemesterMarks(models.Model): ''' Current Purpose : stores information regarding the marks of a student in a course in a semester @@ -1161,10 +1145,12 @@ def __str__(self): return f"{self.member} on {self.thesis}" -class SeminarEntry(models.Model): +class ProgressSeminarEntry(models.Model): """PhD Seminar reports with versioning and RPC approval.""" thesis = models.ForeignKey(ThesisTopic, on_delete=models.CASCADE, related_name='seminars') version = models.PositiveSmallIntegerField() + semester = models.ForeignKey(Semester, on_delete=models.SET_NULL, null=True, blank=True, + help_text="Student's semester when this report was created.") created_at = models.DateTimeField(auto_now_add=True) STATUS_CHOICES = [ @@ -1185,6 +1171,15 @@ class SeminarEntry(models.Model): future_plan = models.TextField(blank=True) upload_doc = models.FileField(upload_to='seminar_docs/', null=True, blank=True) + # Publication counts — matches the official progress-seminar form's three + # publication questions directly, one field each (no category breakdown). + pub_published_or_accepted = models.PositiveIntegerField( + default=0, help_text="Papers published/accepted in journals or conference proceedings") + pub_presented_unpublished = models.PositiveIntegerField( + default=0, help_text="Papers presented in conferences/meetings/workshops (unpublished)") + pub_submitted_under_review = models.PositiveIntegerField( + default=0, help_text="Papers submitted (under review)") + # RPC Evaluation fields quality = models.CharField( max_length=20, @@ -1245,25 +1240,9 @@ 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): +class ProgressSeminarConsent(models.Model): """RPC member consent for seminar.""" - seminar = models.ForeignKey(SeminarEntry, related_name='consents', on_delete=models.CASCADE) + seminar = models.ForeignKey(ProgressSeminarEntry, 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) @@ -1272,9 +1251,9 @@ class Meta: unique_together = ('seminar','member') -class SeminarComment(models.Model): +class ProgressSeminarComment(models.Model): """RPC member comments on seminar.""" - seminar = models.ForeignKey(SeminarEntry, related_name='comments', on_delete=models.CASCADE) + seminar = models.ForeignKey(ProgressSeminarEntry, 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) @@ -1322,9 +1301,11 @@ class ThesisSubmission(models.Model): 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) + dean_panel_remarks = models.TextField(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) + director_remarks = models.TextField(blank=True) status = models.CharField(max_length=30, choices=STATUS_CHOICES, default='submitted', db_index=True) updated_at = models.DateTimeField(auto_now=True) @@ -1529,6 +1510,39 @@ def __str__(self): return f"{self.student} — {self.progress_seminar_slot.name} ({self.semester})" +class TeachingCreditRegistration(models.Model): + """Records a PhD student's semester-level teaching credit enrollment. + + Analogous to ThesisRegistration / ProgressSeminarRegistration; one record + per student per semester. This is only the enrollment gate -- the actual + course choice-and-allocation process (TeachingCreditAllocation) happens + separately, after this enrollment is verified. + """ + STATUS_CHOICES = [ + ('pending', 'Pending Verification'), + ('verified', 'Verified'), + ('rejected', 'Rejected'), + ] + + student = models.ForeignKey(Student, on_delete=models.CASCADE, + related_name='teaching_credit_registrations') + teaching_credit_slot = models.ForeignKey(TeachingCreditSlot, on_delete=models.CASCADE, + related_name='registrations') + semester = models.ForeignKey(Semester, on_delete=models.CASCADE) + 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) + remarks = models.CharField(max_length=500, blank=True) + + class Meta: + unique_together = ('student', 'semester') + db_table = 'TeachingCreditRegistration' + + def __str__(self): + return f"{self.student} — {self.teaching_credit_slot.name} ({self.semester})" + + # =========================================================================== # Thesis & Progress Seminar Grade Evaluation # =========================================================================== @@ -1632,4 +1646,471 @@ def __str__(self): return ( f"{self.registration.student} | Sem {self.registration.semester.semester_no} " f"| Grade: {g}" - ) \ No newline at end of file + ) + + +DEFAULT_PROGRESS_SEMINAR_CREDIT = 0 +# 0, not some plausible-looking number: today ProgressSeminarEntry (the RPC-approval +# grade record) has no required link to a ProgressSeminarRegistration (the credit +# enrollment-gate record) at all -- create_report() creates a graded entry from just +# a Dean-approved ThesisTopic, with no registration check. So this fallback fires on +# a genuine data gap (missing/unlinked registration), not a rare fluke, and it should +# under-count rather than guess -- guessing already produced a wrong "3" once. + + +def resolve_progress_seminar_catalog_entry(student, semester): + """Resolve the programme_curriculum.Seminar catalog row (code/name/credit) that + applies to a student's progress seminar in a given semester, via their + ProgressSeminarRegistration -> SeminarSlot -> seminars M2M, preferring the + student's own discipline when a slot serves more than one. Returns None if no + registration/catalog match exists (e.g. semester is None, or the student never + had a ProgressSeminarRegistration for that semester). + + This is the single source of truth for "how many credits is one progress + seminar worth" -- do not hardcode that number elsewhere; call this instead.""" + if not semester: + return None + reg = ProgressSeminarRegistration.objects.filter( + student=student, semester=semester + ).select_related('progress_seminar_slot').prefetch_related( + 'progress_seminar_slot__seminars' + ).first() + if not reg or not reg.progress_seminar_slot: + return None + discipline = getattr(getattr(student, 'batch_id', None), 'discipline', None) + manager = reg.progress_seminar_slot.seminars + entry = (manager.filter(discipline=discipline).first() if discipline else None) or manager.first() + return entry + + +def resolve_progress_seminar_credit(student, semester): + """Credit value for one progress seminar, sourced from the catalog via + resolve_progress_seminar_catalog_entry(), falling back to + DEFAULT_PROGRESS_SEMINAR_CREDIT only when no catalog entry can be found.""" + entry = resolve_progress_seminar_catalog_entry(student, semester) + return entry.credit if entry and entry.credit else DEFAULT_PROGRESS_SEMINAR_CREDIT + + +# =========================================================================== +# PhD Course (Coursework) Registration +# =========================================================================== + +class PhDCourseRegistrationRequest(models.Model): + """A PhD student's self-submitted request to register for a curriculum + course in their current semester. Independent of CourseAddRequest + (which is the UG/PG backlog add-course flow) — PhD students don't go + through pre-registration/final-registration, so this is a standalone + request-and-verify workflow, verified separately by acadadmin. + """ + STATUS_CHOICES = [ + ("Pending", "Pending"), + ("Approved", "Approved"), + ("Rejected", "Rejected"), + ] + + student = models.ForeignKey(Student, on_delete=models.CASCADE, + related_name='phd_course_requests') + semester = models.ForeignKey(Semester, on_delete=models.CASCADE) + academic_year = models.CharField(max_length=9) + semester_type = models.CharField( + max_length=20, + choices=[ + ("Odd Semester", "Odd Semester"), + ("Even Semester", "Even Semester"), + ("Summer Semester", "Summer Semester"), + ], + ) + course_slot = models.ForeignKey(CourseSlot, on_delete=models.CASCADE) + course = models.ForeignKey(Courses, on_delete=models.CASCADE, + related_name='phd_course_requests') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="Pending") + remarks = models.CharField(max_length=500, blank=True) + requested_at = models.DateTimeField(default=timezone.now) + processed_at = models.DateTimeField(null=True, blank=True) + processed_by = models.ForeignKey(ExtraInfo, null=True, blank=True, + on_delete=models.SET_NULL, + related_name='phd_course_requests_processed') + + class Meta: + unique_together = ('student', 'semester', 'course_slot') + db_table = 'PhDCourseRegistrationRequest' + + def __str__(self): + return f"{self.student} — {self.course.code} [{self.status}]" + + +# =========================================================================== +# Comprehensive Examination +# =========================================================================== + +class ComprehensiveExam(models.Model): + """PhD Comprehensive Examination eligibility, committee & convener approval. + + One record per student. The written/oral exam itself — subjects, dates, + result — is tracked per-attempt in ComprehensiveExamAttempt, since a + student may retake the whole cycle up to MAX_ATTEMPTS times. + """ + ENTRY_QUALIFICATION_CHOICES = [ + ('masters', 'ME/M.Tech/M.Des/M.Phil (16 credits required)'), + ('bachelors', 'B.Tech/B.E./M.Sc./MA (40 credits required)'), + ] + STATUS_CHOICES = [ + ('academic_office_pending', 'Pending Academic Office Verification'), + ('academic_office_rejected', 'Rejected by Academic Office'), + ('convener_pending', 'Pending Convener Approval'), + ('convener_rejected', 'Rejected by Convener'), + ('in_progress', 'Committee Approved — In Progress'), + ('passed', 'Passed'), + ('failed_final', 'Failed — Attempts Exhausted'), + ] + + MAX_ATTEMPTS = 2 + + student = models.OneToOneField(Student, on_delete=models.CASCADE, related_name='comprehensive_exam') + supervisor = models.ForeignKey(Faculty, related_name='comprehensive_exams_supervised', on_delete=models.CASCADE) + co_supervisor = models.ForeignKey(Faculty, related_name='comprehensive_exams_cosupervised', on_delete=models.CASCADE, null=True, blank=True) + + possible_thesis_title = models.CharField(max_length=300, blank=True) + + entry_qualification = models.CharField(max_length=10, choices=ENTRY_QUALIFICATION_CHOICES) + credits_completed = models.PositiveIntegerField(default=0) + current_cpi = models.DecimalField(max_digits=4, decimal_places=2, null=True, blank=True) + research_methodology_completed = models.BooleanField(default=False) + + # Academic Office verification + credits_verified = models.BooleanField(default=False) + cpi_verified = models.BooleanField(default=False) + research_methodology_verified = models.BooleanField(default=False) + academic_office_remarks = models.TextField(blank=True) + academic_office_verified_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='comprehensive_exams_office_verified', + ) + academic_office_verified_at = models.DateTimeField(null=True, blank=True) + + # Convener approval — Dean Academic stands in for the DPGC/PGCS convener for now. + convener_remarks = models.TextField(blank=True) + convener_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='comprehensive_exams_convened', + ) + convener_at = models.DateTimeField(null=True, blank=True) + + status = models.CharField(max_length=30, choices=STATUS_CHOICES, default='academic_office_pending') + current_attempt_number = models.PositiveSmallIntegerField(default=1) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + @property + def required_credits(self): + return 16 if self.entry_qualification == 'masters' else 40 + + def __str__(self): + return f"Comprehensive Exam — {self.student.id.user.get_full_name()}" + + +class ComprehensiveExamCommitteeMember(models.Model): + """Examination committee for a comprehensive exam (≤5 members); persists across attempts.""" + exam = models.ForeignKey(ComprehensiveExam, related_name='committee', on_delete=models.CASCADE) + member = models.ForeignKey(Faculty, on_delete=models.CASCADE) + + class Meta: + unique_together = ('exam', 'member') + + def __str__(self): + return f"{self.member} on {self.exam}" + + +class ComprehensiveExamAttempt(models.Model): + """One attempt (max ComprehensiveExam.MAX_ATTEMPTS) of the exam: subjects, dates, result. + + HOD reviews the floated subjects acting as the discipline coordinator; the + supervisor confirms the student's opted subjects before the exam proceeds. + """ + STATUS_CHOICES = [ + ('subjects_floated', 'Subjects Floated, Pending HOD Approval'), + ('hod_rejected', 'Subjects Rejected by HOD'), + ('subjects_ready', 'Subjects Approved, Pending Student Selection'), + ('subjects_opted', 'Subjects Opted, Pending Supervisor Confirmation'), + ('confirmation_rejected', 'Supervisor Rejected Opted Subjects'), + ('result_pending', 'Confirmed — Awaiting Result'), + ('passed', 'Passed'), + ('failed', 'Failed'), + ] + RESULT_CHOICES = [('passed', 'Passed'), ('failed', 'Failed')] + + exam = models.ForeignKey(ComprehensiveExam, related_name='attempts', on_delete=models.CASCADE) + attempt_number = models.PositiveSmallIntegerField() + status = models.CharField(max_length=25, choices=STATUS_CHOICES, default='subjects_floated') + + written_exam_date = models.DateField(null=True, blank=True) + oral_exam_date = models.DateField(null=True, blank=True) + + hod_remarks = models.TextField(blank=True) + hod_reviewed_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='comprehensive_attempts_hod_reviewed', + ) + hod_reviewed_at = models.DateTimeField(null=True, blank=True) + + supervisor_confirmation_remarks = models.TextField(blank=True) + + # Result — mirrors the official "Comprehensive Examination Report" form. + result = models.CharField(max_length=10, choices=RESULT_CHOICES, null=True, blank=True) + fundamentals_comment = models.TextField(blank=True) + problem_identification_comment = models.TextField(blank=True) + plan_of_work_comment = models.TextField(blank=True) + suggestions_comment = models.TextField(blank=True) + additional_literature_comment = models.TextField(blank=True) + milestone_plan_upload = models.FileField(upload_to='comprehensive_exam_milestones/', null=True, blank=True) + + reported_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='comprehensive_attempts_reported', + ) + reported_at = models.DateTimeField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('exam', 'attempt_number') + ordering = ['exam', 'attempt_number'] + + def __str__(self): + return f"Attempt {self.attempt_number} — {self.exam}" + + +class FloatedSubject(models.Model): + """A subject floated for the written exam of a given attempt (≤6); student opts ≤2.""" + attempt = models.ForeignKey(ComprehensiveExamAttempt, related_name='subjects', on_delete=models.CASCADE) + subject_name = models.CharField(max_length=200) + selected_by_student = models.BooleanField(default=False) + + def __str__(self): + return f"{self.subject_name} ({'opted' if self.selected_by_student else 'floated'})" + + +# =========================================================================== +# Open Seminar +# =========================================================================== + +class OpenSeminar(models.Model): + """PhD Open Seminar — parent record, one per student. + + Unlike ComprehensiveExam, retries have no attempt cap and each retry + constitutes a brand-new committee (see OpenSeminarAttempt), since + eligibility/credits can change between attempts. + """ + STATUS_CHOICES = [ + ('in_progress', 'In Progress'), + ('satisfactory', 'Satisfactory'), + ] + + student = models.OneToOneField(Student, on_delete=models.CASCADE, related_name='open_seminar') + supervisor = models.ForeignKey(Faculty, related_name='open_seminars_supervised', on_delete=models.CASCADE) + co_supervisor = models.ForeignKey(Faculty, related_name='open_seminars_cosupervised', on_delete=models.CASCADE, null=True, blank=True) + + possible_thesis_title = models.CharField(max_length=300, blank=True) + + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='in_progress') + current_attempt_number = models.PositiveSmallIntegerField(default=1) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Open Seminar — {self.student.id.user.get_full_name()}" + + +class OpenSeminarAttempt(models.Model): + """One attempt (unlimited) of the Open Seminar: fresh committee + Dean + Nominee each time, credit breakdown snapshot, committee verdict, and the + Dean Nominee's separate confidential report. + """ + STATUS_CHOICES = [ + ('convener_pending', 'Pending Convener Approval'), + ('convener_rejected', 'Rejected by Convener'), + ('result_pending', 'Committee Approved — Awaiting Result'), + ('satisfactory', 'Satisfactory'), + ('not_satisfactory', 'Not Satisfactory'), + ] + RESULT_CHOICES = [('satisfactory', 'Satisfactory'), ('not_satisfactory', 'Not Satisfactory')] + + RATING_3WAY = [('Enough', 'Enough'), ('Just', 'Just Sufficient'), ('Insuff', 'Insufficient')] + QUALITY_CHOICES = [('Excellent', 'Excellent'), ('Good', 'Good'), ('Sat', 'Satisfactory'), ('Unsat', 'Unsatisfactory')] + + open_seminar = models.ForeignKey(OpenSeminar, related_name='attempts', on_delete=models.CASCADE) + attempt_number = models.PositiveSmallIntegerField() + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='convener_pending') + + proposed_date = models.DateField(null=True, blank=True) + + # Credit breakdown -- course_work/progress_seminar/thesis_research are + # computed server-side from the student's own records; teaching_credits + # has no numeric source anywhere in Fusion and stays manual. + course_work_credits = models.PositiveIntegerField(default=0) + progress_seminar_credits = models.PositiveIntegerField(default=0) + thesis_research_credits = models.PositiveIntegerField(default=0) + teaching_credits = models.PositiveIntegerField(default=0) + semesters_completed = models.PositiveIntegerField(default=0) + + # Eligibility snapshot -- rpc_recommended is read from the student's latest + # rpc_approved ProgressSeminarEntry.rec_open; first_draft_sent has no data source. + rpc_recommended_open_seminar = models.BooleanField(default=False) + first_draft_sent_to_dean = models.BooleanField(default=False) + + # Convener (Dean Academic stands in for DPGC/PGCS for now) approval + convener_remarks = models.TextField(blank=True) + convener_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='open_seminar_attempts_convened', + ) + convener_at = models.DateTimeField(null=True, blank=True) + + # Dean Nominee -- ad-hoc faculty appointment, made by the Convener at + # approval time; submits their own confidential report independently. + dean_nominee = models.ForeignKey( + Faculty, null=True, blank=True, + on_delete=models.SET_NULL, related_name='open_seminar_nominations', + ) + + # Committee's joint verdict -- the authoritative pass/fail result. + result = models.CharField(max_length=20, choices=RESULT_CHOICES, null=True, blank=True) + committee_comments = models.TextField(blank=True) + reported_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='open_seminar_attempts_reported', + ) + reported_at = models.DateTimeField(null=True, blank=True) + + # Dean Nominee's own confidential report -- mirrors "Report of Dean + # Nominee" form. Kept out of any dict/serializer shown to + # student/supervisor/committee; only Convener/Dean Academic and the + # nominee themselves should ever see these fields. + dn_quality = models.CharField(max_length=20, choices=QUALITY_CHOICES, blank=True) + dn_quantity = models.CharField(max_length=10, choices=RATING_3WAY, blank=True) + dn_publications = models.CharField(max_length=10, choices=RATING_3WAY, blank=True) + dn_overall = models.CharField(max_length=20, choices=RESULT_CHOICES, blank=True) + dn_comments = models.TextField(blank=True) + dn_submitted_at = models.DateTimeField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('open_seminar', 'attempt_number') + ordering = ['open_seminar', 'attempt_number'] + + @property + def total_credits(self): + return ( + self.course_work_credits + self.progress_seminar_credits + + self.thesis_research_credits + self.teaching_credits + ) + + def __str__(self): + return f"Open Seminar Attempt {self.attempt_number} — {self.open_seminar}" + + +class OpenSeminarCommitteeMember(models.Model): + """Open Seminar committee member (≤5); fresh per attempt.""" + attempt = models.ForeignKey(OpenSeminarAttempt, related_name='committee', on_delete=models.CASCADE) + member = models.ForeignKey(Faculty, on_delete=models.CASCADE) + + class Meta: + unique_together = ('attempt', 'member') + + def __str__(self): + return f"{self.member} on {self.attempt}" + + +# =========================================================================== +# Teaching Credit +# =========================================================================== +# Workflow: [Precondition: ComprehensiveExam.status == 'passed'] -> Student +# submits 4 course choices for a semester -> HOD allocates one of the 4 (or +# sends it back with remarks, student edits and resubmits) -> [offline +# teaching] -> any student registered for the allocated course that semester +# submits one anonymous evaluation -> HOD reviews the aggregated (anonymized) +# evaluations and marks the registration completed with a satisfactory/ +# not_satisfactory result (satisfactory awards the credit; not_satisfactory +# is terminal -- no retry, a fresh attempt would just be a new semester's +# registration). + +class TeachingCreditAllocation(models.Model): + """PhD student's teaching-credit registration for a semester.""" + STATUS_CHOICES = [ + ('pending', 'Pending HOD Decision'), + ('sent_back', 'Sent Back by HOD'), + ('allocated', 'Course Allocated'), + ('completed', 'Completed'), + ] + RESULT_CHOICES = [('satisfactory', 'Satisfactory'), ('not_satisfactory', 'Not Satisfactory')] + + student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name='teaching_credit_allocations') + semester = models.ForeignKey(Semester, on_delete=models.CASCADE) + + choice_1 = models.ForeignKey(Courses, on_delete=models.CASCADE, related_name='teaching_credit_choice1') + choice_2 = models.ForeignKey(Courses, on_delete=models.CASCADE, related_name='teaching_credit_choice2', null=True, blank=True) + choice_3 = models.ForeignKey(Courses, on_delete=models.CASCADE, related_name='teaching_credit_choice3', null=True, blank=True) + choice_4 = models.ForeignKey(Courses, on_delete=models.CASCADE, related_name='teaching_credit_choice4', null=True, blank=True) + + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + allocated_course = models.ForeignKey( + Courses, on_delete=models.SET_NULL, null=True, blank=True, + related_name='teaching_credit_allocations', + ) + hod_remarks = models.TextField(blank=True) + decided_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='teaching_credit_decisions', + ) + decided_at = models.DateTimeField(null=True, blank=True) + + result = models.CharField(max_length=20, choices=RESULT_CHOICES, null=True, blank=True) + completed_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='teaching_credit_completions', + ) + completed_at = models.DateTimeField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('student', 'semester') + + def __str__(self): + return f"Teaching Credit — {self.student.id.user.get_full_name()} ({self.semester})" + + +class TeachingCreditEvaluationResponse(models.Model): + """One class-student's anonymous evaluation of the Research Scholar's teaching. + + respondent is stored only to enforce one submission per student; it is + never exposed via the API -- only aggregated stats and anonymized + comments are shown to HOD/anyone else. + """ + BAND_CHOICES = [ + ('<80', '<80%'), ('80-90', '80%-90%'), ('90-95', '90%-95%'), ('95-100', '95%-100%'), + ] + QUALITY_CHOICES = [ + ('Poor', 'Poor'), ('Average', 'Average'), ('Good', 'Good'), ('Excellent', 'Excellent'), + ] + + registration = models.ForeignKey(TeachingCreditAllocation, related_name='evaluations', on_delete=models.CASCADE) + respondent = models.ForeignKey(Student, on_delete=models.CASCADE, related_name='teaching_credit_evaluations_given') + + punctuality_band = models.CharField(max_length=10, choices=BAND_CHOICES, blank=True) + schedule_adherence_band = models.CharField(max_length=10, choices=BAND_CHOICES, blank=True) + topics_sequence = models.CharField(max_length=10, choices=QUALITY_CHOICES, blank=True) + teaching_aids = models.CharField(max_length=10, choices=QUALITY_CHOICES, blank=True) + questions_answered = models.CharField(max_length=10, choices=QUALITY_CHOICES, blank=True) + overall_effectiveness = models.CharField(max_length=10, choices=QUALITY_CHOICES, blank=True) + strengths_weaknesses = models.TextField(blank=True) + + submitted_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('registration', 'respondent') + + def __str__(self): + return f"Evaluation for {self.registration} by respondent #{self.respondent_id}" \ No newline at end of file diff --git a/FusionIIIT/applications/academic_procedures/urls.py b/FusionIIIT/applications/academic_procedures/urls.py index 1a0887e6b..c2b82a660 100644 --- a/FusionIIIT/applications/academic_procedures/urls.py +++ b/FusionIIIT/applications/academic_procedures/urls.py @@ -34,7 +34,6 @@ url(r'^addThesis/$', views.add_thesis, name='add_thesis'), url(r'^process_verification_request/$', views.process_verification_request), url(r'^auto_process_verification_request/$', views.auto_process_verification_request), - url(r'^teaching_credit/$', views.teaching_credit_register), url(r'^course_marks_data/$', views.course_marks_data), # -- url(r'^submit_marks/$', views.submit_marks), # -- url(r'^verify_course_marks_data/$', views.verify_course_marks_data), # -- diff --git a/FusionIIIT/applications/academic_procedures/views.py b/FusionIIIT/applications/academic_procedures/views.py index 9d763beff..eecbe8b67 100644 --- a/FusionIIIT/applications/academic_procedures/views.py +++ b/FusionIIIT/applications/academic_procedures/views.py @@ -38,7 +38,7 @@ from applications.programme_curriculum.models import Course as Courses from .models import (BranchChange, CoursesMtech, InitialRegistration, StudentRegistrationChecks, Register, Thesis, FinalRegistration, ThesisTopicProcess, - Constants, FeePayments, TeachingCreditRegistration, SemesterMarks, + Constants, FeePayments, SemesterMarks, MarkSubmissionCheck, Dues,AssistantshipClaim, MTechGraduateSeminarReport, PhDProgressExamination,CourseRequested, course_registration, course_replacement, MessDue, Assistantship_status , backlog_course) from notification.views import academics_module_notif @@ -2956,42 +2956,6 @@ def get_registration_courses(courses): return x -@require_designation("acadadmin", "Dean Academic") -def teaching_credit_register(request) : - if request.method == 'POST': - try: - roll = request.POST.get('roll') - course1 = request.POST.get('course1') - - - roll = str(roll) - - student_id = get_object_or_404(User, username=request.POST.get('roll')) - student_id = ExtraInfo.objects.all().select_related('user','department').filter(user=student_id).first() - student_id = Student.objects.all().select_related('id','id__user','id__department').filter(id=student_id.id).first() - - course1 = Curriculum.objects.select_related().get(curriculum_id = request.POST.get('course1')) - course2 = Curriculum.objects.select_related().get(curriculum_id = request.POST.get('course2')) - course3 = Curriculum.objects.select_related().get(curriculum_id = request.POST.get('course3')) - course4 = Curriculum.objects.select_related().get(curriculum_id = request.POST.get('course4')) - - p = TeachingCreditRegistration( - student_id = student_id, - curr_1 = course1, - curr_2 = course2, - curr_3 = course3, - curr_4 = course4 - ) - p.save() - - messages.info(request, ' Successful') - return HttpResponseRedirect('/academic-procedures/main') - except Exception as e: - return HttpResponseRedirect('/academic-procedures/main') - else: - return HttpResponseRedirect('/academic-procedures/main') - - @require_designation("Professor", "Associate Professor", "Assistant Professor") diff --git a/FusionIIIT/applications/examination/api/views.py b/FusionIIIT/applications/examination/api/views.py index 4e6f0a5ed..1c687a7c2 100644 --- a/FusionIIIT/applications/examination/api/views.py +++ b/FusionIIIT/applications/examination/api/views.py @@ -3,7 +3,11 @@ from django.shortcuts import get_object_or_404 from django.db import transaction from decimal import Decimal, ROUND_HALF_UP -from applications.academic_procedures.models import(course_registration, course_replacement) +from applications.academic_procedures.models import( + course_registration, course_replacement, + ThesisRegistration, ProgressSeminarRegistration, ProgressSeminarEntry, TeachingCreditAllocation, + resolve_progress_seminar_catalog_entry, DEFAULT_PROGRESS_SEMINAR_CREDIT, +) from applications.programme_curriculum.models import Course as Courses , Batch, CourseInstructor from applications.examination.models import(hidden_grades , ResultAnnouncement, authentication, PublishedResultStudent) from applications.globals.access import user_holds_role, user_holds_any_role @@ -67,6 +71,24 @@ def _safe_filename(name: str, extension: str = "") -> str: f"{x:.1f}" for x in [i / 10 for i in range(20, 101)] } +PROGRAMME_TYPE_BUCKETS = { + 'UG': ['B.Tech', 'B.Des'], + 'PG': ['M.Tech', 'M.Des'], + # Student.programme is declared as 'PhD' in academic_information.Constants.PROGRAMME, + # but the PhD student-promotion flow (programme_curriculum) actually writes 'Ph.D' — + # accept both so this doesn't silently return zero PhD students. + 'PHD': ['PhD', 'Ph.D'], +} + + +def resolve_programme_list(programme_type): + """('UG'/'PG'/'PHD', case-insensitive) -> (programme_list, error_message_or_None).""" + programme_list = PROGRAMME_TYPE_BUCKETS.get(programme_type.upper()) + if programme_list is None: + return None, "Invalid programme_type. Must be 'UG', 'PG', or 'PHD'." + return programme_list, None + + # Helper function to format semester display for PDFs def format_semester_display(semester_no, semester_type=None, semester_label=None): if semester_label and 'summer' in semester_label.lower(): @@ -90,7 +112,162 @@ def round_from_last_decimal(number, decimal_places=1): d = Decimal(str(number)) return Decimal(d).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP) -def calculate_spi_for_student(student, selected_semester, semester_type): +def _phd_catalog_entry(slot, relation_name, student): + """Pick the Thesis/Seminar catalog row (programme_curriculum.Thesis/Seminar, reached + via the slot's M2M) for a slot, preferring the one matching the student's own + discipline in case a slot serves more than one.""" + if not slot: + return None + manager = getattr(slot, relation_name) + discipline = getattr(getattr(student, 'batch_id', None), 'discipline', None) + entry = manager.filter(discipline=discipline).first() if discipline else None + return entry or manager.first() + + +def _phd_extra_records(student, semester_no, require_announced=False): + """PhD-only records for a student's Thesis / Progress Seminar / Teaching Credit + activity in one semester -- one record per registration/allocation (multiple + Thesis evaluation blocks collapse into a single record with a concatenated grade, + e.g. 'SXS', matching how the institute's own Thesis catalog entry names the whole + course). Each record carries both a single display row and the individual + per-block (credit, grade) items needed for correct SPI/CPI credit-earned math. + + Returns [] for non-PhD students, or once nothing has been graded/submitted yet + (Progress Seminar has no grading pipeline today, so it will always be empty for + now). require_announced=True additionally requires Thesis/Progress Seminar grades + to have been officially announced (not just submitted) -- use this for + student-facing views, matching the existing convention in + student_thesis_enrollment_api. Teaching Credit has no separate announce step, so + its result is shown as soon as it's final either way.""" + if student.programme not in PROGRAMME_TYPE_BUCKETS['PHD']: + return [] + + records = [] + + for reg in ThesisRegistration.objects.filter( + student=student, semester__semester_no=semester_no + ).select_related('thesis_slot').prefetch_related('evaluations', 'thesis_slot__theses'): + blocks = [ + ev for ev in reg.evaluations.all().order_by('block_number') + if ev.grade and (not require_announced or ev.announced) + ] + if not blocks: + continue + catalog = _phd_catalog_entry(reg.thesis_slot, 'theses', student) + # Display credit = what was actually earned (e.g. 2 Satisfactory blocks out of + # 3 -> 6), not reg.credits (the nominal registered total, e.g. 9) -- otherwise + # a row showing grade "SXS" would misleadingly claim full credit for the failed + # block too. Reuses _apply_grade_factor so this can never drift from the real + # SPI/CPI accounting below. + block_totals = {'points': Decimal('0'), 'credits': Decimal('0'), 'earned': Decimal('0')} + for ev in blocks: + _apply_grade_factor(ev.grade, Decimal('3'), block_totals) + records.append({ + "key": f"thesis_{reg.id}", + "code": catalog.code if catalog else f"THESIS-{reg.id}", + "name": catalog.name if catalog else "PhD Thesis Research", + "display_credit": int(block_totals['earned']), + "display_grade": ''.join(ev.grade for ev in blocks), + "grade_items": [(Decimal('3'), ev.grade) for ev in blocks], + }) + + # The real, working Progress Seminar grade lives on ProgressSeminarEntry -- the + # RPC committee's approved report (status='rpc_approved' + overall_grade), reached + # via the student's ThesisTopic. ProgressSeminarRegistration's own placeholder + # ProgressSeminarEvaluation is unused by any submission UI today; it's kept below + # as a defensive fallback in case that pipeline is ever wired up. + seminar_entry = ProgressSeminarEntry.objects.filter( + thesis__student=student, semester__semester_no=semester_no, status='rpc_approved', + ).exclude(overall_grade='').order_by('-version').first() + + if seminar_entry: + # Credit is sourced from the Seminar catalog table via the shared resolver -- + # not hardcoded -- so it stays correct if the catalog value ever changes. + catalog = resolve_progress_seminar_catalog_entry(student, seminar_entry.semester) + credit = catalog.credit if catalog and catalog.credit else DEFAULT_PROGRESS_SEMINAR_CREDIT + records.append({ + "key": f"seminar_entry_{seminar_entry.id}", + "code": catalog.code if catalog else "SEMINAR", + "name": catalog.name if catalog else "PhD Progress Seminar", + "display_credit": credit, + "display_grade": seminar_entry.overall_grade, + "grade_items": [(Decimal(str(credit)), seminar_entry.overall_grade)], + }) + seminar_regs = [] + else: + seminar_regs = ProgressSeminarRegistration.objects.filter( + student=student, semester__semester_no=semester_no + ).select_related('progress_seminar_slot', 'evaluation').prefetch_related('progress_seminar_slot__seminars') + + for reg in seminar_regs: + evaluation = getattr(reg, 'evaluation', None) + if not (evaluation and evaluation.grade and (not require_announced or evaluation.announced)): + continue + catalog = _phd_catalog_entry(reg.progress_seminar_slot, 'seminars', student) + credit = catalog.credit if catalog and catalog.credit else DEFAULT_PROGRESS_SEMINAR_CREDIT + records.append({ + "key": f"seminar_{reg.id}", + "code": catalog.code if catalog else "SEMINAR", + "name": catalog.name if catalog else "PhD Progress Seminar", + "display_credit": credit, + "display_grade": evaluation.grade, + "grade_items": [(Decimal(str(credit)), evaluation.grade)], + }) + + for alloc in TeachingCreditAllocation.objects.filter( + student=student, semester__semester_no=semester_no + ).select_related('allocated_course'): + if not (alloc.allocated_course and alloc.result): + continue + grade = 'S' if alloc.result == 'satisfactory' else 'X' + credit = alloc.allocated_course.credit + records.append({ + "key": f"teaching_credit_{alloc.id}", + "code": alloc.allocated_course.code, + "name": f"{alloc.allocated_course.name} (Teaching Credit)", + "display_credit": credit, + "display_grade": grade, + "grade_items": [(Decimal(str(credit)), grade)], + }) + + return records + + +def _phd_extra_display_rows(student, semester_no, require_announced=False): + """PhD-only display rows (course_name/course_code/credit/grade/points), shaped + exactly like a Student_grades-derived course_grades entry -- one row per + Thesis/Progress Seminar/Teaching Credit registration.""" + return [ + (r["key"], { + "course_name": r["name"], "course_code": r["code"], + "credit": r["display_credit"], "grade": r["display_grade"], + "points": Decimal('0.0'), + }) + for r in _phd_extra_records(student, semester_no, require_announced) + ] + + +def _phd_extra_grade_items(student, semester_no, require_announced=False): + """(credit, grade) tuples for feeding the SPI/CPI accumulation loop -- one tuple + per individually-graded block, so credit-earned math stays correct even though + display rows collapse multiple Thesis blocks into a single row.""" + items = [] + for r in _phd_extra_records(student, semester_no, require_announced): + items.extend(r["grade_items"]) + return items + + +def _apply_grade_factor(grade, credit, totals): + """totals: {'points': Decimal, 'credits': Decimal, 'earned': Decimal}, mutated in place.""" + factor = grade_conversion.get((grade or "").strip(), -1) + if factor >= 0: + if factor != 0: + totals['points'] += Decimal(str(factor)) * credit + totals['credits'] += credit + totals['earned'] += credit + + +def calculate_spi_for_student(student, selected_semester, semester_type, require_announced=False): semester_unit = Decimal('0') grades = ( Student_grades.objects @@ -111,17 +288,13 @@ def calculate_spi_for_student(student, selected_semester, semester_type): ) .order_by('semester', 'semester_type_order') ) - total_points = Decimal('0') - total_credits = Decimal('0') + totals = {'points': Decimal('0'), 'credits': Decimal('0'), 'earned': Decimal('0')} for g in grades: credit = Decimal(str(g.course_id.credit)) - factor = grade_conversion.get(g.grade.strip(), -1) - if factor >= 0: - if factor != 0: - factor = Decimal(str(factor)) - total_points += factor * credit - total_credits += credit - semester_unit += credit + _apply_grade_factor(g.grade, credit, totals) + for credit, grade in _phd_extra_grade_items(student, selected_semester, require_announced): + _apply_grade_factor(grade, credit, totals) + total_points, total_credits, semester_unit = totals['points'], totals['credits'], totals['earned'] return round_from_last_decimal(Decimal('10') * (total_points / total_credits)) if total_credits else 0, semester_unit, (total_points*10) def trace_registration(reg_id, mapping): @@ -131,7 +304,7 @@ def trace_registration(reg_id, mapping): reg_id = mapping[reg_id] return reg_id -def calculate_cpi_for_student(student, selected_semester, semester_type): +def calculate_cpi_for_student(student, selected_semester, semester_type, require_announced=False): total_unit = Decimal('0') if selected_semester % 2 == 0 and semester_type == 'Summer Semester': grades = ( @@ -198,18 +371,18 @@ def calculate_cpi_for_student(student, selected_semester, semester_type): continue original_reg_id = trace_registration(reg_id, reg_replacement_map) grade_groups[original_reg_id].append(g) - total_points = Decimal('0') - total_credits = Decimal('0') + totals = {'points': Decimal('0'), 'credits': Decimal('0'), 'earned': Decimal('0')} for orig_reg, g_list in grade_groups.items(): best_record = max(g_list, key=lambda r: grade_conversion.get(r.grade.strip(), -1)) - grade_factor = grade_conversion.get(best_record.grade.strip(), -1) credit = Decimal(str(getattr(best_record.course_id, 'credit', 3))) - if grade_factor >= 0: - if grade_factor != 0: - grade_factor = Decimal(str(grade_factor)) - total_points += grade_factor * credit - total_credits += credit - total_unit += credit + _apply_grade_factor(best_record.grade, credit, totals) + # PhD Thesis/Progress Seminar/Teaching Credit grades: once per semester number in the + # cumulative range, regardless of which branch above was taken (these registration + # tables have no Odd/Even/Summer axis of their own). + for sem_no in range(1, selected_semester + 1): + for credit, grade in _phd_extra_grade_items(student, sem_no, require_announced): + _apply_grade_factor(grade, credit, totals) + total_points, total_credits, total_unit = totals['points'], totals['credits'], totals['earned'] return round_from_last_decimal(Decimal('10') * (total_points / total_credits)) if total_credits else 0, total_unit, (total_points*10) def parse_academic_year(academic_year, semester_type): @@ -238,6 +411,19 @@ def parse_academic_year(academic_year, semester_type): session = academic_year # Use the complete academic year string as session. return working_year, session +def course_instructor_year(academic_year, semester_type): + """ + Year used by CourseInstructor rows for a given academic year + semester type. + CourseInstructor stores Odd/Summer under the start year and Even under the end + year (see CourseInstructor.academic_year). This differs from parse_academic_year, + which keys Summer on the end year to match Student_grades — so use this helper + (not working_year) whenever filtering CourseInstructor by year. + """ + if semester_type == "Summer Semester": + return int(academic_year.split("-")[0].strip()) + working_year, _ = parse_academic_year(academic_year, semester_type) + return working_year + def is_valid_grade(grade: str, course_code: str) -> bool: """ Returns True if the grade is valid for the given course code. @@ -322,15 +508,11 @@ class UniqueRegistrationYearsView(APIView): def get(self, request): programme_type = request.GET.get('programme_type', None) years_query = course_registration.objects.exclude(session__isnull=True) - + if programme_type: - if programme_type.upper() == 'UG': - programme_list = ['B.Tech', 'B.Des'] - elif programme_type.upper() == 'PG': - programme_list = ['M.Tech', 'M.Des', 'PhD'] - else: - programme_list = [] - + programme_list, _ = resolve_programme_list(programme_type) + programme_list = programme_list or [] + if programme_list: from applications.academic_information.models import Student student_ids_with_programme = Student.objects.filter( @@ -405,30 +587,24 @@ def download_template(request): # Apply programme type filter if specified if programme_type: - if programme_type.upper() == 'UG': - programme_list = ['B.Tech', 'B.Des'] - elif programme_type.upper() == 'PG': - programme_list = ['M.Tech', 'M.Des', 'PhD'] - else: - return Response( - {"error": "Invalid programme_type. Must be 'UG' or 'PG'."}, - status=status.HTTP_400_BAD_REQUEST - ) - + programme_list, prog_err = resolve_programme_list(programme_type) + if prog_err: + return Response({"error": prog_err}, status=status.HTTP_400_BAD_REQUEST) + from applications.academic_information.models import Student student_ids_with_programme = Student.objects.filter( programme__in=programme_list ).values_list('id', flat=True) - + course_info_query = course_info_query.filter( student_id__in=student_ids_with_programme ) - + course_info = course_info_query.order_by("student_id_id") if not course_info.exists(): if programme_type: - programme_name = "Undergraduate" if programme_type.upper() == 'UG' else "Postgraduate" + programme_name = {"UG": "Undergraduate", "PG": "Postgraduate", "PHD": "PhD"}.get(programme_type.upper(), programme_type) return Response( {"error": f"No {programme_name} students found in this course for the selected academic year and semester."}, status=status.HTTP_404_NOT_FOUND @@ -525,25 +701,19 @@ def check_course_students(request): ) if programme_type: - if programme_type.upper() == 'UG': - programme_list = ['B.Tech', 'B.Des'] - elif programme_type.upper() == 'PG': - programme_list = ['M.Tech', 'M.Des', 'PhD'] - else: - return Response( - {"error": "Invalid programme_type. Must be 'UG' or 'PG'."}, - status=status.HTTP_400_BAD_REQUEST - ) - + programme_list, prog_err = resolve_programme_list(programme_type) + if prog_err: + return Response({"error": prog_err}, status=status.HTTP_400_BAD_REQUEST) + from applications.academic_information.models import Student student_ids_with_programme = Student.objects.filter( programme__in=programme_list ).values_list('id', flat=True) - + course_info_query = course_info_query.filter( student_id__in=student_ids_with_programme ) - + has_students = course_info_query.exists() student_count = course_info_query.count() if has_students else 0 @@ -1131,6 +1301,7 @@ def post(self, request): "grade": reg.grade, "points": Decimal(str(grade_conversion.get((reg.grade or "").strip(), 0) * 10)).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP), } + course_grades.update(dict(_phd_extra_display_rows(student, semester_number))) # Add complete student information like CheckResultView student_info = { @@ -1726,7 +1897,7 @@ def post(self, request): semester_type = request.data.get("semester_type") programme_type = request.data.get("programme_type") - if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): + if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor", "acadadmin"]): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -1738,31 +1909,34 @@ def post(self, request): status=400, ) - instructor_id = request.user.username - working_year, _ = parse_academic_year(academic_year=academic_year, semester_type=semester_type) - unique_course_ids = ( - CourseInstructor.objects.filter(instructor_id_id=instructor_id, year = working_year, semester_type=semester_type) - .values("course_id_id") - .distinct() - .annotate(course_id_int=Cast("course_id_id", IntegerField())) - ) - - # Retrieve course details with programme type filtering - courses_query = Courses.objects.filter( - id__in=unique_course_ids.values_list("course_id_int", flat=True) - ) + acting_as_acadadmin = role == "acadadmin" and user_holds_role(request.user, "acadadmin") + if acting_as_acadadmin: + course_ids = ( + course_registration.objects + .filter(session=academic_year, semester_type=semester_type) + .values_list("course_id", flat=True) + .distinct() + ) + courses_query = Courses.objects.filter(id__in=course_ids) + else: + unique_course_ids = ( + CourseInstructor.objects + .filter(instructor_id_id=request.user.username, year=course_instructor_year(academic_year, semester_type), semester_type=semester_type) + .values("course_id_id") + .distinct() + .annotate(course_id_int=Cast("course_id_id", IntegerField())) + ) + courses_query = Courses.objects.filter( + id__in=unique_course_ids.values_list("course_id_int", flat=True) + ) student_ids_with_programme = None if programme_type: - if programme_type.upper() == 'UG': - programme_list = ['B.Tech', 'B.Des'] - elif programme_type.upper() == 'PG': - programme_list = ['M.Tech', 'M.Des', 'PhD'] - else: - programme_list = [] - + programme_list, _ = resolve_programme_list(programme_type) + programme_list = programme_list or [] + if programme_list: from applications.academic_information.models import Student student_ids_with_programme = Student.objects.filter( @@ -1823,7 +1997,7 @@ def post(self, request): try: # 1) ROLE CHECK role = request.data.get("Role") - if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): + if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor", "acadadmin"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) @@ -1881,56 +2055,46 @@ def post(self, request): ) from applications.academic_information.models import Student - ug_programmes = ['B.Tech', 'B.Des'] - pg_programmes = ['M.Tech', 'M.Des', 'PhD'] - - ug_student_ids = Student.objects.filter(programme__in=ug_programmes).values_list('id', flat=True) - pg_student_ids = Student.objects.filter(programme__in=pg_programmes).values_list('id', flat=True) - - course_has_ug = regs.filter(student_id__in=ug_student_ids).exists() - course_has_pg = regs.filter(student_id__in=pg_student_ids).exists() + bucket_student_ids = { + bucket: Student.objects.filter(programme__in=programmes).values_list('id', flat=True) + for bucket, programmes in PROGRAMME_TYPE_BUCKETS.items() + } + present_buckets = [ + bucket for bucket, ids in bucket_student_ids.items() + if regs.filter(student_id__in=ids).exists() + ] if programme_type: - if programme_type.upper() == 'UG': - if not course_has_ug: - return Response( - {"error": "No UG students registered in this course."}, - status=status.HTTP_400_BAD_REQUEST - ) - regs = regs.filter(student_id__in=ug_student_ids) - elif programme_type.upper() == 'PG': - if not course_has_pg: - return Response( - {"error": "No PG students registered in this course."}, - status=status.HTTP_400_BAD_REQUEST - ) - regs = regs.filter(student_id__in=pg_student_ids) - else: + bucket = programme_type.upper() + if bucket not in PROGRAMME_TYPE_BUCKETS: return Response( - {"error": "Invalid programme_type. Must be 'UG' or 'PG'."}, + {"error": "Invalid programme_type. Must be 'UG', 'PG', or 'PHD'."}, status=status.HTTP_400_BAD_REQUEST ) + if bucket not in present_buckets: + return Response( + {"error": f"No {bucket} students registered in this course."}, + status=status.HTTP_400_BAD_REQUEST + ) + programme_type = bucket + regs = regs.filter(student_id__in=bucket_student_ids[bucket]) else: - if course_has_ug and course_has_pg: + if len(present_buckets) > 1: return Response( { - "error": "This course has both UG and PG students. Please specify programme_type as 'UG' or 'PG'.", + "error": f"This course has students from multiple programme types ({', '.join(present_buckets)}). Please specify programme_type.", "course_info": { "course_code": course.code, "course_name": course.name, - "has_ug": course_has_ug, - "has_pg": course_has_pg, + "programme_types_present": present_buckets, "total_registrations": regs.count() } }, status=status.HTTP_400_BAD_REQUEST ) - elif course_has_ug and not course_has_pg: - programme_type = 'UG' - regs = regs.filter(student_id__in=ug_student_ids) - elif course_has_pg and not course_has_ug: - programme_type = 'PG' - regs = regs.filter(student_id__in=pg_student_ids) + elif len(present_buckets) == 1: + programme_type = present_buckets[0] + regs = regs.filter(student_id__in=bucket_student_ids[programme_type]) existing_query = Student_grades.objects.filter( course_id=course_id, @@ -1939,29 +2103,16 @@ def post(self, request): ) if programme_type: - if programme_type.upper() == 'UG': - ug_student_rolls = [reg.student_id_id for reg in regs] - existing_ug_grades = existing_query.filter(roll_no__in=ug_student_rolls) - - if existing_ug_grades.exists(): - non_resubmit_ug = existing_ug_grades.filter(reSubmit=False) - if non_resubmit_ug.exists(): - return Response( - {"error": "THIS COURSE HAS ALREADY BEEN SUBMITTED FOR UG STUDENTS."}, - status=status.HTTP_400_BAD_REQUEST - ) - - elif programme_type.upper() == 'PG': - pg_student_rolls = [reg.student_id_id for reg in regs] - existing_pg_grades = existing_query.filter(roll_no__in=pg_student_rolls) - - if existing_pg_grades.exists(): - non_resubmit_pg = existing_pg_grades.filter(reSubmit=False) - if non_resubmit_pg.exists(): - return Response( - {"error": "THIS COURSE HAS ALREADY BEEN SUBMITTED FOR PG STUDENTS."}, - status=status.HTTP_400_BAD_REQUEST - ) + bucket_student_rolls = [reg.student_id_id for reg in regs] + existing_bucket_grades = existing_query.filter(roll_no__in=bucket_student_rolls) + + if existing_bucket_grades.exists(): + non_resubmit_bucket = existing_bucket_grades.filter(reSubmit=False) + if non_resubmit_bucket.exists(): + return Response( + {"error": f"THIS COURSE HAS ALREADY BEEN SUBMITTED FOR {programme_type} STUDENTS."}, + status=status.HTTP_400_BAD_REQUEST + ) else: existing = existing_query.first() if existing and not existing.reSubmit: @@ -1970,11 +2121,12 @@ def post(self, request): status=status.HTTP_400_BAD_REQUEST ) - # 8) INSTRUCTOR‐OWNERSHIP CHECK - if not CourseInstructor.objects.filter( + # 8) INSTRUCTOR‐OWNERSHIP CHECK (acadadmin may submit for any course) + acting_as_acadadmin = role == "acadadmin" and user_holds_role(request.user, "acadadmin") + if not acting_as_acadadmin and not CourseInstructor.objects.filter( course_id_id=course_id, instructor_id_id=request.user.username, - year=working_year + year=course_instructor_year(academic_year, semester_type) ).exists(): return Response( {"error": "Access denied: you are not assigned as instructor for this course."}, @@ -2002,13 +2154,9 @@ def post(self, request): ) if programme_type: - if programme_type.upper() == 'UG': - ug_rolls = [reg.student_id_id for reg in regs] - reset_query = reset_query.filter(roll_no__in=ug_rolls) - elif programme_type.upper() == 'PG': - pg_rolls = [reg.student_id_id for reg in regs] - reset_query = reset_query.filter(roll_no__in=pg_rolls) - + bucket_rolls = [reg.student_id_id for reg in regs] + reset_query = reset_query.filter(roll_no__in=bucket_rolls) + reset_query.update(reSubmit=False) # ─── Process each CSV row ─── @@ -2041,14 +2189,9 @@ def post(self, request): # Check if student belongs to the specified programme type if programme_type: student_programme = stud.programme - if programme_type.upper() == 'UG' and student_programme not in ug_programmes: - errors.append( - f"Row {idx}: Student {roll_no} is not a UG student (programme: {student_programme})." - ) - continue - elif programme_type.upper() == 'PG' and student_programme not in pg_programmes: + if student_programme not in PROGRAMME_TYPE_BUCKETS[programme_type]: errors.append( - f"Row {idx}: Student {roll_no} is not a PG student (programme: {student_programme})." + f"Row {idx}: Student {roll_no} is not a {programme_type} student (programme: {student_programme})." ) continue @@ -2130,7 +2273,7 @@ def post(self, request): unique_course_ids = ( CourseInstructor.objects - .filter(instructor_id_id=instructor_id, year=working_year, semester_type=semester_type) + .filter(instructor_id_id=instructor_id, year=course_instructor_year(academic_year, semester_type), semester_type=semester_type) .values("course_id_id") .distinct() .annotate(course_id_int=Cast("course_id_id", IntegerField())) @@ -2144,20 +2287,14 @@ def post(self, request): # Apply programme type filter if specified if programme_type: - if programme_type.upper() == 'UG': - programme_list = ['B.Tech', 'B.Des'] - elif programme_type.upper() == 'PG': - programme_list = ['M.Tech', 'M.Des', 'PhD'] - else: - return Response( - {"error": "Invalid programme_type. Must be 'UG' or 'PG'."}, - status=status.HTTP_400_BAD_REQUEST - ) - + programme_list, prog_err = resolve_programme_list(programme_type) + if prog_err: + return Response({"error": prog_err}, status=status.HTTP_400_BAD_REQUEST) + student_ids_with_programme = Student.objects.filter( programme__in=programme_list ).values_list('id', flat=True) - + grades_qs = grades_qs.filter(roll_no__in=student_ids_with_programme) course_ids = grades_qs.values_list("course_id_id", flat=True).distinct() @@ -2203,20 +2340,14 @@ def post(self, request): ) if programme_type: - if programme_type.upper() == 'UG': - programme_list = ['B.Tech', 'B.Des'] - elif programme_type.upper() == 'PG': - programme_list = ['M.Tech', 'M.Des', 'PhD'] - else: - return Response( - {"error": "Invalid programme_type. Must be 'UG' or 'PG'."}, - status=status.HTTP_400_BAD_REQUEST - ) - + programme_list, prog_err = resolve_programme_list(programme_type) + if prog_err: + return Response({"error": prog_err}, status=status.HTTP_400_BAD_REQUEST) + student_ids_with_programme = Student.objects.filter( programme__in=programme_list ).values_list('id', flat=True) - + grades = grades.filter(roll_no__in=student_ids_with_programme) grades = grades.order_by("roll_no") @@ -2224,13 +2355,13 @@ def post(self, request): if user_holds_role(request.user, "acadadmin"): ci = CourseInstructor.objects.filter( course_id_id=course_id, - year=working_year, + year=course_instructor_year(academic_year, semester_type), semester_type=semester_type, ) else: ci = CourseInstructor.objects.filter( course_id_id=course_id, - year=working_year, + year=course_instructor_year(academic_year, semester_type), semester_type=semester_type, instructor_id_id=request.user.username ) @@ -3003,8 +3134,8 @@ def post(self, request, *args, **kwargs): else: pass - spi, su, _ = calculate_spi_for_student(student, semester_no, semester_type) - cpi, tu, _ = calculate_cpi_for_student(student, semester_no, semester_type) + spi, su, _ = calculate_spi_for_student(student, semester_no, semester_type, require_announced=True) + cpi, tu, _ = calculate_cpi_for_student(student, semester_no, semester_type, require_announced=True) # Add student personal information to the response student_info = { @@ -3020,20 +3151,31 @@ def post(self, request, *args, **kwargs): "academic_year": academic_year or "" # Backend uses snake_case } + courses_list = [ + { + "coursecode": grade.course_id.code, + "courseid": grade.course_id.id, + "coursename": grade.course_id.name, + "credits": grade.course_id.credit, + "grade":grade.grade, + "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 + ] + for key, row in _phd_extra_display_rows(student, semester_no, require_announced=True): + courses_list.append({ + "coursecode": row["course_code"], + "courseid": key, + "coursename": row["course_name"], + "credits": row["credit"], + "grade": row["grade"], + "points": row["points"], + }) + response_data = { "success": True, "student_info": student_info, - "courses": [ - { - "coursecode": grade.course_id.code, - "courseid": grade.course_id.id, - "coursename": grade.course_id.name, - "credits": grade.course_id.credit, - "grade":grade.grade, - "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 - ], + "courses": courses_list, "spi": spi, "cpi": cpi, "su": su, @@ -3095,20 +3237,14 @@ def post(self, request): ) if programme_type: - if programme_type.upper() == 'UG': - programme_list = ['B.Tech', 'B.Des'] - elif programme_type.upper() == 'PG': - programme_list = ['M.Tech', 'M.Des', 'PhD'] - else: - return Response( - {"error": "Invalid programme_type. Must be 'UG' or 'PG'."}, - status=status.HTTP_400_BAD_REQUEST, - ) - + programme_list, prog_err = resolve_programme_list(programme_type) + if prog_err: + return Response({"error": prog_err}, status=status.HTTP_400_BAD_REQUEST) + student_ids_with_programme = Student.objects.filter( programme__in=programme_list ).values_list('id', flat=True) - + registrations = registrations.filter(student_id__in=student_ids_with_programme) # Build a set of registered roll numbers for fast lookup. @@ -3544,7 +3680,7 @@ def post(self, request): instructors_map = {} instructors = CourseInstructor.objects.filter( course_id__in=course_ids, - year=working_year, + year=course_instructor_year(academic_year, semester_type), semester_type=semester_type ).select_related() @@ -3667,6 +3803,13 @@ def post(self, request): {"success": False, "message": "semester_no and semester_type are required."}, status=400, ) + try: + semester_no = int(semester_no) + except (TypeError, ValueError): + return JsonResponse( + {"success": False, "message": "semester_no must be a number."}, + status=400, + ) try: student = Student.objects.get(id_id=roll_number) @@ -3698,8 +3841,8 @@ def post(self, request): if grades_info.exists(): academic_year = grades_info.first().academic_year - spi, su, _ = calculate_spi_for_student(student, semester_no, semester_type) - cpi, tu, _ = calculate_cpi_for_student(student, semester_no, semester_type) + spi, su, _ = calculate_spi_for_student(student, semester_no, semester_type, require_announced=True) + cpi, tu, _ = calculate_cpi_for_student(student, semester_no, semester_type, require_announced=True) student_info = { "name": f"{student.id.user.first_name} {student.id.user.last_name}".strip(), @@ -3714,10 +3857,8 @@ def post(self, request): "academic_year": academic_year or "" } - # Build courses list like CheckResultView - from applications.academic_information.models import grade_conversion - from decimal import Decimal, ROUND_HALF_UP - + # Build courses list like CheckResultView (grade_conversion/Decimal/ + # ROUND_HALF_UP are already imported at module level in this file) courses = [ { "coursecode": grade.course_id.code, @@ -3729,6 +3870,15 @@ def post(self, request): } for grade in grades_info ] + for key, row in _phd_extra_display_rows(student, semester_no, require_announced=True): + courses.append({ + "coursecode": row["course_code"], + "courseid": key, + "coursename": row["course_name"], + "credits": row["credit"], + "grade": row["grade"], + "points": row["points"], + }) else: # Use provided data spi = float(data.get('spi', 0)) @@ -3953,9 +4103,9 @@ def post(self, request): filename = _safe_filename(f"{prefix}{roll}_{semester_suffix}", extension=".pdf") response['Content-Disposition'] = f'attachment; filename="{filename}"' response['Content-Length'] = len(pdf_data) - + return response - + except Exception as e: return JsonResponse({'error': f'PDF generation failed: {str(e)}'}, status=500) @@ -4050,6 +4200,8 @@ def post(self, request): Decimal('0.1'), rounding=ROUND_HALF_UP), "special_symbol": course_reg_map.get(course.id, ''), } + for key, row in _phd_extra_display_rows(student, semester_number): + course_grades[key] = {**row, "special_symbol": ""} programme_full = { "B.Tech": "Bachelor of Technology", @@ -4110,37 +4262,18 @@ def post(self, request): .order_by('semester', 'semester_type') ) - NON_CREDIT_GRADES = {'F', 'I', 'X', 'AU', 'CD'} - credits_per_sem = {} - try: - sem_credit_qs = ( - Student_grades.objects - .filter(roll_no=student_id) - .exclude(grade__isnull=True) - .exclude(grade__in=NON_CREDIT_GRADES) - .values('semester', 'semester_type') - .annotate(sem_credits=Sum('course_id__credit')) - ) - for row in sem_credit_qs: - if row['semester'] is not None: - credits_per_sem[(row['semester'], row['semester_type'])] = row['sem_credits'] or 0 - except Exception: - pass - - running_credits = 0 for sem in distinct_semesters: s_no = sem['semester'] s_type = sem['semester_type'] is_summer = bool(s_type and 'summer' in str(s_type).lower()) - running_credits += credits_per_sem.get((s_no, s_type), 0) try: s_spi, _, _ = calculate_spi_for_student(student, s_no, s_type) - s_cpi, _, _ = calculate_cpi_for_student(student, s_no, s_type) + s_cpi, s_cum_credits, _ = calculate_cpi_for_student(student, s_no, s_type) semester_history.append({ "semester": s_no, "spi": float(s_spi) if s_spi else 0, "cpi": float(s_cpi) if s_cpi else 0, - "cumulative_credits": running_credits, + "cumulative_credits": float(s_cum_credits), "is_summer": is_summer, }) except Exception: @@ -4317,6 +4450,225 @@ def post(self, request): ) +def _build_grade_validation_semesters(student): + """One student's full semester-by-semester grade history for Grade Validation -- + shared by GradeValidationView's get_all_grades action and export_all_zip action + (previously duplicated ~90 lines apart). PhD Thesis/Progress Seminar/Teaching + Credit rows are merged into whichever semester bucket already covers that + semester number (preferring a non-summer one); if a PhD student has activity in + a semester with no regular course grade or registration at all, a bucket is + created for it instead of silently dropping it from this audit view.""" + roll_no = student.id_id + + all_grades = ( + Student_grades.objects.filter(roll_no=roll_no) + .select_related("course_id") + .order_by("semester", "semester_type", "course_id__code") + ) + semesters_map = defaultdict(list) + for g in all_grades: + semesters_map[(g.semester, g.semester_type)].append(g) + + all_regs = ( + course_registration.objects.filter(student_id=student) + .select_related("course_id", "semester_id") + .order_by("semester_id__semester_no", "semester_type", "course_id__code") + ) + reg_map = defaultdict(list) + for r in all_regs: + reg_map[(r.semester_id.semester_no, r.semester_type)].append(r) + + def _sem_sort_key(key): + s_no, s_type = key + is_summer = bool(s_type and "summer" in str(s_type).lower()) + return (s_no if s_no is not None else 0, 1 if is_summer else 0) + + def _is_summer_key(key): + return bool(key[1] and "summer" in str(key[1]).lower()) + + # ── Merge in PhD Thesis / Progress Seminar / Teaching Credit rows ────────── + key_to_phd_rows = {} + if student.programme in PROGRAMME_TYPE_BUCKETS['PHD']: + phd_semester_nos = set() + for qs, field in ( + (ThesisRegistration.objects.filter(student=student), 'semester__semester_no'), + (ProgressSeminarEntry.objects.filter(thesis__student=student), 'semester__semester_no'), + (TeachingCreditAllocation.objects.filter(student=student), 'semester__semester_no'), + ): + phd_semester_nos.update(n for n in qs.values_list(field, flat=True) if n is not None) + + for s_no in phd_semester_nos: + rows = _phd_extra_display_rows(student, s_no) + if not rows: + continue + mapped_rows = [{ + "code": row["course_code"], "name": row["course_name"], + "credits": float(row["credit"]), "grade": row["grade"], + "remark": "PhD", + } for _, row in rows] + + graded_candidates = [k for k in semesters_map if k[0] == s_no] + non_summer_graded = [k for k in graded_candidates if not _is_summer_key(k)] + reg_candidates = [k for k in reg_map if k[0] == s_no] + non_summer_reg = [k for k in reg_candidates if not _is_summer_key(k)] + + if non_summer_graded: + target = non_summer_graded[0] + elif non_summer_reg: + target = non_summer_reg[0] + reg_map.pop(target, None) # promote: it now has a real grade, not just a pending registration + semesters_map[target] # materialize the (already-empty) defaultdict entry + elif graded_candidates: + target = graded_candidates[0] + elif reg_candidates: + target = reg_candidates[0] + reg_map.pop(target, None) + semesters_map[target] + else: + target = (s_no, "Odd Semester" if s_no % 2 else "Even Semester") + semesters_map[target] + + key_to_phd_rows[target] = mapped_rows + + sorted_keys = sorted(semesters_map.keys(), key=_sem_sort_key) + + FAILING_GRADES = {"F", "I", "X", "AU", "CD"} + NON_CREDIT_GRADES = {"F", "I", "X", "AU", "CD"} + course_first_grade = {} + + semesters_data = [] + summer_counter = 0 + running_total_credits = Decimal('0') + + for key in sorted_keys: + s_no, s_type = key + is_summer = _is_summer_key(key) + + if is_summer: + summer_counter += 1 + label = f"Summer Semester {summer_counter}" + else: + label = f"Semester {s_no}" + + courses = [] + sem_credits_earned = Decimal('0') + for g in semesters_map[key]: + course = g.course_id + cid = course.id + grade = g.grade or "" + + if cid in course_first_grade: + prev = course_first_grade[cid] + remark = "Backlog" if prev in FAILING_GRADES else "Improvement" + else: + remark = "Regular" + course_first_grade[cid] = grade + + credit = Decimal(str(course.credit)) if course.credit is not None else Decimal('0') + if grade and grade.strip() not in NON_CREDIT_GRADES: + sem_credits_earned += credit + + courses.append({ + "code": course.code or "", "name": course.name or "", + "credits": float(credit), "grade": grade, "remark": remark, + }) + + for phd_row in key_to_phd_rows.get(key, []): + courses.append(phd_row) + if phd_row["grade"] and phd_row["grade"].strip() not in NON_CREDIT_GRADES: + sem_credits_earned += Decimal(str(phd_row["credits"])) + + if courses: + running_total_credits += sem_credits_earned + try: + s_spi, _, _ = calculate_spi_for_student(student, s_no, s_type) + s_cpi, _, _ = calculate_cpi_for_student(student, s_no, s_type) + except Exception: + s_spi, s_cpi = 0, 0 + + semesters_data.append({ + "semester_no": s_no, + "semester_type": s_type, + "is_summer": is_summer, + "label": label, + "courses": courses, + "semester_credits": float(sem_credits_earned), + "total_credits": float(running_total_credits), + "spi": float(s_spi) if s_spi else 0.0, + "cpi": float(s_cpi) if s_cpi else 0.0, + }) + + # ── Registered-but-not-yet-graded semesters (reg_map no longer contains any + # keys that were promoted into the graded loop above) ───────────────────── + graded_keys = set(sorted_keys) + try: + pending_keys = sorted( + [k for k in reg_map if k not in graded_keys and reg_map[k]], + key=_sem_sort_key, + ) + for key in pending_keys: + s_no, s_type = key + is_summer = _is_summer_key(key) + label = "Summer Semester (Registered)" if is_summer else f"Semester {s_no} (Registered)" + + reg_courses = [] + reg_credits_total = Decimal('0') + for r in reg_map[key]: + credit = Decimal(str(r.course_id.credit)) if r.course_id.credit is not None else Decimal('0') + reg_credits_total += credit + reg_courses.append({ + "code": r.course_id.code or "", "name": r.course_id.name or "", + "credits": float(credit), "grade": "—", + "remark": r.registration_type or "Regular", + }) + + if reg_courses: + semesters_data.append({ + "semester_no": s_no, + "semester_type": s_type, + "is_summer": is_summer, + "is_registered_only": True, + "label": label, + "courses": reg_courses, + "semester_credits": float(reg_credits_total), + "total_credits": float(running_total_credits), + "spi": None, + "cpi": None, + }) + except Exception: + pass + + PROGRAMME_MAP = { + "B.Tech": "Bachelor of Technology", + "B.Des": "Bachelor of Design", + "M.Tech": "Master of Technology", + "M.Des": "Master of Design", + "PhD": "Doctor of Philosophy", + } + programme_full = PROGRAMME_MAP.get(student.programme, student.programme or "") + + discipline_full = "" + try: + if student.batch_id and student.batch_id.discipline: + discipline_full = student.batch_id.discipline.name + except Exception: + pass + if not discipline_full: + try: + discipline_full = student.id.department.name if student.id.department else "" + except Exception: + pass + + student_info = { + "roll_no": student.id.user.username, + "name": f"{student.id.user.first_name} {student.id.user.last_name}".strip(), + "programme": programme_full, + "discipline": discipline_full, + } + + return student_info, semesters_data + + class GradeValidationView(APIView): """ API for Grade Validation: fetch batch years/branches, list students, @@ -4408,183 +4760,7 @@ def post(self, request): except Student.DoesNotExist: return Response({"error": "Student not found."}, status=status.HTTP_404_NOT_FOUND) - student_id = student.id_id - - # All grades across every semester, ordered chronologically - all_grades = ( - Student_grades.objects.filter(roll_no=student_id) - .select_related("course_id") - .order_by("semester", "semester_type", "course_id__code") - ) - - # Group by (semester_no, semester_type) - semesters_map = defaultdict(list) - for g in all_grades: - semesters_map[(g.semester, g.semester_type)].append(g) - - # Sort keys: summer comes AFTER the matching regular semester - def _sem_sort_key(key): - s_no, s_type = key - is_summer = bool(s_type and "summer" in str(s_type).lower()) - # (semester_no, 1 if summer else 0) keeps summers right after their regular sem - return (s_no if s_no is not None else 0, 1 if is_summer else 0) - - sorted_keys = sorted(semesters_map.keys(), key=_sem_sort_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"} - course_first_grade: dict = {} # course_id -> first grade string - - semesters_data = [] - summer_counter = 0 # increment each time we encounter a summer semester - running_total_credits = Decimal('0') - - for key in sorted_keys: - s_no, s_type = key - is_summer = bool(s_type and "summer" in str(s_type).lower()) - - if is_summer: - summer_counter += 1 - label = f"Summer Semester {summer_counter}" - else: - label = f"Semester {s_no}" - - courses = [] - sem_credits_earned = Decimal('0') - for g in semesters_map[key]: - course = g.course_id - cid = course.id - grade = g.grade or "" - - if cid in course_first_grade: - prev = course_first_grade[cid] - remark = "Backlog" if prev in FAILING_GRADES else "Improvement" - else: - remark = "Regular" - course_first_grade[cid] = grade # record only first appearance - - # Count credits only for non-failing grades - credit = Decimal(str(course.credit)) if course.credit is not None else Decimal('0') - if grade and grade.strip() not in NON_CREDIT_GRADES: - sem_credits_earned += credit - - courses.append({ - "code": course.code or "", - "name": course.name or "", - "credits": float(credit), - "grade": grade, - "remark": remark, - }) - - if courses: # skip empty semesters (no graded courses) - running_total_credits += sem_credits_earned - - # Compute SPI / CPI via existing helpers - try: - s_spi, _, _ = calculate_spi_for_student(student, s_no, s_type) - s_cpi, _, _ = calculate_cpi_for_student(student, s_no, s_type) - except Exception: - s_spi, s_cpi = 0, 0 - - semesters_data.append({ - "semester_no": s_no, - "semester_type": s_type, - "is_summer": is_summer, - "label": label, - "courses": courses, - "semester_credits": float(sem_credits_earned), - "total_credits": float(running_total_credits), - "spi": float(s_spi) if s_spi else 0.0, - "cpi": float(s_cpi) if s_cpi else 0.0, - }) - - # ── Append registered-but-not-yet-graded semester ──────────────── - graded_keys = set(sorted_keys) - try: - all_regs = ( - course_registration.objects - .filter(student_id=student) - .select_related("course_id", "semester_id") - .order_by("semester_id__semester_no", "semester_type", "course_id__code") - ) - reg_map = defaultdict(list) - for r in all_regs: - reg_map[(r.semester_id.semester_no, r.semester_type)].append(r) - - # Only include keys that have NO corresponding grade entry - pending_keys = [ - k for k in reg_map - if k not in graded_keys and reg_map[k] - ] - # Sort pending keys same way - pending_keys.sort(key=_sem_sort_key) - - for key in pending_keys: - s_no, s_type = key - is_summer = bool(s_type and "summer" in str(s_type).lower()) - if is_summer: - label = f"Summer Semester (Registered)" - else: - label = f"Semester {s_no} (Registered)" - - reg_courses = [] - reg_credits_total = Decimal('0') - for r in reg_map[key]: - credit = Decimal(str(r.course_id.credit)) if r.course_id.credit is not None else Decimal('0') - reg_credits_total += credit - reg_courses.append({ - "code": r.course_id.code or "", - "name": r.course_id.name or "", - "credits": float(credit), - "grade": "—", - "remark": r.registration_type or "Regular", - }) - - if reg_courses: - semesters_data.append({ - "semester_no": s_no, - "semester_type": s_type, - "is_summer": is_summer, - "is_registered_only": True, - "label": label, - "courses": reg_courses, - "semester_credits": float(reg_credits_total), - "total_credits": float(running_total_credits), # cumulative unchanged - "spi": None, - "cpi": None, - }) - except Exception: - pass - - # Build student info - PROGRAMME_MAP = { - "B.Tech": "Bachelor of Technology", - "B.Des": "Bachelor of Design", - "M.Tech": "Master of Technology", - "M.Des": "Master of Design", - "PhD": "Doctor of Philosophy", - } - programme_full = PROGRAMME_MAP.get(student.programme, student.programme or "") - - discipline_full = "" - try: - if student.batch_id and student.batch_id.discipline: - discipline_full = student.batch_id.discipline.name - except Exception: - pass - if not discipline_full: - try: - discipline_full = student.id.department.name if student.id.department else "" - except Exception: - pass - - student_info = { - "roll_no": roll_no, - "name": f"{student.id.user.first_name} {student.id.user.last_name}".strip(), - "programme": programme_full, - "discipline": discipline_full, - } + student_info, semesters_data = _build_grade_validation_semesters(student) return Response( {"student_info": student_info, "semesters": semesters_data}, @@ -4619,148 +4795,9 @@ def _sem_sort_key(key): .order_by("id__user__username") ) - PROGRAMME_MAP2 = { - "B.Tech": "Bachelor of Technology", - "B.Des": "Bachelor of Design", - "M.Tech": "Master of Technology", - "M.Des": "Master of Design", - "PhD": "Doctor of Philosophy", - } - NON_CREDIT_GRADES2 = {"F", "I", "X", "AU", "CD"} - FAILING_GRADES2 = {"F", "I", "X", "AU", "CD"} - def _get_student_data(stu): """Return (student_info dict, semesters list) for one student.""" - stu_id = stu.id_id - programme_full2 = PROGRAMME_MAP2.get(stu.programme, stu.programme or "") - discipline2 = "" - try: - if stu.batch_id and stu.batch_id.discipline: - discipline2 = stu.batch_id.discipline.name - except Exception: - pass - - all_grades2 = ( - Student_grades.objects.filter(roll_no=stu_id) - .select_related("course_id") - .order_by("semester", "semester_type", "course_id__code") - ) - - sem_map2 = defaultdict(list) - for g in all_grades2: - sem_map2[(g.semester, g.semester_type)].append(g) - - def _key2(k): - s, t = k - return (s or 0, 1 if (t and "summer" in str(t).lower()) else 0) - - sorted_keys2 = sorted(sem_map2.keys(), key=_key2) - course_first2: dict = {} - sems2 = [] - summer_ctr2 = 0 - running_creds2 = Decimal('0') - - for key2 in sorted_keys2: - s_no2, s_type2 = key2 - is_sum2 = bool(s_type2 and "summer" in str(s_type2).lower()) - if is_sum2: - summer_ctr2 += 1 - lbl2 = f"Summer Semester {summer_ctr2}" - else: - lbl2 = f"Semester {s_no2}" - - courses2 = [] - sem_creds2 = Decimal('0') - for g2 in sem_map2[key2]: - c2 = g2.course_id - cid2 = c2.id - grade2 = g2.grade or "" - if cid2 in course_first2: - prev2 = course_first2[cid2] - rem2 = "Backlog" if prev2 in FAILING_GRADES2 else "Improvement" - else: - rem2 = "Regular" - course_first2[cid2] = grade2 - credit2 = Decimal(str(c2.credit)) if c2.credit is not None else Decimal('0') - if grade2 and grade2.strip() not in NON_CREDIT_GRADES2: - sem_creds2 += credit2 - courses2.append({ - "code": c2.code or "", - "name": c2.name or "", - "credits": float(credit2), - "grade": grade2, - "remark": rem2, - }) - - if courses2: - running_creds2 += sem_creds2 - try: - sp2, _, _ = calculate_spi_for_student(stu, s_no2, s_type2) - cp2, _, _ = calculate_cpi_for_student(stu, s_no2, s_type2) - except Exception: - sp2, cp2 = 0, 0 - sems2.append({ - "label": lbl2, - "is_registered_only": False, - "courses": courses2, - "semester_credits": float(sem_creds2), - "total_credits": float(running_creds2), - "spi": float(sp2) if sp2 else 0.0, - "cpi": float(cp2) if cp2 else 0.0, - }) - - # Registered-only semesters - graded_keys2 = set(sorted_keys2) - try: - all_regs2 = ( - course_registration.objects - .filter(student_id=stu) - .select_related("course_id", "semester_id") - .order_by("semester_id__semester_no", "semester_type", "course_id__code") - ) - reg_map2 = defaultdict(list) - for r2 in all_regs2: - reg_map2[(r2.semester_id.semester_no, r2.semester_type)].append(r2) - pending2 = sorted( - [k for k in reg_map2 if k not in graded_keys2 and reg_map2[k]], - key=_key2, - ) - for pk2 in pending2: - s_no2p, s_type2p = pk2 - is_sump = bool(s_type2p and "summer" in str(s_type2p).lower()) - lblp = f"Summer Semester (Registered)" if is_sump else f"Semester {s_no2p} (Registered)" - reg_cs2 = [] - reg_cred2 = Decimal('0') - for r2p in reg_map2[pk2]: - cr2p = Decimal(str(r2p.course_id.credit)) if r2p.course_id.credit is not None else Decimal('0') - reg_cred2 += cr2p - reg_cs2.append({ - "code": r2p.course_id.code or "", - "name": r2p.course_id.name or "", - "credits": float(cr2p), - "grade": "—", - "remark": r2p.registration_type or "Regular", - }) - if reg_cs2: - sems2.append({ - "label": lblp, - "is_registered_only": True, - "courses": reg_cs2, - "semester_credits": float(reg_cred2), - "total_credits": float(running_creds2), - "spi": None, - "cpi": None, - }) - except Exception: - pass - - stu_info2 = { - "roll_no": stu.id.user.username, - "name": f"{stu.id.user.first_name} {stu.id.user.last_name}".strip(), - "programme": programme_full2, - "discipline": discipline2, - } - return stu_info2, sems2 + return _build_grade_validation_semesters(stu) def _build_pdf_bytes(stu_info, semesters): buf = _BytesIO() diff --git a/FusionIIIT/applications/programme_curriculum/api/serializers.py b/FusionIIIT/applications/programme_curriculum/api/serializers.py index 3b94c1642..b24c96122 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, Thesis, ProgressSeminar +from applications.programme_curriculum.models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot, CourseInstructor, Thesis, Seminar, TeachingCredit from applications.programme_curriculum.models_student_management import StudentBatchUpload # this is for Programme model .... @@ -110,12 +110,21 @@ class Meta: fields = "__all__" -class ProgressSeminarSerializer(serializers.ModelSerializer): +class SeminarSerializer(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 = Seminar + fields = "__all__" + + +class TeachingCreditSerializer(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 + model = TeachingCredit fields = "__all__" diff --git a/FusionIIIT/applications/programme_curriculum/api/urls.py b/FusionIIIT/applications/programme_curriculum/api/urls.py index 65560f33d..14cf56996 100644 --- a/FusionIIIT/applications/programme_curriculum/api/urls.py +++ b/FusionIIIT/applications/programme_curriculum/api/urls.py @@ -93,29 +93,43 @@ 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'), - + # Seminar APIs + path('admin_seminars/', views.admin_view_all_seminars, name='admin_view_all_seminars'), + path('admin_add_seminar/', views.add_seminar, name='add_seminar'), + path('admin_delete_seminar//', views.admin_delete_seminar, name='admin_delete_seminar'), + path('admin_update_seminar//', views.update_seminar, name='update_seminar'), + + # Teaching Credit APIs + path('admin_teaching_credits/', views.admin_view_all_teaching_credits, name='admin_view_all_teaching_credits'), + path('admin_add_teaching_credit/', views.add_teaching_credit, name='add_teaching_credit'), + path('admin_delete_teaching_credit//', views.admin_delete_teaching_credit, name='admin_delete_teaching_credit'), + path('admin_update_teaching_credit//', views.update_teaching_credit, name='update_teaching_credit'), + # 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'), - + + # Seminar Slot APIs + path('admin_add_seminar_slot/', views.add_seminar_slot, name='add_seminar_slot'), + + # Teaching Credit Slot APIs + path('admin_add_teaching_credit_slot/', views.add_teaching_credit_slot, name='add_teaching_credit_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 + + # Seminar Slot Detail / Delete APIs + path('admin_seminar_slot//', views.admin_view_a_seminar_slot, name='admin_view_a_seminar_slot'), + path('admin_delete_seminar_slot//', views.delete_seminar_slot, name='delete_seminar_slot'), + + # Teaching Credit Slot Detail / Delete APIs + path('admin_teaching_credit_slot//', views.admin_view_a_teaching_credit_slot, name='admin_view_a_teaching_credit_slot'), + path('admin_delete_teaching_credit_slot//', views.delete_teaching_credit_slot, name='delete_teaching_credit_slot'), + + # Edit Thesis Slot / Seminar Slot / Teaching Credit 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'), + path('admin_edit_seminar_slot//', views.edit_seminar_slot_form, name='edit_seminar_slot_form'), + path('admin_edit_teaching_credit_slot//', views.edit_teaching_credit_slot_form, name='edit_teaching_credit_slot_form'), # Delete APIs path('admin_delete_course//', views.admin_delete_course, name='admin_delete_course'), diff --git a/FusionIIIT/applications/programme_curriculum/api/views.py b/FusionIIIT/applications/programme_curriculum/api/views.py index 24ce0beab..c99c4cdc5 100644 --- a/FusionIIIT/applications/programme_curriculum/api/views.py +++ b/FusionIIIT/applications/programme_curriculum/api/views.py @@ -8,13 +8,14 @@ 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, Thesis, ProgressSeminar, ThesisSlot, ProgressSeminarSlot -from ..forms import ProgrammeForm, DisciplineForm, CurriculumForm, SemesterForm, CourseForm, BatchForm, CourseSlotForm, ReplicateCurriculumForm,NewCourseProposalFile,CourseProposalTrackingFile, CourseInstructor, CourseInstructorForm, ThesisForm, ProgressSeminarForm, ThesisSlotForm, ProgressSeminarSlotForm +from ..models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot,NewProposalFile,Proposal_Tracking,CourseInstructor,CourseAuditLog, Thesis, Seminar, ThesisSlot, SeminarSlot, TeachingCredit, TeachingCreditSlot +from ..forms import ProgrammeForm, DisciplineForm, CurriculumForm, SemesterForm, CourseForm, BatchForm, CourseSlotForm, ReplicateCurriculumForm,NewCourseProposalFile,CourseProposalTrackingFile, CourseInstructor, CourseInstructorForm, ThesisForm, SeminarForm, ThesisSlotForm, SeminarSlotForm, TeachingCreditForm, TeachingCreditSlotForm from ..filters import CourseFilter, BatchFilter, CurriculumFilter -from .serializers import CourseSerializer,CurriculumSerializer,BatchSerializer, ThesisSerializer, ProgressSeminarSerializer +from .serializers import CourseSerializer,CurriculumSerializer,BatchSerializer, ThesisSerializer, SeminarSerializer, TeachingCreditSerializer from .views_student_management import get_batch_curriculum_display, get_available_curriculums_for_batch from django.core.serializers import serialize +from django.core.serializers.json import DjangoJSONEncoder from django.db import IntegrityError, transaction from django.utils import timezone from django.forms.models import model_to_dict @@ -613,17 +614,28 @@ def admin_view_semesters_of_a_curriculum(request, curriculum_id): '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')) + # Seminar slots + for ss in SeminarSlot.objects.filter(semester=semester).order_by('id'): + seminars = list(ss.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 + 'id': ss.id, + 'type': 'Seminar', + 'name': ss.name, + 'slot_type': 'seminar', + 'courses': seminars # reuse 'courses' key for table rendering compatibility }) - + + # Teaching credit slots + for tcs in TeachingCreditSlot.objects.filter(semester=semester).order_by('id'): + teaching_credits = list(tcs.teaching_credits.values('id', 'name', 'code', 'credit')) + slots.append({ + 'id': tcs.id, + 'type': 'Teaching Credit', + 'name': tcs.name, + 'slot_type': 'teaching_credit', + 'courses': teaching_credits # 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) @@ -4365,7 +4377,16 @@ def create_course_audit_log(course, user, action, old_data=None, new_data=None, version_bump_type='NONE', old_version=None, new_version=None, admin_override=False, reason=""): """Create an audit log entry for course changes""" - + + def _json_safe(value): + # The JSONField columns use the default encoder, which can't serialize + # Decimal/datetime (e.g. Course.version is a DecimalField). Coerce to + # JSON-native types so saving the audit log doesn't raise (which was + # surfacing as a 500 while the course row itself had already been saved). + if value is None: + return None + return json.loads(json.dumps(value, cls=DjangoJSONEncoder)) + changed_fields = [] if old_data and new_data: # Find changed fields @@ -4373,17 +4394,17 @@ def create_course_audit_log(course, user, action, old_data=None, new_data=None, new_value = new_data.get(field) if old_value != new_value: changed_fields.append(field) - + audit_log = CourseAuditLog.objects.create( course=course, user=user, action=action, - old_values=old_data, - new_values=new_data, + old_values=_json_safe(old_data), + new_values=_json_safe(new_data), changed_fields=changed_fields, version_bump_type=version_bump_type, - old_version=old_version, - new_version=new_version, + old_version=_json_safe(old_version), + new_version=_json_safe(new_version), admin_override=admin_override, reason=reason ) @@ -4496,68 +4517,68 @@ def admin_delete_thesis(request, thesis_id): }, status=500) -# ------------ Progress Seminar Views ---------------# +# ------------ 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() +def admin_view_all_seminars(request): + """Returns all seminars with required fields as JSON data.""" + + seminars = Seminar.objects.all() - progress_seminars_data = [ + 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 + "id": s.id, + "code": s.code, + "name": s.name, + "discipline": s.discipline.name, + "discipline_acronym": s.discipline.acronym, + "programme_type": s.programme_type, + "programme_type_display": s.get_programme_type_display(), + "credits": s.credit, + "working_seminar": s.working_seminar } - for ps in progress_seminars + for s in seminars ] - return JsonResponse({'progress_seminars': progress_seminars_data}) + return JsonResponse({'seminars': seminars_data}) @api_view(['POST']) @authentication_classes([TokenAuthentication]) @permission_classes([IsAuthenticated]) -def add_progress_seminar(request): - """Add a new progress seminar""" - +def add_seminar(request): + """Add a new 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( + + seminar = Seminar.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) + working_seminar=data.get('working_seminar', True) ) - + return JsonResponse({ 'success': True, - 'message': 'Progress Seminar added successfully', - 'progress_seminar_id': progress_seminar.id + 'message': 'Seminar added successfully', + 'seminar_id': seminar.id }, status=201) - + except IntegrityError: return JsonResponse({ - 'error': 'A progress seminar with this code already exists for this discipline' + 'error': 'A seminar with this code already exists for this discipline' }, status=400) except Exception as e: return JsonResponse({ @@ -4568,21 +4589,21 @@ def add_progress_seminar(request): @api_view(['DELETE']) @authentication_classes([TokenAuthentication]) @permission_classes([IsAuthenticated]) -def admin_delete_progress_seminar(request, progress_seminar_id): - """Delete a progress seminar""" - +def admin_delete_seminar(request, seminar_id): + """Delete a seminar""" + try: - ps = get_object_or_404(ProgressSeminar, id=progress_seminar_id) - ps_code = ps.code - ps_name = ps.name - - ps.delete() - + s = get_object_or_404(Seminar, id=seminar_id) + s_code = s.code + s_name = s.name + + s.delete() + return JsonResponse({ 'success': True, - 'message': f'Progress Seminar {ps_code} - {ps_name} deleted successfully' + 'message': f'Seminar {s_code} - {s_name} deleted successfully' }, status=200) - + except Exception as e: return JsonResponse({ 'error': str(e) @@ -4642,20 +4663,20 @@ def update_thesis(request, thesis_id): @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) +def update_seminar(request, seminar_id): + """Get seminar details for editing (GET) or update an existing seminar (PUT).""" + s = get_object_or_404(Seminar, id=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, + 'id': s.id, + 'code': s.code, + 'name': s.name, + 'credit': s.credit, + 'discipline': s.discipline.id, + 'discipline_name': s.discipline.name, + 'discipline_acronym': s.discipline.acronym, + 'programme_type': s.programme_type, } return Response(data, status=status.HTTP_200_OK) @@ -4664,22 +4685,22 @@ def update_progress_seminar(request, progress_seminar_id): 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() + s.code = data.get('code', s.code) + s.name = data.get('name', s.name) + s.credit = data.get('credit', s.credit) + s.discipline = discipline + s.programme_type = data.get('programme_type', s.programme_type) + s.save() return JsonResponse({ 'success': True, - 'message': f'Progress Seminar {ps.code} - {ps.name} updated successfully', - 'progress_seminar_id': ps.id, + 'message': f'Seminar {s.code} - {s.name} updated successfully', + 'seminar_id': s.id, }, status=200) except IntegrityError: return JsonResponse({ - 'error': 'A progress seminar with this code already exists for this discipline' + 'error': 'A seminar with this code already exists for this discipline' }, status=400) except Exception as e: return JsonResponse({ @@ -4725,27 +4746,27 @@ def add_thesis_slot(request): @api_view(['POST']) @authentication_classes([TokenAuthentication]) @permission_classes([IsAuthenticated]) -def add_progress_seminar_slot(request): - """Add a new progress seminar slot to a semester.""" +def add_seminar_slot(request): + """Add a new seminar slot to a semester.""" try: data = json.loads(request.body) - ps_slot = ProgressSeminarSlot.objects.create( + seminar_slot = SeminarSlot.objects.create( semester_id=data['semester'], name=data['name'], - progress_seminar_slot_info=data.get('progress_seminar_slot_info', ''), + seminar_slot_info=data.get('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']) + if 'seminars' in data and data['seminars']: + seminar_slot.seminars.set(data['seminars']) return JsonResponse({ 'status': 'success', - 'message': 'Progress seminar slot created successfully', - 'id': ps_slot.id + 'message': 'Seminar slot created successfully', + 'id': seminar_slot.id }) except Exception as e: @@ -4785,31 +4806,31 @@ def admin_view_a_thesis_slot(request, thesis_slot_id): }) -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) +def admin_view_a_seminar_slot(request, seminar_slot_id): + """API to view a seminar slot""" + seminar_slot = get_object_or_404(SeminarSlot, id=seminar_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': [ + 'seminar_slot': { + 'id': seminar_slot.id, + 'name': seminar_slot.name, + 'seminar_slot_info': seminar_slot.seminar_slot_info, + 'duration': seminar_slot.duration, + 'min_registration_limit': seminar_slot.min_registration_limit, + 'max_registration_limit': seminar_slot.max_registration_limit, + 'seminars': [ { - 'id': ps.id, - 'code': ps.code, - 'name': ps.name, - 'credit': ps.credit, - } for ps in ps_slot.progress_seminars.all() + 'id': s.id, + 'code': s.code, + 'name': s.name, + 'credit': s.credit, + } for s in seminar_slot.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, + 'id': seminar_slot.semester.curriculum.id, + 'name': seminar_slot.semester.curriculum.name, + 'version': seminar_slot.semester.curriculum.version, + 'semester_no': seminar_slot.semester.semester_no, } }, }) @@ -4822,11 +4843,11 @@ def delete_thesis_slot(request, thesis_slot_id): 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 delete_seminar_slot(request, seminar_slot_id): + """Delete a seminar slot""" + seminar_slot = get_object_or_404(SeminarSlot, id=seminar_slot_id) + seminar_slot.delete() + return JsonResponse({'status': 'success', 'message': 'Seminar slot deleted successfully'}) def edit_thesis_slot_form(request, thesis_slot_id): @@ -4866,38 +4887,288 @@ def edit_thesis_slot_form(request, thesis_slot_id): 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 +def edit_seminar_slot_form(request, seminar_slot_id): + """GET returns existing seminar slot data; PUT updates it.""" + seminar_slot = get_object_or_404(SeminarSlot, id=seminar_slot_id) + curriculum_id = seminar_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, + 'id': seminar_slot.id, + 'semester': seminar_slot.semester.id, + 'name': seminar_slot.name, + 'seminar_slot_info': seminar_slot.seminar_slot_info, + 'seminars': [s.id for s in seminar_slot.seminars.all()], + 'duration': seminar_slot.duration, + 'min_registration_limit': seminar_slot.min_registration_limit, + 'max_registration_limit': seminar_slot.max_registration_limit, 'curriculum_id': curriculum_id, } - return JsonResponse({'status': 'success', 'progress_seminar_slot': data}) + return JsonResponse({'status': 'success', 'seminar_slot': data}) elif request.method == 'PUT': try: data = json.loads(request.body) - form = ProgressSeminarSlotForm(data, instance=ps_slot) + form = SeminarSlotForm(data, instance=seminar_slot) if form.is_valid(): form.save() return JsonResponse({ 'status': 'success', - 'message': 'Progress seminar slot updated successfully', + 'message': '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 + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}, status=405) + + +# ------------ Teaching Credit Views ---------------# + +@api_view(['GET']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def admin_view_all_teaching_credits(request): + """Returns all teaching credits with required fields as JSON data.""" + + teaching_credits = TeachingCredit.objects.all() + + teaching_credits_data = [ + { + "id": tc.id, + "code": tc.code, + "name": tc.name, + "discipline": tc.discipline.name, + "discipline_acronym": tc.discipline.acronym, + "programme_type": tc.programme_type, + "programme_type_display": tc.get_programme_type_display(), + "credits": tc.credit, + "working_teaching_credit": tc.working_teaching_credit + } + for tc in teaching_credits + ] + + return JsonResponse({'teaching_credits': teaching_credits_data}) + + +@api_view(['POST']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def add_teaching_credit(request): + """Add a new teaching credit""" + + 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']) + + teaching_credit = TeachingCredit.objects.create( + code=data['code'], + name=data['name'], + credit=data['credit'], + discipline=discipline, + programme_type=data['programme_type'], + working_teaching_credit=data.get('working_teaching_credit', True) + ) + + return JsonResponse({ + 'success': True, + 'message': 'Teaching Credit added successfully', + 'teaching_credit_id': teaching_credit.id + }, status=201) + + except IntegrityError: + return JsonResponse({ + 'error': 'A teaching credit 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_teaching_credit(request, teaching_credit_id): + """Delete a teaching credit""" + + try: + tc = get_object_or_404(TeachingCredit, id=teaching_credit_id) + tc_code = tc.code + tc_name = tc.name + + tc.delete() + + return JsonResponse({ + 'success': True, + 'message': f'Teaching Credit {tc_code} - {tc_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_teaching_credit(request, teaching_credit_id): + """Get teaching credit details for editing (GET) or update an existing teaching credit (PUT).""" + tc = get_object_or_404(TeachingCredit, id=teaching_credit_id) + + if request.method == 'GET': + data = { + 'id': tc.id, + 'code': tc.code, + 'name': tc.name, + 'credit': tc.credit, + 'discipline': tc.discipline.id, + 'discipline_name': tc.discipline.name, + 'discipline_acronym': tc.discipline.acronym, + 'programme_type': tc.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')) + + tc.code = data.get('code', tc.code) + tc.name = data.get('name', tc.name) + tc.credit = data.get('credit', tc.credit) + tc.discipline = discipline + tc.programme_type = data.get('programme_type', tc.programme_type) + tc.save() + + return JsonResponse({ + 'success': True, + 'message': f'Teaching Credit {tc.code} - {tc.name} updated successfully', + 'teaching_credit_id': tc.id, + }, status=200) + + except IntegrityError: + return JsonResponse({ + 'error': 'A teaching credit 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_teaching_credit_slot(request): + """Add a new teaching credit slot to a semester.""" + try: + data = json.loads(request.body) + + tc_slot = TeachingCreditSlot.objects.create( + semester_id=data['semester'], + name=data['name'], + teaching_credit_slot_info=data.get('teaching_credit_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 'teaching_credits' in data and data['teaching_credits']: + tc_slot.teaching_credits.set(data['teaching_credits']) + + return JsonResponse({ + 'status': 'success', + 'message': 'Teaching credit slot created successfully', + 'id': tc_slot.id + }) + + except Exception as e: + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }, status=400) + + +def admin_view_a_teaching_credit_slot(request, tc_slot_id): + """API to view a teaching credit slot""" + tc_slot = get_object_or_404(TeachingCreditSlot, id=tc_slot_id) + + return JsonResponse({ + 'teaching_credit_slot': { + 'id': tc_slot.id, + 'name': tc_slot.name, + 'teaching_credit_slot_info': tc_slot.teaching_credit_slot_info, + 'duration': tc_slot.duration, + 'min_registration_limit': tc_slot.min_registration_limit, + 'max_registration_limit': tc_slot.max_registration_limit, + 'teaching_credits': [ + { + 'id': tc.id, + 'code': tc.code, + 'name': tc.name, + 'credit': tc.credit, + } for tc in tc_slot.teaching_credits.all() + ], + 'curriculum': { + 'id': tc_slot.semester.curriculum.id, + 'name': tc_slot.semester.curriculum.name, + 'version': tc_slot.semester.curriculum.version, + 'semester_no': tc_slot.semester.semester_no, + } + }, + }) + + +def delete_teaching_credit_slot(request, tc_slot_id): + """Delete a teaching credit slot""" + tc_slot = get_object_or_404(TeachingCreditSlot, id=tc_slot_id) + tc_slot.delete() + return JsonResponse({'status': 'success', 'message': 'Teaching credit slot deleted successfully'}) + + +def edit_teaching_credit_slot_form(request, tc_slot_id): + """GET returns existing teaching credit slot data; PUT updates it.""" + tc_slot = get_object_or_404(TeachingCreditSlot, id=tc_slot_id) + curriculum_id = tc_slot.semester.curriculum.id + + if request.method == 'GET': + data = { + 'id': tc_slot.id, + 'semester': tc_slot.semester.id, + 'name': tc_slot.name, + 'teaching_credit_slot_info': tc_slot.teaching_credit_slot_info, + 'teaching_credits': [tc.id for tc in tc_slot.teaching_credits.all()], + 'duration': tc_slot.duration, + 'min_registration_limit': tc_slot.min_registration_limit, + 'max_registration_limit': tc_slot.max_registration_limit, + 'curriculum_id': curriculum_id, + } + return JsonResponse({'status': 'success', 'teaching_credit_slot': data}) + + elif request.method == 'PUT': + try: + data = json.loads(request.body) + form = TeachingCreditSlotForm(data, instance=tc_slot) + if form.is_valid(): + form.save() + return JsonResponse({ + 'status': 'success', + 'message': 'Teaching credit 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) diff --git a/FusionIIIT/applications/programme_curriculum/forms.py b/FusionIIIT/applications/programme_curriculum/forms.py index 8d2d18261..9453b247c 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, Thesis, ProgressSeminar, ThesisSlot, ProgressSeminarSlot +from .models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot, PROGRAMME_CATEGORY_CHOICES,NewProposalFile,Proposal_Tracking, CourseInstructor, Thesis, Seminar, ThesisSlot, SeminarSlot, TeachingCredit, TeachingCreditSlot 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) @@ -458,17 +458,17 @@ class Meta: } -class ProgressSeminarForm(ModelForm): +class SeminarForm(ModelForm): class Meta: - model = ProgressSeminar + model = Seminar 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}), + 'code': forms.TextInput(attrs={'placeholder': 'Seminar Code (e.g., CS898)', 'max_length': 10}), + 'name': forms.TextInput(attrs={'placeholder': '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'}), + 'working_seminar': forms.CheckboxInput(attrs={'class': 'ui checkbox'}), } @@ -488,16 +488,46 @@ class Meta: } -class ProgressSeminarSlotForm(ModelForm): +class SeminarSlotForm(ModelForm): class Meta: - model = ProgressSeminarSlot + model = SeminarSlot 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'}), + 'seminar_slot_info': forms.Textarea(attrs={'placeholder': 'Enter Information about this Seminar Slot'}), + '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'}), + } + + +class TeachingCreditForm(ModelForm): + class Meta: + model = TeachingCredit + fields = '__all__' + widgets = { + 'code': forms.TextInput(attrs={'placeholder': 'Teaching Credit Code (e.g., CS897)', 'max_length': 10}), + 'name': forms.TextInput(attrs={'placeholder': 'Teaching Credit 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_teaching_credit': forms.CheckboxInput(attrs={'class': 'ui checkbox'}), + } + + +class TeachingCreditSlotForm(ModelForm): + class Meta: + model = TeachingCreditSlot + 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'}), + 'teaching_credit_slot_info': forms.Textarea(attrs={'placeholder': 'Enter Information about this Teaching Credit Slot'}), + 'teaching_credits': 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/0044_teachingcredit_teachingcreditslot.py b/FusionIIIT/applications/programme_curriculum/migrations/0044_teachingcredit_teachingcreditslot.py new file mode 100644 index 000000000..2540fd486 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0044_teachingcredit_teachingcreditslot.py @@ -0,0 +1,45 @@ +# Generated by Django 3.1.5 on 2026-07-11 16:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0043_courseauditlog_json_encoder'), + ] + + operations = [ + migrations.CreateModel( + name='TeachingCredit', + 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_teaching_credit', models.BooleanField(default=True)), + ('discipline', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.discipline')), + ], + options={ + 'unique_together': {('code', 'discipline')}, + }, + ), + migrations.CreateModel( + name='TeachingCreditSlot', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('teaching_credit_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')), + ('teaching_credits', models.ManyToManyField(blank=True, to='programme_curriculum.TeachingCredit')), + ], + options={ + 'unique_together': {('semester', 'name')}, + }, + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0045_rename_progress_seminar_to_seminar.py b/FusionIIIT/applications/programme_curriculum/migrations/0045_rename_progress_seminar_to_seminar.py new file mode 100644 index 000000000..6bf9d7cee --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0045_rename_progress_seminar_to_seminar.py @@ -0,0 +1,38 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0044_teachingcredit_teachingcreditslot'), + # academic_procedures 0021 creates FKs to ProgressSeminarSlot/ProgressSeminar + # under their pre-rename names; it must apply before this rename runs, or + # migration state-building fails with a lazy-reference error. + ('academic_procedures', '0021_thesis_registration_models'), + ] + + operations = [ + migrations.RenameModel( + old_name='ProgressSeminar', + new_name='Seminar', + ), + migrations.RenameField( + model_name='seminar', + old_name='working_progress_seminar', + new_name='working_seminar', + ), + migrations.RenameModel( + old_name='ProgressSeminarSlot', + new_name='SeminarSlot', + ), + migrations.RenameField( + model_name='seminarslot', + old_name='progress_seminar_slot_info', + new_name='seminar_slot_info', + ), + migrations.RenameField( + model_name='seminarslot', + old_name='progress_seminars', + new_name='seminars', + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/models.py b/FusionIIIT/applications/programme_curriculum/models.py index 8d2c57d27..5ac542c7f 100644 --- a/FusionIIIT/applications/programme_curriculum/models.py +++ b/FusionIIIT/applications/programme_curriculum/models.py @@ -252,24 +252,46 @@ 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""" - +class Seminar(models.Model): + """Store 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, + max_length=3, choices=[('PG', 'Postgraduate'), ('PHD', 'Doctor of Philosophy')], - null=False, + null=False, blank=False ) - working_progress_seminar = models.BooleanField(default=True) - + working_seminar = models.BooleanField(default=True) + class Meta: unique_together = ('code', 'discipline') - + + def __str__(self): + return f"{self.code} - {self.name} ({self.discipline.acronym})" + + +class TeachingCredit(models.Model): + """Store teaching credit 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_teaching_credit = models.BooleanField(default=True) + + class Meta: + unique_together = ('code', 'discipline') + def __str__(self): return f"{self.code} - {self.name} ({self.discipline.acronym})" @@ -344,14 +366,37 @@ 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""" - +class SeminarSlot(models.Model): + """Store 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) + seminar_slot_info = models.TextField(null=True, blank=True) + seminars = models.ManyToManyField(Seminar, 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 TeachingCreditSlot(models.Model): + """Store teaching credit 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) + teaching_credit_slot_info = models.TextField(null=True, blank=True) + teaching_credits = models.ManyToManyField(TeachingCredit, blank=True) duration = models.PositiveIntegerField(default=1) min_registration_limit = models.PositiveIntegerField(default=0) max_registration_limit = models.PositiveIntegerField(default=1000) diff --git a/FusionIIIT/templates/academic_procedures/academic.html b/FusionIIIT/templates/academic_procedures/academic.html index 8b289a460..320cf1379 100644 --- a/FusionIIIT/templates/academic_procedures/academic.html +++ b/FusionIIIT/templates/academic_procedures/academic.html @@ -123,10 +123,6 @@ Thesis - - Apply for Teaching Credits - - Apply for Assistantship @@ -314,9 +310,6 @@ {% include 'academic_procedures/addThesis.html' %} -
- {% include 'academic_procedures/teaching_credit_register.html' %} -
{% include 'academic_procedures/underconstruction.html' %}
diff --git a/FusionIIIT/templates/academic_procedures/teaching_credit_register.html b/FusionIIIT/templates/academic_procedures/teaching_credit_register.html deleted file mode 100644 index 8a9a844a0..000000000 --- a/FusionIIIT/templates/academic_procedures/teaching_credit_register.html +++ /dev/null @@ -1,142 +0,0 @@ -{% extends 'globals/base.html'%} -{% load static %} -{% block body %} -{% block feedback %} - {% comment %}The tab menu starts here!{% endcomment %} -
- -
-
- - {% comment %}Form Tag starts here!{% endcomment %} -
- {% csrf_token %} -
- - -
-
- - -
- -
-
- - -
-
-
- -
-
- - -
- -
- - -
-
- - -
- - -
- - -
-
- -
-
- - -
- -
- - -
-
- - -
- - -
- -
-
- - - - -{% comment %}Form Tag ends here!{% endcomment %} - - - - - -
- -
-
- -
- -
-
- -{% endblock %} -{% endblock %} -