diff --git a/FusionIIIT/Fusion/settings/common.py b/FusionIIIT/Fusion/settings/common.py index bc97f1548..878c39aea 100644 --- a/FusionIIIT/Fusion/settings/common.py +++ b/FusionIIIT/Fusion/settings/common.py @@ -85,10 +85,20 @@ CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' CELERY_TIMEZONE = 'Asia/Calcutta' + +# Base URL of the React frontend, used to build links (e.g. emailed thesis +# examiner invitation/review links) that must point at frontend pages rather +# than raw backend API endpoints. +FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:5173') + CELERY_BEAT_SCHEDULE = { 'leave-migration-task': { 'task': 'applications.leave.tasks.execute_leave_migrations', 'schedule': crontab(minute='1', hour='0') + }, + 'phd-thesis-review-invitations-task': { + 'task': 'applications.academic_procedures.tasks.process_review_invitations', + 'schedule': crontab(minute='0', hour='2') } } diff --git a/FusionIIIT/Fusion/settings/development.py b/FusionIIIT/Fusion/settings/development.py index 63587a11f..65381434d 100644 --- a/FusionIIIT/Fusion/settings/development.py +++ b/FusionIIIT/Fusion/settings/development.py @@ -6,6 +6,11 @@ ALLOWED_HOSTS = ['*'] +# Local dev has no real SMTP credentials (EMAIL_HOST_PASSWORD is only set in +# production.py from the environment), so outgoing mail is printed to the +# runserver console instead of actually being sent. +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', diff --git a/FusionIIIT/Fusion/urls.py b/FusionIIIT/Fusion/urls.py index e3b3f6792..07490f8b4 100755 --- a/FusionIIIT/Fusion/urls.py +++ b/FusionIIIT/Fusion/urls.py @@ -47,7 +47,7 @@ url(r'^complaint/', include('applications.complaint_system.urls')), url(r'^healthcenter/', include('applications.health_center.urls')), url(r'^leave/', include('applications.leave.urls')), - url(r'^placement/', include('applications.placement_cell.urls')), + url(r'^placement/', include('applications.placement_cell.api.urls')), url(r'^filetracking/', include('applications.filetracking.urls')), url(r'^spacs/', include('applications.scholarships.urls')), url(r'^visitorhostel/', include('applications.visitor_hostel.urls')), diff --git a/FusionIIIT/applications/academic_information/api/views.py b/FusionIIIT/applications/academic_information/api/views.py index 81f6df721..88b14fd87 100644 --- a/FusionIIIT/applications/academic_information/api/views.py +++ b/FusionIIIT/applications/academic_information/api/views.py @@ -30,7 +30,9 @@ from django.views.decorators.csrf import csrf_exempt from django.http import HttpResponse, JsonResponse from django.db.models import Q -from applications.academic_procedures.api.views import role_required +from applications.globals.decorators import role_required +from applications.globals.api.views import resolve_audience_recipients +from notifications.signals import notify from django.core.cache import cache from django.db import connection, transaction @@ -756,7 +758,9 @@ def generate_xlsheet_api(request): if programme_type.upper() == 'UG': sql += " AND s.programme IN ('B.Tech', 'B.Des')" elif programme_type.upper() == 'PG': - sql += " AND s.programme IN ('M.Tech', 'M.Des', 'PhD')" + sql += " AND s.programme IN ('M.Tech', 'M.Des')" + elif programme_type.upper() == 'PHD': + sql += " AND s.programme = 'PhD'" else: sql += " AND s.programme = %s" params.append(programme_type) @@ -1253,10 +1257,28 @@ def list_calendar(request): @authentication_classes([TokenAuthentication]) @role_required(['acadadmin']) def add_calendar(request): - Calendar.objects.create( + audience_type = request.data.get('audience_type', 'all') + calendar_event = Calendar.objects.create( description=request.data.get('description'), from_date=request.data.get('from_date'), to_date=request.data.get('to_date'), + audience_type=audience_type, + target_role_id=request.data.get('target_role'), + target_department_id=request.data.get('target_department'), + target_batch_id=request.data.get('target_batch'), + ) + if audience_type == 'individual': + calendar_event.target_users.set(request.data.get('target_users', [])) + + recipients = resolve_audience_recipients(calendar_event) + notify.send( + sender=request.user, + recipient=recipients, + verb=calendar_event.description, + description=f"{calendar_event.from_date} to {calendar_event.to_date}", + url='', + module='Academic Calendar', + role=calendar_event.target_role.name if audience_type == 'role' and calendar_event.target_role else None, ) return Response({'message': 'Created successfully!'}) diff --git a/FusionIIIT/applications/academic_information/migrations/0002_thesis_registration_models.py b/FusionIIIT/applications/academic_information/migrations/0002_thesis_registration_models.py new file mode 100644 index 000000000..aa4999df1 --- /dev/null +++ b/FusionIIIT/applications/academic_information/migrations/0002_thesis_registration_models.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-06 11:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='student', + name='specialization', + field=models.CharField(choices=[('Power and Control', 'Power and Control'), ('Power & Control', 'Power & Control'), ('Microwave and Communication Engineering', 'Microwave and Communication Engineering'), ('Communication and Signal Processing', 'Communication and Signal Processing'), ('Micro-nano Electronics', 'Micro-nano Electronics'), ('Nanoelectronics and VLSI Design', 'Nanoelectronics and VLSI Design'), ('CAD/CAM', 'CAD/CAM'), ('Design', 'Design'), ('Manufacturing', 'Manufacturing'), ('Manufacturing and Automation', 'Manufacturing and Automation'), ('CSE', 'CSE'), ('AI & ML', 'AI & ML'), ('Data Science', 'Data Science'), ('Mechatronics', 'Mechatronics'), ('MDes', 'MDes'), ('None', 'None'), ('', 'No Specialization')], default='', max_length=40, null=True), + ), + ] diff --git a/FusionIIIT/applications/academic_information/migrations/0003_merge_20260314_1604.py b/FusionIIIT/applications/academic_information/migrations/0003_merge_20260314_1604.py new file mode 100644 index 000000000..28931dbae --- /dev/null +++ b/FusionIIIT/applications/academic_information/migrations/0003_merge_20260314_1604.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-03-14 16:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0002_thesis_registration_models'), + ('academic_information', '0002_auto_20260210_1820'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/academic_information/migrations/0004_auto_20260803_0930.py b/FusionIIIT/applications/academic_information/migrations/0004_auto_20260803_0930.py new file mode 100644 index 000000000..0d63ea8dc --- /dev/null +++ b/FusionIIIT/applications/academic_information/migrations/0004_auto_20260803_0930.py @@ -0,0 +1,43 @@ +# Generated by Django 3.1.5 on 2026-08-03 09:30 + +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), + ('programme_curriculum', '0045_rename_progress_seminar_to_seminar'), + ('globals', '0008_announcement'), + ('academic_information', '0003_merge_20260314_1604'), + ] + + operations = [ + migrations.AddField( + model_name='calendar', + name='audience_type', + field=models.CharField(choices=[('all', 'Everyone'), ('role', 'Specific Role'), ('batch', 'Specific Batch'), ('department', 'Specific Department'), ('individual', 'Specific Individuals')], default='all', max_length=20), + ), + migrations.AddField( + model_name='calendar', + name='target_batch', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='programme_curriculum.batch'), + ), + migrations.AddField( + model_name='calendar', + name='target_department', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='globals.departmentinfo'), + ), + migrations.AddField( + model_name='calendar', + name='target_role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='globals.designation'), + ), + migrations.AddField( + model_name='calendar', + name='target_users', + field=models.ManyToManyField(blank=True, related_name='targeted_calendar_events', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/FusionIIIT/applications/academic_information/migrations/0004_merge_20260805_2216.py b/FusionIIIT/applications/academic_information/migrations/0004_merge_20260805_2216.py new file mode 100644 index 000000000..fa54f801a --- /dev/null +++ b/FusionIIIT/applications/academic_information/migrations/0004_merge_20260805_2216.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-08-05 22:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0003_merge_20260314_1604'), + ('academic_information', '0003_student_section'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/academic_information/migrations/0005_merge_20260804_2335.py b/FusionIIIT/applications/academic_information/migrations/0005_merge_20260804_2335.py new file mode 100644 index 000000000..ea782bdc4 --- /dev/null +++ b/FusionIIIT/applications/academic_information/migrations/0005_merge_20260804_2335.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-08-04 23:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0003_student_section'), + ('academic_information', '0004_auto_20260803_0930'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/academic_information/migrations/0006_merge_20260805_2220.py b/FusionIIIT/applications/academic_information/migrations/0006_merge_20260805_2220.py new file mode 100644 index 000000000..0c72adbee --- /dev/null +++ b/FusionIIIT/applications/academic_information/migrations/0006_merge_20260805_2220.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-08-05 22:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0004_merge_20260805_2216'), + ('academic_information', '0005_merge_20260804_2335'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/academic_information/models.py b/FusionIIIT/applications/academic_information/models.py index 82eae6137..f12d0f8f9 100755 --- a/FusionIIIT/applications/academic_information/models.py +++ b/FusionIIIT/applications/academic_information/models.py @@ -1,6 +1,7 @@ from django.db import models +from django.contrib.auth.models import User -from applications.globals.models import ExtraInfo, Faculty +from applications.globals.models import ExtraInfo, Faculty, Designation, DepartmentInfo from applications.programme_curriculum.models import Batch @@ -282,10 +283,23 @@ def __str__(self): class Calendar(models.Model): - + + AUDIENCE_CHOICES = ( + ('all', 'Everyone'), + ('role', 'Specific Role'), + ('batch', 'Specific Batch'), + ('department', 'Specific Department'), + ('individual', 'Specific Individuals'), + ) + from_date = models.DateField() to_date = models.DateField() description = models.CharField(max_length=40) + audience_type = models.CharField(max_length=20, choices=AUDIENCE_CHOICES, default='all') + target_role = models.ForeignKey(Designation, null=True, blank=True, on_delete=models.SET_NULL) + target_department = models.ForeignKey(DepartmentInfo, null=True, blank=True, on_delete=models.SET_NULL) + target_batch = models.ForeignKey(Batch, null=True, blank=True, on_delete=models.SET_NULL) + target_users = models.ManyToManyField(User, blank=True, related_name='targeted_calendar_events') class Meta: db_table = 'Calendar' diff --git a/FusionIIIT/applications/academic_information/utils.py b/FusionIIIT/applications/academic_information/utils.py index 568f35abf..589c89c57 100644 --- a/FusionIIIT/applications/academic_information/utils.py +++ b/FusionIIIT/applications/academic_information/utils.py @@ -2,7 +2,7 @@ Student_attendance) from ..academic_procedures.models import (BranchChange, CoursesMtech, FinalRegistrations, InitialRegistration, StudentRegistrationChecks, Register, Thesis, FinalRegistration, ThesisTopicProcess, - Constants, FeePayments, TeachingCreditRegistration, SemesterMarks, + Constants, FeePayments, TeachingCreditRegistration, SemesterMarks, MarkSubmissionCheck, Dues,AssistantshipClaim, MTechGraduateSeminarReport, PhDProgressExamination,CourseRequested, course_registration, MessDue, Assistantship_status , backlog_course,) diff --git a/FusionIIIT/applications/academic_procedures/admin.py b/FusionIIIT/applications/academic_procedures/admin.py index b3d30260c..a4d3d9df7 100644 --- a/FusionIIIT/applications/academic_procedures/admin.py +++ b/FusionIIIT/applications/academic_procedures/admin.py @@ -1,14 +1,183 @@ from django.contrib import admin +from django.utils.html import format_html +from django.urls import reverse +from django.utils import timezone from .models import (BranchChange, CoursesMtech, FeePayments, FinalRegistration, InitialRegistration,StudentRegistrationChecks, MinimumCredits, Register, Thesis, CourseRequested, ThesisTopicProcess, FeePayment, TeachingCreditRegistration, - SemesterMarks, MarkSubmissionCheck,Dues,MTechGraduateSeminarReport,PhDProgressExamination,AssistantshipClaim,MessDue,Assistantship_status, course_registration) + SemesterMarks, MarkSubmissionCheck,Dues,MTechGraduateSeminarReport,PhDProgressExamination,AssistantshipClaim,MessDue,Assistantship_status, course_registration, + ThesisSubmission, ReviewInvitation) class RegisterAdmin(admin.ModelAdmin): model = Register search_fields = ('curr_id__course_code',) + +@admin.register(ThesisSubmission) +class ThesisSubmissionAdmin(admin.ModelAdmin): + list_display = ['id', 'get_thesis_title', 'get_student', 'status', 'submitted_at', 'supervisor_approval', 'director_approval'] + list_filter = ['status', 'submitted_at', 'supervisor_approved_at', 'director_approved_at'] + search_fields = ['thesis__research_theme', 'thesis__student__id__user__username', 'thesis__student__id__user__first_name', 'thesis__student__id__user__last_name'] + readonly_fields = ['file_token', 'submitted_at', 'updated_at', 'get_synopsis_link', 'get_report_link'] + date_hierarchy = 'submitted_at' + ordering = ['-submitted_at'] + + fieldsets = ( + ('Basic Information', { + 'fields': ('thesis', 'status', 'file_token') + }), + ('Files', { + 'fields': ('synopsis', 'get_synopsis_link', 'thesis_report', 'get_report_link') + }), + ('Approvals', { + 'fields': ('supervisor', 'supervisor_approved_at', 'director', 'director_approved_at') + }), + ('Timestamps', { + 'fields': ('submitted_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def get_thesis_title(self, obj): + return obj.thesis.research_theme + get_thesis_title.short_description = 'Thesis Title' + get_thesis_title.admin_order_field = 'thesis__research_theme' + + def get_student(self, obj): + if obj.thesis and obj.thesis.student: + return obj.thesis.student.id.user.get_full_name() or obj.thesis.student.id.user.username + return '-' + get_student.short_description = 'Student' + + def supervisor_approval(self, obj): + if obj.supervisor_approved_at: + return format_html('✓ {}', obj.supervisor_approved_at.strftime('%Y-%m-%d')) + return format_html('Pending') + supervisor_approval.short_description = 'Supervisor' + + def director_approval(self, obj): + if obj.director_approved_at: + return format_html('✓ {}', obj.director_approved_at.strftime('%Y-%m-%d')) + return format_html('Pending') + director_approval.short_description = 'Director' + + def get_synopsis_link(self, obj): + if obj.synopsis: + return format_html('View Synopsis', obj.synopsis.url) + return '-' + get_synopsis_link.short_description = 'Synopsis Link' + + def get_report_link(self, obj): + if obj.thesis_report: + return format_html('View Report', obj.thesis_report.url) + return '-' + get_report_link.short_description = 'Report Link' + + +@admin.register(ReviewInvitation) +class ReviewInvitationAdmin(admin.ModelAdmin): + list_display = ['id', 'prof_name', 'prof_email', 'get_thesis', 'status', 'priority', 'last_sent', 'expires_at', 'is_expired_badge'] + list_filter = ['status', 'priority', 'created_at', 'expires_at'] + search_fields = ['prof_name', 'prof_email', 'submission__thesis__research_theme'] + readonly_fields = ['token', 'created_at', 'updated_at', 'get_accept_url', 'get_reject_url', 'get_review_url'] + date_hierarchy = 'created_at' + ordering = ['submission', 'priority'] + actions = ['resend_invitation', 'mark_expired', 'send_review_form'] + + fieldsets = ( + ('Professor Information', { + 'fields': ('prof_name', 'prof_position', 'prof_email', 'prof_phone', 'prof_address', 'prof_time_ranking') + }), + ('Invitation Details', { + 'fields': ('submission', 'priority', 'token', 'status') + }), + ('Action URLs', { + 'fields': ('get_accept_url', 'get_reject_url', 'get_review_url'), + 'classes': ('collapse',) + }), + ('Email Tracking', { + 'fields': ('last_sent', 'review_form_sent', 'expires_at') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def get_thesis(self, obj): + return obj.submission.thesis.research_theme + get_thesis.short_description = 'Thesis' + get_thesis.admin_order_field = 'submission__thesis__research_theme' + + def is_expired_badge(self, obj): + if obj.is_expired(): + return format_html('EXPIRED') + elif obj.expires_at: + days_left = (obj.expires_at - timezone.now()).days + if days_left <= 7: + return format_html('{} days left', days_left) + return format_html('{} days left', days_left) + return '-' + is_expired_badge.short_description = 'Expiry Status' + + def get_accept_url(self, obj): + from django.conf import settings + url = getattr(settings, 'SITE_URL', 'http://localhost:8000') + url += reverse('procedures:invitation_action', args=[obj.token, 'accept']) + return format_html('{}', url, url) + get_accept_url.short_description = 'Accept URL' + + def get_reject_url(self, obj): + from django.conf import settings + url = getattr(settings, 'SITE_URL', 'http://localhost:8000') + url += reverse('procedures:invitation_action', args=[obj.token, 'reject']) + return format_html('{}', url, url) + get_reject_url.short_description = 'Reject URL' + + def get_review_url(self, obj): + from django.conf import settings + url = getattr(settings, 'SITE_URL', 'http://localhost:8000') + url += reverse('procedures:review_detail', args=[obj.token]) + return format_html('{}', url, url) + get_review_url.short_description = 'Review Form URL' + + def resend_invitation(self, request, queryset): + from .utils import send_invitation_email + count = 0 + for inv in queryset: + if inv.status == 'pending' and not inv.is_expired(): + try: + send_invitation_email(inv) + inv.last_sent = timezone.now() + inv.save(update_fields=['last_sent']) + count += 1 + except Exception as e: + self.message_user(request, f"Failed to send to {inv.prof_email}: {str(e)}", level='error') + self.message_user(request, f"Successfully resent {count} invitation(s)") + resend_invitation.short_description = "Resend invitation email to selected reviewers" + + def mark_expired(self, request, queryset): + count = queryset.filter(status='pending').update(status='expired') + self.message_user(request, f"Marked {count} invitation(s) as expired") + mark_expired.short_description = "Mark selected invitations as expired" + + def send_review_form(self, request, queryset): + from .utils import send_review_form_email + count = 0 + for inv in queryset: + if inv.status == 'accepted': + try: + send_review_form_email(inv) + inv.review_form_sent = timezone.now() + inv.save(update_fields=['review_form_sent']) + count += 1 + except Exception as e: + self.message_user(request, f"Failed to send to {inv.prof_email}: {str(e)}", level='error') + self.message_user(request, f"Successfully sent review form to {count} reviewer(s)") + send_review_form.short_description = "Send review form email to accepted reviewers" + + admin.site.register(Thesis) admin.site.register(Register,RegisterAdmin) admin.site.register(BranchChange) @@ -31,3 +200,4 @@ class RegisterAdmin(admin.ModelAdmin): admin.site.register(FinalRegistration) admin.site.register(StudentRegistrationChecks) admin.site.register(course_registration) + diff --git a/FusionIIIT/applications/academic_procedures/api/urls.py b/FusionIIIT/applications/academic_procedures/api/urls.py index f6f068e42..feb01327d 100644 --- a/FusionIIIT/applications/academic_procedures/api/urls.py +++ b/FusionIIIT/applications/academic_procedures/api/urls.py @@ -133,4 +133,314 @@ # pg TA url(r'^ta/stipends/$', views.ta_stipends), + + # ======================================================================== + # PhD-SPECIFIC URLS (Added for PhD student management) + # ======================================================================== + + # PhD Thesis Registration endpoints + + # Student endpoints + url(r'^stu/thesis/$', views.student_thesis_api, name='student-thesis'), + url(r'^stu/thesis/download/$', views.student_download_pdf_api, name='student-thesis-download'), + + # Faculty list for dropdowns + url(r'^faculty/$', views.faculty_list_api, name='faculty-list'), + + # Supervisor endpoints + url(r'^supervisor/dashboard/$', views.supervisor_thesis_topic_dashboard, name='supervisor-dashboard'), + url(r'^supervisor/thesis/(?P\d+)/review/$', views.supervisor_review_api, name='supervisor-thesis-review'), + + # HOD endpoints + url(r'^hod/dashboard/$', views.hod_dashboard, name='hod-dashboard'), + url(r'^hod/thesis/(?P\d+)/review/$', views.hod_review_api, name='hod-thesis-review'), + + # Dean endpoints + url(r'^dean/dashboard/$', views.dean_dashboard, name='dean-dashboard'), + url(r'^dean/thesis/(?P\d+)/review/$', views.dean_review_api, name='dean-thesis-review'), + url(r'^dean/thesis/(?P\d+)/generate/$', views.dean_generate_pdf_api, name='dean-thesis-generate'), + + # PhD Seminar endpoints + + # Student + url(r'^seminar-reports/$', views.list_reports), + url(r'^seminar-reports/create/(?P\d+)/$', views.create_report), + url(r'^seminar-reports/(?P\d+)/$', views.detail_report), + + # RPC + url(r'^seminar-reports/list/$', views.rpc_seminar_list), + url(r'^seminar-reports/(?P\d+)/rpc-detail/$', views.rpc_detail), + url(r'^seminar-reports/(?P\d+)/rpc-consent/$', views.rpc_consent), + url(r'^seminar-reports/(?P\d+)/rpc-finalize/$', views.rpc_finalize), + + # ======================================================================== + # Thesis Slot Semester-Level Registration (Enrollment) + # ======================================================================== + # Student + url(r'^stu/thesis-enrollment/$', views.student_thesis_enrollment_api, name='student-thesis-enrollment'), + # Acad Admin + url(r'^acadadmin/thesis-enrollments/$', views.admin_thesis_enrollment_list, name='admin-thesis-enrollment-list'), + url(r'^acadadmin/thesis-enrollments/verify/$', views.admin_verify_enrollments, name='admin-verify-enrollments'), + url(r'^acadadmin/thesis-enrollments/reject/$', views.admin_reject_enrollments, name='admin-reject-enrollments'), + + # ======================================================================== + # PG Decimal Thesis Grading -- Supervisor Score + Batch-Wide Examiner Panel + # ======================================================================== + # Student (thesis + synopsis submission -- separate from PhD's thesis_submit) + url(r'^stu/pg-thesis-submit/$', views.pg_thesis_submit, name='pg-thesis-submit'), + url(r'^stu/pg-thesis-submission-status/$', views.pg_thesis_submission_status, + name='pg-thesis-submission-status'), + # Supervisor + url(r'^supervisor/thesis-decimal-scores/$', views.supervisor_thesis_decimal_scores, + name='supervisor-thesis-decimal-scores'), + # HOD + url(r'^hod/thesis-examiner-panels/$', views.hod_examiner_panel_dashboard, + name='hod-thesis-examiner-panel-dashboard'), + url(r'^hod/thesis-examiner-panels/submit/$', views.hod_submit_examiner_panel, + name='hod-submit-thesis-examiner-panel'), + # Dean + url(r'^dean/thesis-examiner-panels/$', views.dean_examiner_panel_dashboard, + name='dean-thesis-examiner-panel-dashboard'), + url(r'^dean/thesis-examiner-panels/rank-and-invite/$', views.dean_rank_and_invite_examiner_panel, + name='dean-rank-and-invite-examiner-panel'), + # External examiner (token-authenticated, no Fusion account) + url(r'^thesis-examiner-panel/(?P[0-9a-f-]+)/(?Paccept|reject)/$', + views.examiner_panel_invitation_action, name='examiner-panel-invitation-action'), + url(r'^thesis-examiner-panel/(?P[0-9a-f-]+)/detail/$', + views.examiner_panel_batch_detail, name='examiner-panel-batch-detail'), + url(r'^thesis-examiner-panel/(?P[0-9a-f-]+)/score/$', + views.examiner_panel_submit_score, name='examiner-panel-submit-score'), + + # ======================================================================== + # 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'), + # All blocks comprehensive upload + url(r'^supervisor/thesis-grades-all-template/$', views.supervisor_download_all_thesis_grades_template, name='supervisor-thesis-grades-all-template'), + url(r'^supervisor/thesis-grades-all/upload/$', views.supervisor_upload_all_thesis_grades, name='supervisor-thesis-grades-all-upload'), + url(r'^supervisor/thesis-grades-all/bulk-submit/$', views.supervisor_bulk_submit_all_thesis_grades, name='supervisor-thesis-grades-all-bulk-submit'), + # Acad Admin + url(r'^acadadmin/thesis-grades/$', views.admin_thesis_grades_list, name='admin-thesis-grades-list'), + url(r'^acadadmin/thesis-grades/verify/$', views.admin_verify_thesis_grades, name='admin-thesis-grades-verify'), + url(r'^acadadmin/thesis-grades/announce/$', views.admin_announce_thesis_grades, name='admin-thesis-grades-announce'), + + # PhD Thesis Submission + + # Student + url(r'^thesis/submit/$', views.thesis_submit, name='thesis_submit'), + url(r'^thesis/submission-status/$', views.thesis_submission_status, name='thesis_submission_status'), + + # Supervisor + url(r'^thesis/supervisor-dashboard/$', views.supervisor_dashboard, name='supervisor_dashboard'), + url(r'^thesis/submission-detail/(?P\d+)/$', views.supervisor_submission_detail, name='supervisor_submission_detail'), + url(r'^thesis/supervisor-assign/$', views.supervisor_assign, name='supervisor_assign'), + url(r'^thesis/supervisor-review-reports/$', views.supervisor_review_reports, name='supervisor_review_reports'), + + # Dean (panel approval + invitation dispatch) + url(r'^thesis/dean-dashboard/$', views.dean_panel_dashboard, name='dean_panel_dashboard'), + url(r'^thesis/dean-panel-approve/$', views.dean_panel_approve, name='dean_panel_approve'), + url(r'^thesis/dean-send-invitations/$', views.dean_send_invitations, name='dean_send_invitations'), + + # Director + url(r'^thesis/director-dashboard/$', views.director_dashboard, name='director_dashboard'), + url(r'^thesis/director-approve/$', views.director_approve, name='director_approve'), + + # Professor Invitation (External reviewers) + url(r'^invitation/(?P[0-9a-f-]+)/(?Paccept|reject)/$', + views.invitation_action, name='invitation_action'), + + # Review Form (External reviewers) + url(r'^review/(?P[0-9a-f-]+)/$', views.review_detail, name='review_detail'), + + # Acadadmin (examiner honorarium) + url(r'^thesis/examiner-honorarium/$', views.examiner_honorarium_list, name='examiner_honorarium_list'), + + # ======================================================================== + # PhD Comprehensive Examination + # ======================================================================== + + # Student + url(r'^stu/comprehensive-exam/$', views.student_comprehensive_exam_api, name='student-comprehensive-exam'), + + # 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/attempt/(?P\d+)/set-exam-date/$', + views.supervisor_set_exam_date, name='supervisor-comprehensive-exam-set-exam-date'), + 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 DPGC (HOD of the student's department stands in) + url(r'^hod/comprehensive-exam/dpgc-dashboard/$', + views.hod_dpgc_comprehensive_exam_dashboard, name='hod-dpgc-comprehensive-exam-dashboard'), + url(r'^hod/comprehensive-exam/(?P\d+)/dpgc-approve/$', + views.hod_dpgc_approve_comprehensive_exam, name='hod-dpgc-comprehensive-exam-approve'), + + # RPC (the student's existing committee, fetched live) + url(r'^faculty/comprehensive-exam/rpc/$', + views.rpc_comprehensive_exam_list, name='rpc-comprehensive-exam-list'), + url(r'^faculty/comprehensive-exam/rpc/(?P\d+)/$', + views.rpc_comprehensive_exam_detail, name='rpc-comprehensive-exam-detail'), + url(r'^faculty/comprehensive-exam/rpc/(?P\d+)/consent/$', + views.rpc_comprehensive_exam_consent, name='rpc-comprehensive-exam-consent'), + url(r'^faculty/comprehensive-exam/rpc/(?P\d+)/finalize/$', + views.rpc_comprehensive_exam_finalize, name='rpc-comprehensive-exam-finalize'), + + # Convener PGCS (HOD of the student's department stands in) + url(r'^hod/comprehensive-exam/pgcs-dashboard/$', + views.hod_pgcs_comprehensive_exam_dashboard, name='hod-pgcs-comprehensive-exam-dashboard'), + url(r'^hod/comprehensive-exam/attempt/(?P\d+)/pgcs-review/$', + views.hod_pgcs_review_comprehensive_exam, name='hod-pgcs-comprehensive-exam-review'), + + # Dean Academic (forward-only final approval) + url(r'^dean/comprehensive-exam/dashboard/$', + views.dean_comprehensive_exam_dashboard, name='dean-comprehensive-exam-dashboard'), + url(r'^dean/comprehensive-exam/attempt/(?P\d+)/approve/$', + views.dean_approve_comprehensive_exam, name='dean-comprehensive-exam-approve'), + + # ======================================================================== + # 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/attempt/(?P\d+)/set-seminar-date/$', + views.supervisor_set_seminar_date, name='supervisor-open-seminar-set-seminar-date'), + + # Convener DPGC, early review (HOD of the student's department stands in) + url(r'^hod/open-seminar/dpgc-dashboard/$', + views.hod_dpgc_open_seminar_dashboard, name='hod-dpgc-open-seminar-dashboard'), + url(r'^hod/open-seminar/(?P\d+)/dpgc-review/$', + views.hod_dpgc_review_open_seminar, name='hod-dpgc-open-seminar-review'), + + # RPC (the student's existing committee, fetched live) + url(r'^faculty/open-seminar/rpc/$', + views.rpc_open_seminar_list, name='rpc-open-seminar-list'), + url(r'^faculty/open-seminar/rpc/(?P\d+)/$', + views.rpc_open_seminar_detail, name='rpc-open-seminar-detail'), + url(r'^faculty/open-seminar/rpc/(?P\d+)/consent/$', + views.rpc_open_seminar_consent, name='rpc-open-seminar-consent'), + url(r'^faculty/open-seminar/rpc/(?P\d+)/finalize/$', + views.rpc_open_seminar_finalize, name='rpc-open-seminar-finalize'), + + # Convener DPGC, second review (HOD of the student's department stands in) + url(r'^hod/open-seminar/review-dashboard/$', + views.hod_review_open_seminar_dashboard, name='hod-review-open-seminar-dashboard'), + url(r'^hod/open-seminar/attempt/(?P\d+)/review/$', + views.hod_review_open_seminar, name='hod-review-open-seminar'), + + # Dean Academic (appoints Dean Nominee early; forward-only final approval) + url(r'^dean/open-seminar/dashboard/$', + views.dean_open_seminar_dashboard, name='dean-open-seminar-dashboard'), + url(r'^dean/open-seminar/(?P\d+)/appoint-nominee/$', + views.dean_appoint_nominee_open_seminar, name='dean-open-seminar-appoint-nominee'), + url(r'^dean/open-seminar/attempt/(?P\d+)/approve/$', + views.dean_approve_open_seminar, name='dean-open-seminar-approve'), + + # 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 4278ae9ac..cd691d726 100644 --- a/FusionIIIT/applications/academic_procedures/api/views.py +++ b/FusionIIIT/applications/academic_procedures/api/views.py @@ -2,17 +2,19 @@ import random import logging import traceback +import re from collections import defaultdict, deque, OrderedDict from functools import wraps from datetime import date from django.utils import timezone +from django.conf import settings logger = logging.getLogger(__name__) from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404, redirect -from django.db import transaction +from django.db import transaction, IntegrityError 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 @@ -34,12 +36,14 @@ from rest_framework.parsers import MultiPartParser, FormParser from applications.globals.models import Faculty, HoldsDesignation, Designation, ExtraInfo -from applications.programme_curriculum.models import ( CourseInstructor, CourseSlot, Course as Courses, Batch, Semester) +from applications.globals.decorators import role_required +from notifications.signals import notify +from applications.programme_curriculum.models import ( CourseInstructor, CourseSlot, Course as Courses, Batch, Discipline, Semester) # from applications.programme_curriculum.models import Course 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 @@ -116,37 +120,6 @@ def generate_next_session(current_year, next_semester) : return session, semester_type -def role_required(allowed_roles): - """ - Decorator factory that accepts a list of allowed role names. - Accepts multiple HoldsDesignation records per user. - """ - allowed_lower = {role.lower() for role in allowed_roles} - - def decorator(view_func): - @wraps(view_func) - def _wrapped_view(request, *args, **kwargs): - # Fetch all designations for this user - user_roles = ( - HoldsDesignation.objects - .select_related('designation') - .filter(user=request.user) - .values_list('designation__name', flat=True) # or whichever field holds the string - ) - - # Normalize to lowercase for comparison - user_roles_lower = {r.lower() for r in user_roles} - - # Check intersection - if not (user_roles_lower & allowed_lower): - return Response( - {"error": "Permission denied: one of %s required" % allowed_roles}, - status=status.HTTP_403_FORBIDDEN - ) - - return view_func(request, *args, **kwargs) - return _wrapped_view - return decorator @@ -1204,7 +1177,7 @@ def verify_registration(request): o = FinalRegistration.objects.filter(id= obj.id).update(verified = True, course_instructor = offering) # course_registration.objects.bulk_create(ver_reg) academics_module_notif(request.user, student.id.user, 'registration_approved') - + Student.objects.filter(id = student_id).update(curr_semester_no = sem_no) return JsonResponse({'status': 'success', 'message': 'Successfully Accepted'}) @@ -1324,9 +1297,35 @@ def verify_course(request): # lists for selects (no serializers) course_list = list(Courses.objects.values("id", "code", "name", "credit")) - semester_list = list( - Semester.objects.filter(curriculum=curr).values("id", "semester_no") - ) + + # For PhD students, show only current semester + next semester (no summer terms) + batch_name = student.batch_id.name if student.batch_id else "" + is_phd = batch_name.upper().startswith('PHD') + + if is_phd: + current_sem = student.curr_semester_no + all_semesters = Semester.objects.filter( + curriculum=curr + ).exclude( + semester_no__in=[4, 6, 8, 10, 12] + ).order_by('semester_no') + + phd_semester_list = [] + for sem in all_semesters: + if sem.semester_no == current_sem or sem.semester_no == current_sem + 1: + phd_semester_list.append({ + "id": sem.id, + "semester_no": sem.semester_no + }) + + semester_list = phd_semester_list if phd_semester_list else list( + Semester.objects.filter(curriculum=curr).values("id", "semester_no") + ) + else: + semester_list = list( + Semester.objects.filter(curriculum=curr).values("id", "semester_no") + ) + courseslot_list = list( CourseSlot.objects.filter(semester__in=[s["id"] for s in semester_list]).values("id", "name") ) @@ -2153,6 +2152,7 @@ def allot_courses(request): sheet = book.sheet_by_index(0) checks, pre_regs, final_regs, course_regs = [], [], [], [] + row_errors = [] seen = set() for i in range(1, sheet.nrows): @@ -2199,14 +2199,39 @@ def allot_courses(request): semester_type = sem_type )) except Exception as e: - pass # Handle error silently or log it + row_errors.append({ + "row": i + 1, + "roll_no": str(sheet.cell_value(i, 0)).strip() if sheet.ncols > 0 else "", + "slot": str(sheet.cell_value(i, 1)).strip() if sheet.ncols > 1 else "", + "course_code": str(sheet.cell_value(i, 2)).strip() if sheet.ncols > 2 else "", + "error": str(e) + }) + + inserted = len(course_regs) + if inserted == 0: + return Response( + { + 'error': 'No valid rows were found in the uploaded file.', + 'failed_rows': row_errors[:25], + 'failed_rows_count': len(row_errors) + }, + status=status.HTTP_400_BAD_REQUEST + ) StudentRegistrationChecks.objects.bulk_create(checks, ignore_conflicts=True) InitialRegistration.objects.bulk_create(pre_regs, ignore_conflicts=True) FinalRegistration.objects.bulk_create(final_regs, ignore_conflicts=True) course_registration.objects.bulk_create(course_regs, ignore_conflicts=True) - return Response({'message': 'Successfully uploaded!'}) + if row_errors: + return Response({ + 'message': 'Upload completed with partial success.', + 'inserted_rows': inserted, + 'failed_rows_count': len(row_errors), + 'failed_rows': row_errors[:25] + }, status=status.HTTP_207_MULTI_STATUS) + + return Response({'message': 'Successfully uploaded!', 'inserted_rows': inserted}) except Batch.DoesNotExist: return Response({'error': 'Invalid batch id.'}, status=status.HTTP_400_BAD_REQUEST) except Semester.DoesNotExist: @@ -2233,8 +2258,22 @@ def student_next_sem_courses(request): return Response({"error": "User is not a student"}, status=status.HTTP_403_FORBIDDEN) # 403 Forbidden - DRF style obj = Student.objects.select_related('id', 'id__user', 'id__department').get(id=user_details.id) + + # Check if PhD student - they don't have semester-based courses like UG/PG + is_phd_student = obj.programme and obj.programme.upper() == 'PHD' + if is_phd_student: + return Response({ + "courses_list": [], + "message": "PhD students don't follow semester-based course structure" + }, status=status.HTTP_200_OK) + batch = obj.batch_id + if not batch: + return Response({"error": "Student batch not found"}, status=status.HTTP_404_NOT_FOUND) + curr_id = batch.curriculum + if not curr_id: + return Response({"error": "Curriculum not found for student batch"}, status=status.HTTP_404_NOT_FOUND) try: semester_no = obj.curr_semester_no @@ -2261,6 +2300,16 @@ def course_registration_view(request): user_details = current_user.extrainfo student = Student.objects.get(id=user_details) + # Check if PhD student - they don't have course registrations like UG/PG + is_phd_student = student.programme and student.programme.upper() == 'PHD' + if is_phd_student: + return Response({ + "reg_data": [], + "sem_no": 1, + "semester_type": "Odd Semester", + "message": "PhD students don't follow traditional course registration" + }, status=status.HTTP_200_OK) + semester_no = request.query_params.get('semester', student.curr_semester_no) semester_type = request.query_params.get('semester_type', 'Even Semester' if student.curr_semester_no%2==0 else 'Odd Semester') try: @@ -2562,7 +2611,7 @@ def submit_preregistration(request): old_course_registration_id = prev_registration_id, timestamp=timezone.now() ) - + # Optionally, update the StudentRegistrationChecks record to mark pre-registration as complete. reg_check, created = StudentRegistrationChecks.objects.get_or_create( student_id=student, semester_id_id=next_semester.id, @@ -4076,7 +4125,11 @@ def hod_approve(request, sid): def faculty_assignments(request): # if not check_role(request,'faculty'): # return Response({'error':'role=faculty required'}, status=status.HTTP_403_FORBIDDEN) - qs = Assignment.objects.filter(faculty__id__username='skjain') + try: + faculty = Faculty.objects.get(id=request.user.extrainfo) + qs = Assignment.objects.filter(faculty=faculty) + except Faculty.DoesNotExist: + return Response({'error': 'Faculty profile not found'}, status=status.HTTP_404_NOT_FOUND) data = [{ 'id': a.id, 'ta_username': a.ta.id.user.username, @@ -4092,9 +4145,13 @@ def faculty_assignments(request): def faculty_pending(request): if not check_role(request,'faculty'): return Response({'error':'role=faculty required'}, status=status.HTTP_403_FORBIDDEN) - now = datetime.now() + try: + faculty = Faculty.objects.get(id=request.user.extrainfo) + except Faculty.DoesNotExist: + return Response({'error': 'Faculty profile not found'}, status=status.HTTP_404_NOT_FOUND) + now = datetime.datetime.now() qs = StipendRequest.objects.filter( - assignment__faculty=request.user.faculty, + assignment__faculty=faculty, status=StipendRequest.PENDING ).filter( Q(year__lt=now.year) | @@ -4109,8 +4166,12 @@ def faculty_pending(request): def faculty_approved(request): if not check_role(request,'faculty'): return Response({'error':'role=faculty required'}, status=status.HTTP_403_FORBIDDEN) + try: + faculty = Faculty.objects.get(id=request.user.extrainfo) + except Faculty.DoesNotExist: + return Response({'error': 'Faculty profile not found'}, status=status.HTTP_404_NOT_FOUND) qs = StipendRequest.objects.filter( - assignment__faculty=request.user.faculty, + assignment__faculty=faculty, status=StipendRequest.FAC_APPROVED ) data = [{'id': s.id, 'ta': s.assignment.ta.id.user.username, @@ -4159,7 +4220,7 @@ def registered_slots(request): session, semester_type = generate_current_session(datetime.datetime.now().year, student.curr_semester_no) eligibility_resp = get_replace_registration_eligibility(timezone.now().date(), student.curr_semester_no, datetime.datetime.now().year) if isinstance(eligibility_resp, JsonResponse): - return eligibility_resp + return JsonResponse([], safe=False) # Exclude slots with pending drop requests pending_drop_slots = CourseDropRequest.objects.filter( @@ -5874,11 +5935,24 @@ def apply_promotion(request): continue old_sem = student.curr_semester_no new_sem = old_sem + 1 + + # For PhD students, dynamically create next semester + is_phd = hasattr(student, 'programme') and student.programme == 'PHD' + try: - semester_obj = Semester.objects.get(curriculum=student.batch_id.curriculum,semester_no=new_sem) + semester_obj = Semester.objects.get(curriculum=student.batch_id.curriculum, semester_no=new_sem) except Semester.DoesNotExist: - errors.append({"index": idx, "detail": f"Semester {new_sem} not defined."}) - continue + if is_phd: + # Create the semester for PhD students + semester_obj = Semester.objects.create( + curriculum=student.batch_id.curriculum, + semester_no=new_sem, + semester_name=f"Semester {new_sem}" + ) + # Note: Admin needs to manually add thesis course to this semester via CourseSlot + else: + errors.append({"index": idx, "detail": f"Semester {new_sem} not defined for student {sid}."}) + continue student.curr_semester_no = new_sem student.save() frs = FinalRegistration.objects.filter(student_id=student, verified=False, semester_id = semester_obj) @@ -6040,4 +6114,6607 @@ def apply_demotion(request): # ) # created.append(uname) # status_code = status.HTTP_207_MULTI_STATUS if errors else status.HTTP_201_CREATED -# return JsonResponse({"created": created, "errors": errors}, status=status_code) \ No newline at end of file +# return JsonResponse({"created": created, "errors": errors}, status=status_code) + + +# ============================================================================ +# PhD-SPECIFIC VIEW FUNCTIONS (Added for PhD student management) +# ============================================================================ +# These functions handle PhD-specific workflows like thesis registration, +# seminar reports, RPC committees, and external review invitations. +# They are separate from UG/PG functions to maintain production stability. +# ============================================================================ + +def thesis_to_dict(t): + """Serialize a ThesisTopic instance for JSON responses.""" + try: + programme_category = t.student.batch_id.curriculum.programme.category + except AttributeError: + programme_category = None + return { + "id": t.id, + "student_roll": t.student.id.id, + "student_name": t.student.id.user.get_full_name(), + "student_discipline": t.student.specialization, + "programme_category": programme_category, + "category": t.category, + "broad_area": t.broad_area, + "research_theme": t.research_theme, + "supervisor": {"id": t.supervisor.id.id, "name": str(t.supervisor), "discipline": (t.supervisor.id.department.name if t.supervisor.id.department else "")}, + "co_supervisor": ( + {"id": t.co_supervisor.id.id, "name": str(t.co_supervisor), "discipline": (t.co_supervisor.id.department.name if t.co_supervisor.id.department else "")} + if t.co_supervisor else None + ), + "supervisor_consented": t.supervisor_consented, + "co_supervisor_consented": t.co_supervisor_consented, + "external": { + "ext_name": t.external_name, + "ext_email": t.external_email, + "ext_discipline": t.external_discipline, + "ext_institution": t.external_institution, + }, + "load": { + "pg_single": t.pg_single, + "pg_shared": t.pg_shared, + "phd_single": t.phd_single, + "phd_shared": t.phd_shared, + }, + "committee": [ + { + "id": cm.member.id.id, + "name": str(cm.member), + "discipline": (cm.member.id.department.name if cm.member.id.department else ""), + } + for cm in CommitteeMember.objects.filter(thesis = t).all() + ], + "status": t.status, + "hod_remarks": t.hod_remarks, + "dean_remarks" : t.dean_remarks + } + + +# 1. Student APIs + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def student_thesis_api(request): + """ + GET /stu/thesis/ → fetch ({} if none) + POST /stu/thesis/ → create/update when status == supervisor_pending or new + """ + user = request.user + try: + user_details = user.extrainfo + student = Student.objects.get(id=user_details) + except Student.DoesNotExist: + return JsonResponse({"error": "Student record not found"}, status=404) + except Exception as e: + return JsonResponse({"error": f"User setup error: {type(e).__name__}: {e}"}, status=400) + + try: + thesis = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + + if request.method == 'GET': + return JsonResponse(thesis_to_dict(thesis) if thesis else {}, status=200) + except Exception as e: + return JsonResponse({"error": f"Internal error: {type(e).__name__}: {e}"}, status=500) + + # POST: only if no thesis yet or status is supervisor_pending + if thesis and thesis.status != 'supervisor_pending': + return JsonResponse( + {"error": "Cannot edit once under review past supervisor."}, + status=403 + ) + + data = request.data + required = ['supervisor_id', 'category', 'broad_area', 'research_theme'] + missing = [f for f in required if not data.get(f)] + if missing: + return JsonResponse({"error": f"Missing required field(s): {', '.join(missing)}"}, status=400) + if not thesis: + thesis = ThesisTopic(student=student) + + supervisor_id = data.get('supervisor_id') + co_supervisor_id = data.get('co_supervisor_id') + + if co_supervisor_id and co_supervisor_id == supervisor_id: + return JsonResponse({"error": "Co-supervisor must be different from supervisor"}, status=400) + + try: + supervisor = Faculty.objects.get(pk=supervisor_id) + except Faculty.DoesNotExist: + return JsonResponse({"error": "Invalid supervisor"}, status=400) + if not supervisor.id.user.is_active: + return JsonResponse({"error": "Selected supervisor is not an active faculty member"}, status=400) + + if co_supervisor_id: + try: + co_supervisor = Faculty.objects.get(pk=co_supervisor_id) + except Faculty.DoesNotExist: + return JsonResponse({"error": "Invalid co-supervisor"}, status=400) + if not co_supervisor.id.user.is_active: + return JsonResponse({"error": "Selected co-supervisor is not an active faculty member"}, status=400) + + thesis.category = data.get('category') + thesis.broad_area = data.get('broad_area') + thesis.research_theme = data.get('research_theme') + thesis.supervisor_id = supervisor_id + thesis.co_supervisor_id = co_supervisor_id + thesis.external_name = data.get('external_name', '') + thesis.external_email = data.get('external_email', '') + thesis.external_discipline = data.get('external_discipline', '') + thesis.external_institution= data.get('external_institution', '') + thesis.status = 'supervisor_pending' + # Any edit invalidates consents already given (e.g. supervisor consented + # while co-supervisor consent is still pending) -- otherwise a changed + # supervisor/topic could ride through on a stale consent. + thesis.supervisor_consented = False + thesis.co_supervisor_consented = False + thesis.save() + + _thesis_notify( + sender=user, + recipient=supervisor.id.user, + verb='New thesis topic proposal awaiting your review', + description=f"{student.id.user.get_full_name()} has submitted a thesis topic proposal for your review.", + ) + if co_supervisor_id: + _thesis_notify( + sender=user, + recipient=co_supervisor.id.user, + verb='New thesis topic proposal awaiting your review', + description=f"{student.id.user.get_full_name()} has submitted a thesis topic proposal naming you as co-supervisor.", + ) + + return JsonResponse(thesis_to_dict(thesis), status=201) + + +from reportlab.lib.pagesizes import A4 +from reportlab.lib import colors +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import mm +from reportlab.platypus import ( + SimpleDocTemplate, Paragraph, Spacer, + Table, TableStyle, Image +) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def student_download_pdf_api(request): + thesis = get_object_or_404(ThesisTopic, student__id=request.user.extrainfo) + + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + leftMargin=15 * mm, + rightMargin=15 * mm, + topMargin=20 * mm, + bottomMargin=15 * mm + ) + + styles = getSampleStyleSheet() + normal = styles['Normal'] + bold = ParagraphStyle('Bold', parent=normal, fontName='Helvetica-Bold') + elements = [] + + # Header + logo = Image('./media/logo2.jpg', width=25 * mm, height=25 * mm) + college_name = Paragraph( + 'Indian Institute of Information Technology, Design and Manufacturing, Jabalpur
', + ParagraphStyle('Header', parent=styles['Title'], alignment=1) + ) + header_tbl = Table([[logo, college_name]], colWidths=[30 * mm, 150 * mm]) + header_tbl.setStyle(TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('ALIGN', (1, 0), (1, 0), 'CENTER'), + ('LEFTPADDING', (0, 0), (-1, -1), 0), + ('RIGHTPADDING', (0, 0), (-1, -1), 0), + ])) + elements.extend([header_tbl, Spacer(1, 12)]) + elements.extend([Paragraph('Thesis Topic Submission Form', styles['Heading2']), Spacer(1, 20)]) + + # Form fields data + data = [ + [Paragraph('Roll Number:', bold), thesis.student.id.id], + [Paragraph('Student Name:', bold), thesis.student.id.user.get_full_name()], + [Paragraph('Discipline:', bold), thesis.student.specialization], + [Paragraph('Category:', bold), thesis.category], + [Paragraph('Broad Area:', bold), thesis.broad_area], + [Paragraph('Research Theme:', bold), + Paragraph(thesis.research_theme.replace('\n', '
'), normal)], + [Paragraph('Supervisor:', bold), thesis.supervisor.id.user.get_full_name()], + ] + if thesis.co_supervisor: + data.append([Paragraph('Co-Supervisor:', bold), thesis.co_supervisor.id.user.get_full_name()]) + if thesis.external_name: + data.extend([ + [Paragraph('External Supervisor:', bold), thesis.external_name], + [Paragraph('Email:', bold), thesis.external_email], + [Paragraph('Discipline:', bold), thesis.external_discipline], + [Paragraph('Institution:', bold), thesis.external_institution], + ]) + + # Create the form table with increased row heights + form_tbl = Table( + data, + colWidths=[55 * mm, 125 * mm], + rowHeights=[13 * mm] * len(data) # each row is 15 mm tall + ) + form_tbl.setStyle(TableStyle([ + ('GRID', (0, 0), (-1, -1), 0.4, colors.grey), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 8), + ('RIGHTPADDING', (0, 0), (-1, -1), 8), + ('TOPPADDING', (0, 0), (-1, -1), 10), # extra breathing room + ('BOTTOMPADDING', (0, 0), (-1, -1), 10), + ('BACKGROUND', (0, 0), (0, -1), colors.whitesmoke), + ])) + elements.extend([form_tbl, Spacer(1, 40)]) + + # Signatures: two per row + sig_line = '__________ Date: _______' + row1 = [ + Paragraph('Supervisor Sig.:', bold), sig_line, + Paragraph('Co-Supervisor Sig.:', bold) if thesis.co_supervisor else '', + sig_line if thesis.co_supervisor else '' + ] + sig_tbl = Table([row1], colWidths=[30 * mm, 60 * mm, 30 * mm, 60 * mm]) + sig_tbl.setStyle(TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'BOTTOM'), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + elements.append(sig_tbl) + + # Build and return PDF + doc.build(elements) + buffer.seek(0) + return HttpResponse(buffer, content_type='application/pdf') +# 2. Faculty list for dropdowns + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def faculty_list_api(request): + """ + GET /faculty/ → all faculty {id, name, discipline} + """ + qs = Faculty.objects.select_related('id__user', 'id__department') + data = [] + for f in qs: + user = f.id.user + dept = f.id.department + data.append({ + 'id': f.id.id, + 'name': f"{user.first_name} {user.last_name}", + 'discipline': dept.name if dept else '', + }) + return JsonResponse(data, safe=False) + + +# 3. Supervisor endpoints +from django.db import models + +from django.db.models import Q +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from django.http import JsonResponse + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_thesis_topic_dashboard(request): + """ + GET /supervisor/dashboard/ + → returns { pending, forwarded } + for any thesis where request.user is either supervisor OR co_supervisor. + """ + ex = request.user + + qs = ThesisTopic.objects.filter( + Q(supervisor__id=ex.username) | Q(co_supervisor__id=ex.username) + ) + + 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': [serialize_for_viewer(t) for t in pending_qs], + 'forwarded': [serialize_for_viewer(t) for t in forwarded_qs], + }) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def supervisor_review_api(request, pk): + thesis = get_object_or_404(ThesisTopic, pk=pk) + user_ex = request.user.username + is_sup = (thesis.supervisor_id == user_ex) + is_co = (thesis.co_supervisor and thesis.co_supervisor_id == user_ex) + + if request.method == 'GET': + if not (is_sup or is_co): + return JsonResponse({"error": "Access denied."}, status=403) + payload = thesis_to_dict(thesis) + payload.update({"is_supervisor": is_sup, "is_co_supervisor": is_co}) + return JsonResponse(payload, status=200) + + if thesis.status != 'supervisor_pending' and thesis.status != 'hod_rejected': + return JsonResponse({"error": "Cannot review at this stage."}, status=403) + + data = request.data + + if 'research_theme' in data and data['research_theme'] != thesis.research_theme: + thesis.research_theme = data['research_theme'] + # Content changed -- any consent already given (e.g. supervisor + # consented while co-supervisor's is still pending) no longer + # reflects what's being approved, so it must be re-given. + thesis.supervisor_consented = False + thesis.co_supervisor_consented = False + + if is_co and not is_sup: + if thesis.co_supervisor_consented: + return JsonResponse({"error": "Already consented."}, status=400) + if data.get('co_supervisor_consented'): + thesis.co_supervisor_consented = True + thesis.save() + _thesis_notify( + sender=request.user, + recipient=thesis.supervisor.id.user, + verb='Co-supervisor has consented to the thesis topic', + description=f"{thesis.co_supervisor.id.user.get_full_name()} has consented to " + f"{thesis.student.id.user.get_full_name()}'s thesis topic.", + ) + return JsonResponse({"message": "Co-Supervisor consent recorded."}, status=200) + return JsonResponse({"error": "Invalid consent payload."}, status=400) + + if is_sup: + + if not (thesis.supervisor_consented and + (not thesis.co_supervisor or thesis.co_supervisor_consented)): + + # Committee/PG-load edits change what's being approved -- a + # co-supervisor consent already given against the old values + # must not silently carry over. + thesis.co_supervisor_consented = False + + thesis.pg_single = data.get('pg_single', thesis.pg_single) + thesis.pg_shared = data.get('pg_shared', thesis.pg_shared) + thesis.phd_single = data.get('phd_single', thesis.phd_single) + thesis.phd_shared = data.get('phd_shared', thesis.phd_shared) + + # This committee doubles as the live RPC for Comprehensive Exam / + # Open Seminar (_exam_rpc_committee). Editing membership while an + # attempt is actively rpc_pending would let a member be dropped + # (or dropped-then-re-added) mid-review, letting finalize succeed + # without their consent or silently reusing a stale one -- so + # membership is frozen until that review reaches a decision. + if ComprehensiveExamAttempt.objects.filter(exam__student=thesis.student, status='rpc_pending').exists() or \ + OpenSeminarAttempt.objects.filter(open_seminar__student=thesis.student, status='rpc_pending').exists(): + return JsonResponse( + {"error": "Cannot edit committee while a Comprehensive Exam or Open Seminar is awaiting RPC consent."}, + status=403 + ) + + CommitteeMember.objects.filter(thesis=thesis).delete() + for member_id in data.get('committee', []): + CommitteeMember.objects.create(thesis=thesis, member_id=member_id) + + CommitteeMember.objects.get_or_create(thesis=thesis, member_id=thesis.supervisor_id) + if thesis.co_supervisor_id: + CommitteeMember.objects.get_or_create(thesis=thesis, member_id=thesis.co_supervisor_id) + + if not thesis.supervisor_consented and data.get('supervisor_consented'): + thesis.supervisor_consented = True + + sup_ok = thesis.supervisor_consented + co_ok = (not thesis.co_supervisor) or thesis.co_supervisor_consented + + if sup_ok and co_ok: + total_rpc = CommitteeMember.objects.filter(thesis=thesis).count() + if total_rpc < 3: + return JsonResponse( + {"error": "Need at least 3 RPC members (including supervisor/co-supervisor)."}, + status=400 + ) + thesis.status = 'hod_pending' + thesis.save() + student_discipline = ( + thesis.student.batch_id.discipline.acronym + if thesis.student.batch_id and thesis.student.batch_id.discipline else None + ) + _thesis_notify( + sender=request.user, + recipient=_hod_users_for_discipline(student_discipline), + verb='Thesis topic pending your review', + description=f"{thesis.student.id.user.get_full_name()}'s thesis topic has been " + f"forwarded by the supervisor and co-supervisor for your review.", + ) + return JsonResponse( + {"message": "Forwarded to HOD successfully.", "status": thesis.status}, + status=200 + ) + + thesis.save() + return JsonResponse( + {"message": "Supervisor changes saved; awaiting all consents and RPC ≥ 3."}, + status=200 + ) + + return JsonResponse({"error": "Not authorized."}, status=403) + + +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() + + +def _users_holding_designation(designation_name): + """Users currently acting in the given designation (via HoldsDesignation.working + -- the field documented as the correct one for permissions/current-holder lookups, + covering officiating/temporary holders too, not just the permanent one).""" + return User.objects.filter( + current_designation__designation__name=designation_name + ).distinct() + + +def _hod_users_for_discipline(discipline_acronym): + """Users currently acting as HOD for one specific discipline acronym (e.g. 'CSE').""" + if not discipline_acronym: + return User.objects.none() + return _users_holding_designation(f"HOD ({discipline_acronym})") + + +def _dean_academic_users(): + return _users_holding_designation('Dean Academic') + + +def _notify(sender, recipient, verb, description='', module=''): + """Thin wrapper around notify.send for PhD workflow notifications. + `recipient` may be a single User, None, or a queryset/list of Users -- + falsy/empty recipients are silently skipped rather than erroring.""" + if recipient is None: + return + if hasattr(recipient, 'exists') and not recipient.exists(): + return + notify.send( + sender=sender, + recipient=recipient, + verb=verb, + description=description, + url='', + module=module, + ) + + +def _thesis_notify(sender, recipient, verb, description=''): + _notify(sender, recipient, verb, description, module='Thesis Topic') + + +def _comprehensive_exam_notify(sender, recipient, verb, description=''): + _notify(sender, recipient, verb, description, module='Comprehensive Exam') + + +def _open_seminar_notify(sender, recipient, verb, description=''): + _notify(sender, recipient, verb, description, module='Open Seminar') + + +def _teaching_credit_notify(sender, recipient, verb, description=''): + _notify(sender, recipient, verb, description, module='Teaching Credit') + + +def _progress_seminar_notify(sender, recipient, verb, description=''): + _notify(sender, recipient, verb, description, module='Progress Seminar') + + +def _phd_course_registration_notify(sender, recipient, verb, description=''): + _notify(sender, recipient, verb, description, module='PhD Course Registration') + + +def _academic_office_users(): + return _users_holding_designation('acadadmin') + + +# 4. HOD endpoints + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def hod_dashboard(request): + """ + GET /hod/dashboard/ → { pending, approved, rejected } + filtered by HOD designation held by the user. + + - pending statuses: ['dean_rejected', 'hod_pending'] + - approved statuses: ['hod_approved', 'dean_approved'] + - rejected statuses: ['hod_rejected'] + """ + user = request.user + data = {'pending': [], 'approved': [], 'rejected': []} + + STATUS_PENDING = ['dean_rejected', 'hod_pending'] + STATUS_APPROVED = ['hod_approved', 'dean_approved'] + STATUS_REJECTED = ['hod_rejected'] + all_statuses = STATUS_PENDING + STATUS_APPROVED + STATUS_REJECTED + + # Get HOD designations for this user + hod_disciplines = get_hod_disciplines(user) + + qs = ThesisTopic.objects.filter(status__in=all_statuses).select_related('student', 'student__batch_id', 'student__batch_id__discipline') + + for thesis in qs: + # Check if thesis student's discipline matches HOD's discipline + # Use discipline acronym (e.g., "CSE") to match with HOD designation (e.g., "HOD (CSE)") + student_discipline_acronym = None + if thesis.student.batch_id and thesis.student.batch_id.discipline: + student_discipline_acronym = thesis.student.batch_id.discipline.acronym + + if not student_discipline_acronym or student_discipline_acronym not in hod_disciplines: + continue + + dto = thesis_to_dict(thesis) + + if thesis.status in STATUS_PENDING: + data['pending'].append(dto) + elif thesis.status in STATUS_APPROVED: + data['approved'].append(dto) + else: # thesis.status in STATUS_REJECTED + data['rejected'].append(dto) + + return JsonResponse(data) + + +@api_view(['GET','POST']) +@permission_classes([IsAuthenticated]) +def hod_review_api(request, pk): + thesis = get_object_or_404(ThesisTopic, pk=pk) + user = request.user + + # Check if user is HOD for the student's discipline + student_discipline_acronym = None + if thesis.student.batch_id and thesis.student.batch_id.discipline: + student_discipline_acronym = thesis.student.batch_id.discipline.acronym + + is_hod = is_hod_of_discipline(user, student_discipline_acronym) + + if request.method == 'GET': + if not is_hod: + return JsonResponse({"error": "Access denied."}, status=403) + data = thesis_to_dict(thesis) + return JsonResponse(data, status=200) + + # POST + if not is_hod or thesis.status not in ['hod_pending','hod_rejected','dean_pending','dean_rejected']: + return JsonResponse({"error":"Forbidden or invalid stage"}, status=403) + + d = request.data + if d.get('approve'): + thesis.status = 'hod_approved' + thesis.hod_remarks = '' + thesis.save() + _thesis_notify( + sender=user, + recipient=_dean_academic_users(), + verb='Thesis topic pending your final approval', + description=f"{thesis.student.id.user.get_full_name()}'s thesis topic has been " + f"approved by the HOD and is awaiting your final approval.", + ) + for recipient in filter(None, [thesis.student.id.user, thesis.supervisor.id.user]): + _thesis_notify( + sender=user, + recipient=recipient, + verb='Thesis topic approved by HOD', + description=f"{thesis.student.id.user.get_full_name()}'s thesis topic has been " + f"approved by the HOD and forwarded to Dean Academic.", + ) + else: + thesis.status = 'hod_rejected' + thesis.hod_remarks = d.get('remarks','') + thesis.supervisor_consented = False + thesis.co_supervisor_consented = False + thesis.dean_remarks = '' + thesis.save() + for recipient in filter(None, [thesis.supervisor.id.user, thesis.student.id.user]): + _thesis_notify( + sender=user, + recipient=recipient, + verb='Thesis topic rejected by HOD', + description=f"The HOD rejected {thesis.student.id.user.get_full_name()}'s thesis " + f"topic. Remarks: {thesis.hod_remarks or '—'}", + ) + + return JsonResponse({"status":thesis.status}, status=200) + + +# 5. Dean endpoints + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_dashboard(request): + """ + GET /dean/dashboard/ → {pending, approved} + for theses with status in dean_pending/dean_approved. + """ + data = {'pending': [], 'approved': [], 'rejected':[]} + qs = ThesisTopic.objects.filter(status__in=['dean_pending','dean_approved', 'hod_approved']) + for t in qs: + dto = thesis_to_dict(t) + bucket = 'pending' if t.status=='dean_pending' or t.status=='hod_approved' else \ + 'approved' if t.status=='dean_approved' else 'rejected' + data[bucket].append(dto) + return JsonResponse(data) + + +@api_view(['GET','POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_review_api(request, pk): + thesis = get_object_or_404(ThesisTopic, pk=pk) + if request.method == 'GET': + data = thesis_to_dict(thesis) + return JsonResponse(data, status=200) + + # POST + if thesis.status not in ['dean_pending','hod_approved']: + return JsonResponse({"error":"Forbidden or invalid stage"}, status=403) + + d = request.data + student_discipline = ( + thesis.student.batch_id.discipline.acronym + if thesis.student.batch_id and thesis.student.batch_id.discipline else None + ) + + if d.get('approve'): + thesis.status = 'dean_approved' + thesis.dean_remarks = '' + thesis.save() + recipients = [thesis.student.id.user, thesis.supervisor.id.user] + if thesis.co_supervisor: + recipients.append(thesis.co_supervisor.id.user) + for recipient in recipients: + _thesis_notify( + sender=request.user, + recipient=recipient, + verb='Thesis topic approved by Dean Academic', + description=f"{thesis.student.id.user.get_full_name()}'s thesis topic has " + f"received final approval from Dean Academic.", + ) + _thesis_notify( + sender=request.user, + recipient=_hod_users_for_discipline(student_discipline), + verb='Thesis topic approved by Dean Academic', + description=f"{thesis.student.id.user.get_full_name()}'s thesis topic has " + f"received final approval from Dean Academic.", + ) + else: + thesis.status = 'dean_rejected' + thesis.dean_remarks = d.get('remarks','') + thesis.save() + _thesis_notify( + sender=request.user, + recipient=_hod_users_for_discipline(student_discipline), + verb='Thesis topic rejected by Dean Academic', + description=f"Dean Academic rejected {thesis.student.id.user.get_full_name()}'s " + f"thesis topic. Remarks: {thesis.dean_remarks or '—'}", + ) + + return JsonResponse({"status":thesis.status}, status=200) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_generate_pdf_api(request, pk): + thesis = get_object_or_404(ThesisTopic, pk=pk) + if thesis.status != 'dean_approved': + return HttpResponse({"error": "Not fully approved"}, status=403) + + buffer = BytesIO() + student_roll = thesis.student.id.id.replace(' ', '_') + filename = f"approved_thesis_{student_roll}.pdf" + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + leftMargin=15 * mm, + rightMargin=15 * mm, + topMargin=15 * mm, + bottomMargin=10 * mm, + title=filename, + ) + + styles = getSampleStyleSheet() + normal = styles['Normal'] + normal.fontSize = 9 + normal.leading = 11 + bold = ParagraphStyle('Bold', parent=normal, fontName='Helvetica-Bold', fontSize=9) + title_center = ParagraphStyle('TitleCenter', parent=styles['Title'], alignment=1, fontSize=12) + heading2 = ParagraphStyle('H2', parent=styles['Heading2'], fontSize=11, spaceAfter=6) + heading3 = ParagraphStyle('H3', parent=styles['Heading3'], fontSize=10, spaceAfter=4) + + elements = [] + + # Header + logo = Image('./media/logo2.jpg', width=22*mm, height=22*mm) + institute = Paragraph( + 'Indian Institute of Information Technology, Design and Manufacturing, Jabalpur', + title_center + ) + header = Table([[logo, institute]], colWidths=[28*mm, 152*mm]) + header.setStyle(TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('ALIGN', (1, 0), (1, 0), 'CENTER'), + ('LEFTPADDING', (0, 0), (-1, -1), 0), + ('RIGHTPADDING',(0, 0), (-1, -1), 0), + ])) + elements += [header, Spacer(1, 8)] + elements += [Paragraph('Thesis Approval Summary', heading2), Spacer(1, 6)] + + # Form fields + form_data = [ + ['Roll Number', thesis.student.id.id], + ['Student Name', thesis.student.id.user.get_full_name()], + ['Discipline', thesis.student.specialization], + ['Category', thesis.category], + ['Broad Area', thesis.broad_area], + ] + # research theme as separate row with wrapping + form_data.append(['Research Theme', + Paragraph(thesis.research_theme.replace('\n', '
'), normal)]) + if thesis.co_supervisor: + form_data.append(['Co-Supervisor', thesis.co_supervisor.id.user.get_full_name()]) + if thesis.external_name: + form_data += [ + ['External Supervisor', thesis.external_name], + ['Email', thesis.external_email], + ['External Discipline', thesis.external_discipline], + ['Institution', thesis.external_institution], + ] + + # All tables same total width: use available width = 160mm + total_width = 160 * mm + col1 = 50 * mm + col2 = total_width - col1 + + # Form table with reduced row height + form_tbl = Table(form_data, colWidths=[col1, col2], rowHeights=[8*mm]*len(form_data)) + form_tbl.setStyle(TableStyle([ + ('GRID', (0, 0), (-1, -1), 0.4, colors.grey), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('BACKGROUND', (0, 0), (0, -1), colors.whitesmoke), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 3), + ('BOTTOMPADDING',(0, 0), (-1, -1), 3), + ('FONTSIZE', (0, 0), (-1, -1), 9), + ])) + elements += [form_tbl, Spacer(1, 8)] + + # Supervision Load + load_data = [ + ['Category', 'Single', 'Shared'], + ['PG', str(thesis.pg_single), str(thesis.pg_shared)], + ['PhD', str(thesis.phd_single), str(thesis.phd_shared)], + ] + load_tbl = Table(load_data, colWidths=[col1, (total_width-col1)/2, (total_width-col1)/2], rowHeights=[7*mm]*3) + load_tbl.setStyle(TableStyle([ + ('GRID', (0, 0), (-1, -1), 0.4, colors.grey), + ('BACKGROUND', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (1, 1), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('LEFTPADDING',(0,0),(-1,-1),3),('RIGHTPADDING',(0,0),(-1,-1),3), + ('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),2), + ('FONTSIZE', (0, 0), (-1, -1), 9), + ])) + elements += [Paragraph('Supervision Load', heading3), load_tbl, Spacer(1, 8)] + + # Committee + comm = [['Member', 'Discipline']] + for cm in thesis.committee.all(): + comm.append([cm.member.id.user.get_full_name(), cm.member.id.department.name or '']) + comm_tbl = Table(comm, colWidths=[col1, col2], rowHeights=[7*mm]*len(comm)) + comm_tbl.setStyle(TableStyle([ + ('GRID', (0, 0), (-1, -1), 0.4, colors.grey), + ('BACKGROUND',(0, 0), (-1, 0), colors.whitesmoke), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('LEFTPADDING',(0,0),(-1,-1),3),('RIGHTPADDING',(0,0),(-1,-1),3), + ('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),2), + ('FONTSIZE', (0, 0), (-1, -1), 9), + ])) + elements += [Paragraph('RPC Committee Members', heading3), comm_tbl, Spacer(1, 8)] + + # Signatures: one per line (compact but readable) + sig_labels = [ + ('Supervisor Signature:', 'Date:'), + ('Co-Supervisor Signature:', 'Date:') if thesis.co_supervisor else None, + ('HOD Signature:', 'Date:'), + ('Dean Signature:', 'Date:') + ] + # Ensure all signature rows have equal, large vertical spacing + # Use moderate spacing and group all signature rows to avoid page break + sig_row_space = 18 # mm, balanced for single page + sig_tables = [] + for label_pair in sig_labels: + if label_pair: + label = label_pair[0] + sig_tbl = Table( + [[ + Paragraph(f'{label}', bold), + '__________________________', + Paragraph(f'{label_pair[1]}', bold), + '_______________' + ]], + colWidths=[45*mm, 55*mm, 15*mm, 45*mm], + rowHeights=[12*mm] # force equal height for all signature rows + ) + sig_tbl.setStyle(TableStyle([ + ('VALIGN', (0, 0), (-1, -1), 'BOTTOM'), + ('LEFTPADDING',(0,0),(-1,-1),2), + ('RIGHTPADDING',(0,0),(-1,-1),2), + # Remove any background for all rows + ])) + sig_tables += [sig_tbl, Spacer(1, sig_row_space)] + # Use KeepTogether to prevent page break in signature block + from reportlab.platypus import KeepTogether + elements.append(KeepTogether(sig_tables)) + + doc.build(elements) + buffer.seek(0) + + # Set proper filename without spaces + buffer.seek(0) + response = HttpResponse(buffer.getvalue(), content_type='application/pdf') + response['Content-Disposition'] = ( + f"attachment; filename=\"{filename}\"; filename*=UTF-8''{filename}" + ) + return response + + +# Seminar Views +# 1. STUDENT + +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 = [] + 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(), + }) + + return JsonResponse(data, safe=False) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def create_report(request, thesis_pk): + thesis = get_object_or_404(ThesisTopic, pk=thesis_pk, student_id=request.user.username) + if thesis.status != 'dean_approved': + return JsonResponse({"error":"Thesis not Dean-approved."}, status=403) + + if thesis.seminars.filter(status='rpc_pending').exists(): + return JsonResponse( + {"error": "A previous seminar report is still awaiting RPC consent."}, + status=403, + ) + + # versioning + last = thesis.seminars.order_by('-version').first() + version = (last.version + 1) if last else 1 + + student = thesis.student + try: + semester = Semester.objects.get( + curriculum=student.batch_id.curriculum, + semester_no=student.curr_semester_no, + ) + except Semester.DoesNotExist: + semester = None + + if semester is None or not ProgressSeminarRegistration.objects.filter( + student=student, semester=semester, status='verified', + ).exists(): + return JsonResponse( + {"error": "No verified Progress Seminar registration for the current semester."}, + status=403, + ) + + 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, + seminar_venue = request.data.get('venue',''), + summary_prev = request.data.get('prev',''), + summary_curr = request.data.get('curr',''), + future_plan = request.data.get('future',''), + upload_doc = request.FILES.get('doc', None), + 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), + ) + + _progress_seminar_notify( + sender=request.user, + recipient=_rpc_committee_users(student), + verb='Progress Seminar report pending your consent', + description=f"{student.id.user.get_full_name()} has submitted a Progress Seminar " + f"report (version {version}) awaiting RPC consent.", + ) + + return JsonResponse({ + "id": seminar.id, + "message": "Seminar submitted; awaiting RPC consent." + }, status=201) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def detail_report(request, pk): + s = get_object_or_404(ProgressSeminarEntry, pk=pk, thesis__student_id=request.user.username) + return JsonResponse({ + "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, + "prev": s.summary_prev, + "curr": s.summary_curr, + "future": s.future_plan, + "doc_url": s.upload_doc.url if s.upload_doc else None, + "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 + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def rpc_seminar_list(request): + faculty = get_object_or_404(Faculty, id__user=request.user) + all_entries = ProgressSeminarEntry.objects.filter( + thesis__committee__member=faculty + ).distinct() + + def serialize(qs): + return [ + { + "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 + ] + + return Response({ + "pending": serialize(all_entries.filter(status='rpc_pending')), + "approved": serialize(all_entries.filter(status='rpc_approved')), + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def rpc_detail(request, pk): + faculty = get_object_or_404(Faculty, id__user=request.user) + seminar = get_object_or_404(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.get_full_name() + roll_number = student_extra.user.username + discipline = seminar.thesis.student.specialization + thesis_title = seminar.thesis.research_theme + + panel = { + f: getattr(seminar, f) for f in [ + 'quality', 'quantity', 'overall_grade', 'expected_period', + 'rec_assist', 'rec_enhance', 'rec_repeat', 'rec_open' + ] + } + + committee = [] + for cm in CommitteeMember.objects.filter(thesis=seminar.thesis).select_related('member__id__user', 'member__id__department'): + fac = cm.member + extra = fac.id + consented = 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}", + "discipline": extra.department.name if extra.department else "", + "consented": consented, + }) + + comments = [ + { + "member": c.member.id.user.get_full_name(), + "text": c.text, + "timestamp": c.timestamp.isoformat() + } + for c in seminar.comments.all() + ] + + 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, + "rollNumber": roll_number, + "discipline": discipline, + "thesisTitle": thesis_title, + "programme_category": _student_programme_category(seminar.thesis.student), + "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, + "prev": seminar.summary_prev, + "curr": seminar.summary_curr, + "future": seminar.future_plan, + "doc_url": seminar.upload_doc.url if seminar.upload_doc else None, + "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), + "consentedCount": sum(1 for m in committee if m["consented"]), + "comments": comments, + "myComment": my_comment.text if my_comment else "", + "isConsented": is_consented, + "status": seminar.status, + } + + return JsonResponse(payload) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def rpc_consent(request, pk): + faculty = get_object_or_404(Faculty, id__user=request.user) + seminar = get_object_or_404(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) + + data = request.data + panel_fields = [ + 'quality', 'quantity', 'overall_grade', 'expected_period', + 'rec_assist', 'rec_enhance', 'rec_repeat', 'rec_open' + ] + + changed = any( + field in data and getattr(seminar, field) != data[field] + for field in panel_fields + ) + if changed: + ProgressSeminarConsent.objects.filter(seminar=seminar).update(consented=False) + + for field in panel_fields: + if field in data: + setattr(seminar, field, data[field]) + seminar.save() + + if 'comment' in data: + ProgressSeminarComment.objects.update_or_create( + seminar=seminar, + member=faculty, + defaults={'text': data['comment']} + ) + + consent_obj, _ = ProgressSeminarConsent.objects.get_or_create(seminar=seminar, member=faculty) + consent_obj.consented = True + consent_obj.save() + + return JsonResponse({"message": "Consent & data recorded."}) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def rpc_finalize(request, pk): + faculty = get_object_or_404(Faculty, id__user=request.user) + seminar = get_object_or_404(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) + + current_committee_ids = CommitteeMember.objects.filter(thesis=seminar.thesis).values_list('member_id', flat=True) + total = len(current_committee_ids) + yes = ProgressSeminarConsent.objects.filter( + seminar=seminar, consented=True, member_id__in=current_committee_ids, + ).count() + + if total == 0 or yes < total: + return JsonResponse({"error": "Not all consents recorded."}, status=400) + + seminar.status = 'rpc_approved' + seminar.save() + + thesis = seminar.thesis + for recipient in [thesis.student.id.user, thesis.supervisor.id.user]: + _progress_seminar_notify( + sender=request.user, + recipient=recipient, + verb='Progress Seminar report approved', + description=f"The RPC has approved {thesis.student.id.user.get_full_name()}'s " + f"Progress Seminar report (version {seminar.version}).", + ) + + return JsonResponse({"message": "Seminar approved."}) + + +from applications.academic_procedures.models import ThesisSubmission, ReviewInvitation, ThesisReview, ExaminerBankDetails +from applications.academic_procedures.utils import ( + send_invitation_email, + send_review_form_email, + send_thank_you_email, + advance_invitation, + INVITATION_TIMEOUT_DAYS, +) + +# 1. Student submits thesis +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@parser_classes([MultiPartParser, FormParser]) +def thesis_submit(request): + user = request.user + try: + user_details = user.extrainfo + student = Student.objects.get(id=user_details) + except Student.DoesNotExist: + return Response({'error': 'Student record not found.'}, 404) + if _student_programme_category(student) != 'PHD': + return Response( + {'error': 'This final thesis submission workflow is for PhD students only.'}, + status=403, + ) + thesis = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + if thesis is None: + return Response({'error': 'No thesis found for given submission.'}, 400) + if not OpenSeminar.objects.filter(student=student, status='satisfactory').exists(): + return Response( + {'error': 'Open Seminar must be completed satisfactorily before final thesis submission.'}, + status=403, + ) + syn = request.FILES.get('synopsis') + rpt = request.FILES.get('thesis_report') + if not all([syn, rpt]): + return Response({'error': 'Missing fields'}, 400) + if syn.size > 5*1024*1024 or rpt.size > 25*1024*1024: + return Response({'error': 'File too large'}, 400) + if syn.content_type != 'application/pdf' or rpt.content_type != 'application/pdf': + return Response({'error': 'Both files must be PDFs'}, status=400) + if ThesisSubmission.objects.filter(thesis=thesis).exists(): + return Response({'error': 'Thesis has already been submitted and cannot be changed.'}, status=400) + sub = ThesisSubmission.objects.create( + thesis = thesis, + synopsis=syn, + thesis_report=rpt, + status='submitted' + ) + return Response({'submission_id': sub.id}, status=201) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def thesis_submission_status(request): + """ + GET /thesis/submission-status/ + Returns the requesting student's own thesis submission (if any), so the + upload screen can show existing status instead of a blank form. `thesis` + is a OneToOneField on ThesisSubmission, so at most one can ever exist. + """ + user = request.user + try: + student = Student.objects.get(id=user.extrainfo) + except Student.DoesNotExist: + return Response({'error': 'Student record not found.'}, status=404) + + thesis = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + if thesis is None: + return Response({'submission': None}, status=200) + + try: + sub = thesis.submission + except ThesisSubmission.DoesNotExist: + return Response({'submission': None}, status=200) + + return Response({ + 'submission': { + 'id': sub.id, + 'status': sub.status, + 'status_label': sub.get_status_display(), + 'submitted_at': sub.submitted_at.isoformat(), + 'synopsis_url': sub.synopsis.url if sub.synopsis else None, + 'thesis_report_url': sub.thesis_report.url if sub.thesis_report else None, + 'dean_panel_remarks': sub.dean_panel_remarks, + 'director_remarks': sub.director_remarks, + }, + }, status=200) + +def _serialize_invitations(sub): + """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 = { + 'token': str(inv.token), + 'name': inv.prof_name, + 'position': inv.prof_position, + 'address': inv.prof_address, + 'phone': inv.prof_phone, + 'fax': inv.prof_fax, + 'email': inv.prof_email, + 'priority': inv.priority, + 'status': inv.status, + } + if inv.examiner_type == 'foreign': + data['time_ranking'] = inv.prof_time_ranking + foreign.append(data) + else: + indian.append(data) + return indian, foreign + + +# 1) Supervisor dashboard: pending vs forwarded +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_dashboard(request): + ex = request.user + topics = ThesisTopic.objects.filter( + Q(supervisor__id=ex.username) | Q(co_supervisor__id=ex.username) + ) + + 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, + '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({ + 'action_required': action_required, + 'history': history, + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_submission_detail(request, submission_id): + """ + 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, + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def supervisor_review_reports(request): + """ + Submissions supervised by the caller that have at least one examiner's + completed review, with the full report content for each. + """ + ex = request.user + topics = ThesisTopic.objects.filter( + Q(supervisor__id=ex.username) | Q(co_supervisor__id=ex.username) + ) + + data = [] + for sub in ThesisSubmission.objects.filter(thesis__in=topics): + completed = ( + ReviewInvitation.objects + .filter(submission=sub, status='completed') + .select_related('review') + .order_by('examiner_type', 'priority') + ) + reviews = [ + { + 'examiner_type': inv.examiner_type, + 'examiner_name': inv.prof_name, + 'examiner_email': inv.prof_email, + 'originality_presentation': inv.review.originality_presentation, + 'quality_comparable': inv.review.quality_comparable, + 'new_ideas_original': inv.review.new_ideas_original, + 'correction_severity': inv.review.correction_severity, + 'technical_content': inv.review.technical_content, + 'highlights': inv.review.highlights, + 'suggestions': inv.review.suggestions, + 'defense_questions': inv.review.defense_questions, + 'recommendation': inv.review.recommendation, + 'submitted_at': inv.review.submitted_at, + } + for inv in completed if hasattr(inv, 'review') + ] + if reviews: + data.append({ + '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, + 'reviews': reviews, + }) + + return Response(data) + + +# 3) Supervisor assign examiners +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_assign(request): + data = request.data + sub = get_object_or_404(ThesisSubmission, id=data.get('submission_id')) + + # Only the thesis's own supervisor/co-supervisor may assign its panel. + topic = sub.thesis + allowed_users = {topic.supervisor.id.user_id} + if topic.co_supervisor: + allowed_users.add(topic.co_supervisor.id.user_id) + if request.user.id not in allowed_users: + return Response( + {'error': 'You are not the supervisor or co-supervisor for this thesis.'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Prevent re-assignment + if sub.status != 'submitted': + return Response( + {'error': 'Examiners have already been assigned.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + indian = data.get('indian_examiners', []) + foreign = data.get('foreign_examiners', []) + + if not indian or not foreign: + return Response( + {'error': 'At least one Indian and one foreign examiner are required.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Save submission + invitations atomically. ReviewInvitation has a + # unique constraint on (submission, examiner_type, priority); each + # category gets its own independent 1..N rank. + with transaction.atomic(): + # Wipe old invites & create new ones + ReviewInvitation.objects.filter(submission=sub).delete() + + for idx, prof in enumerate(indian, start=1): + ReviewInvitation.objects.create( + submission=sub, + examiner_type='indian', + prof_name=prof.get('name', ''), + prof_position=prof.get('position', ''), + prof_address=prof.get('address', ''), + prof_phone=prof.get('phone', ''), + prof_fax=prof.get('fax', ''), + prof_email=prof.get('email', ''), + priority=idx, + ) + + for idx, prof in enumerate(foreign, start=1): + ReviewInvitation.objects.create( + submission=sub, + examiner_type='foreign', + prof_name=prof.get('name', ''), + prof_position=prof.get('position', ''), + prof_address=prof.get('address', ''), + prof_phone=prof.get('phone', ''), + prof_fax=prof.get('fax', ''), + prof_email=prof.get('email', ''), + prof_time_ranking=prof.get('time_ranking', 1), + priority=idx, + ) + + # Update submission only after invitations are created successfully. + # 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) + + +# 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', '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]) +@role_required(['Dean Academic']) +def dean_panel_dashboard(request): + def serialize(sub, action=None, action_label=None, waiting_since=None): + indian, foreign = _serialize_invitations(sub) + return { + 'id': sub.id, + 'title': sub.thesis.research_theme, + '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() + ).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=ACTION_STATUSES.keys() + ).exclude( + Q(status='submitted') & Q(supervisor_approved_at__isnull=True) + ).select_related('thesis__student__id__user').prefetch_related('invitations') + ] + + return Response({ + 'action_required': action_required, + 'history': history, + }) + + +# 5) Dean approves or rejects the proposed panel +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_panel_approve(request): + data = request.data + sub = get_object_or_404(ThesisSubmission, id=data.get('submission_id')) + action = data.get('action') + + if sub.status != 'dean_panel_review': + return Response({'error': 'This panel is not awaiting Dean approval.'}, status=status.HTTP_400_BAD_REQUEST) + + if action == 'approve': + sub.dean = request.user + sub.dean_approved_at = timezone.now() + sub.status = 'director_review' + sub.save() + return Response({'detail': 'Panel approved, forwarded to Director for prioritization.'}) + + if action == 'reject': + 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.'}) + + return Response({'error': 'Unknown action.'}, status=status.HTTP_400_BAD_REQUEST) + + +# 6) Director dashboard: pending prioritization vs already prioritized +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['Director']) +def director_dashboard(request): + def serialize(sub, action=None, action_label=None, waiting_since=None): + 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, + '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' + ).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'] + ).select_related('thesis__student__id__user').prefetch_related('invitations') + ] + + return Response({ + 'action_required': action_required, + 'history': history, + }) + + +# 7) Director sets the priority order within each examiner category, then +# hands the submission back to the Dean to send out invitations. +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Director']) +def director_approve(request): + data = request.data + sub = get_object_or_404(ThesisSubmission, id=data.get('submission_id')) + + if sub.status != 'director_review': + return Response( + {'error': 'This panel is not awaiting Director prioritization.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + action = data.get('action', 'approve') + if action not in ('approve', 'send_back'): + return Response({'error': 'Unknown action.'}, status=status.HTTP_400_BAD_REQUEST) + + indian = data.get('indian_examiners', []) + foreign = data.get('foreign_examiners', []) + if not indian or not foreign: + return Response( + {'error': 'At least one Indian and one foreign examiner are required.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + 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(): + 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.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': detail}) + + +# 7b) Dean sends the invitation to the Rank-1 Indian and Rank-1 Foreign examiners. +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_send_invitations(request): + data = request.data + sub = get_object_or_404(ThesisSubmission, id=data.get('submission_id')) + + if sub.status != 'dean_invite_pending': + return Response( + {'error': 'This submission is not ready for invitations.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + invited = [] + for examiner_type in ('indian', 'foreign'): + inv = ReviewInvitation.objects.filter( + submission=sub, examiner_type=examiner_type, priority=1 + ).first() + if inv is None: + continue + inv.last_sent = timezone.now() + inv.expires_at = timezone.now() + datetime.timedelta(days=INVITATION_TIMEOUT_DAYS) + inv.save(update_fields=['last_sent', 'expires_at']) + try: + send_invitation_email(inv) + invited.append(inv.prof_email) + except Exception: + logger.exception(f"Failed to send invitation to {inv.prof_email} for submission {sub.id}") + + sub.dean = request.user + sub.dean_invited_at = timezone.now() + sub.status = 'in_review' + sub.save() + + return Response({'detail': 'Invitations sent.', 'invited': invited}) + + +# 8. Invitation accept/reject (external examiners have no Fusion account — +# the secret UUID token in the emailed link is the auth mechanism here) +@api_view(['POST']) +@permission_classes([AllowAny]) +def invitation_action(request, token, action): + inv = get_object_or_404(ReviewInvitation, token=token) + if inv.is_expired() or inv.is_finalized(): + return Response({'error': 'Invalid/expired'}, 403) + if action == 'accept': + inv.status = 'accepted' + inv.save() + try: + send_review_form_email(inv) + inv.review_form_sent = timezone.now() + inv.save(update_fields=['review_form_sent']) + except Exception: + logger.exception(f"Failed to send review-form email for token {inv.token}") + return Response({'detail': 'Accepted'}, 200) + if action == 'reject': + inv.status = 'rejected' + inv.save() + # Fall through to the next-ranked examiner in the same category. + advance_invitation(inv.submission, inv.examiner_type) + return Response({'detail': 'Rejected'}, 200) + return Response({'error': 'Unknown action'}, 400) + +# 9. Review detail & submission (token-authenticated, same as invitation_action) +@api_view(['GET', 'POST']) +@permission_classes([AllowAny]) +def review_detail(request, token): + inv = get_object_or_404(ReviewInvitation, token=token) + if inv.is_expired() or inv.is_finalized(): + return Response({'error': 'Invalid/expired'}, status=403) + if inv.status != 'accepted': + return Response({'error': 'This invitation has not been accepted yet.'}, status=403) + + sub = inv.submission + topic = sub.thesis + + if request.method == 'GET': + base = request.build_absolute_uri(settings.MEDIA_URL) + return Response({ + 'student_name': topic.student.id.user.get_full_name(), + 'student_roll': topic.student.id.id, + 'student_discipline': topic.student.specialization, + 'thesis_title': topic.research_theme, + 'synopsis_url': base + sub.synopsis.name, + 'report_url': base + sub.thesis_report.name, + 'examiner_type': inv.examiner_type, + 'examiner': { + 'name': inv.prof_name, + 'email': inv.prof_email, + 'position': inv.prof_position, + 'address': inv.prof_address, + 'phone': inv.prof_phone, + 'fax': inv.prof_fax, + }, + }, status=200) + + # POST: record the formal evaluation and finalize this examiner's invitation. + # Note: this only closes out THIS examiner's invitation -- the other + # category's examiner (Indian/Foreign) is untouched and continues its own + # lifecycle independently. What happens once both examiners have reviewed + # (aggregating outcomes, a final decision, etc.) is not yet implemented. + data = request.data + if not data.get('recommendation'): + return Response({'error': 'A specific recommendation is required.'}, status=400) + + with transaction.atomic(): + ThesisReview.objects.update_or_create( + invitation=inv, + defaults={ + 'originality_presentation': data.get('originality_presentation', ''), + 'quality_comparable': data.get('quality_comparable'), + 'new_ideas_original': data.get('new_ideas_original'), + 'correction_severity': data.get('correction_severity', ''), + 'technical_content': data.get('technical_content', ''), + 'highlights': data.get('highlights', ''), + 'suggestions': data.get('suggestions', ''), + 'defense_questions': data.get('defense_questions', ''), + 'recommendation': data['recommendation'], + }, + ) + + bank = data.get('bank_details') or {} + if any(bank.values()): + ExaminerBankDetails.objects.update_or_create( + invitation=inv, + defaults={ + 'beneficiary_name': bank.get('beneficiary_name', ''), + 'bank_name': bank.get('bank_name', ''), + 'bank_address': bank.get('bank_address', ''), + 'account_no': bank.get('account_no', ''), + 'ifsc_code': bank.get('ifsc_code', ''), + 'pan_no': bank.get('pan_no', ''), + 'iban_no': bank.get('iban_no', ''), + 'swift_code': bank.get('swift_code', ''), + }, + ) + + inv.status = 'completed' + inv.save(update_fields=['status']) + + try: + send_thank_you_email(inv) + except Exception: + logger.exception(f"Failed to send thank-you email for token {inv.token}") + + return Response({'detail': 'Review submitted successfully.'}, status=200) + + +# 10. Acadadmin: bank details for examiners who have completed a review, so +# the honorarium can be processed. Independent of the thesis outcome -- +# surfaces as soon as each individual examiner finishes, regardless of +# what happens next in the review-consolidation workflow. +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def examiner_honorarium_list(request): + invs = ( + ReviewInvitation.objects + .filter(status='completed') + .select_related('bank_details', 'submission__thesis__student__id__user') + .order_by('-updated_at') + ) + + data = [] + for inv in invs: + if not hasattr(inv, 'bank_details'): + continue + bank = inv.bank_details + sub = inv.submission + data.append({ + 'invitation_id': inv.id, + 'examiner_name': inv.prof_name, + 'examiner_email': inv.prof_email, + 'examiner_type': inv.examiner_type, + 'thesis_title': sub.thesis.research_theme, + 'student_name': sub.thesis.student.id.user.get_full_name(), + 'student_roll': sub.thesis.student.id.id, + 'beneficiary_name': bank.beneficiary_name, + 'bank_name': bank.bank_name, + 'bank_address': bank.bank_address, + 'account_no': bank.account_no, + 'ifsc_code': bank.ifsc_code, + 'pan_no': bank.pan_no, + 'iban_no': bank.iban_no, + 'swift_code': bank.swift_code, + }) + + return Response(data) + + +# =========================================================================== +# Thesis Slot Semester-Level Registration +# =========================================================================== +from applications.academic_procedures.models import ( + ThesisTopic, CommitteeMember, ProgressSeminarEntry, + ProgressSeminarConsent, ProgressSeminarComment, + ThesisRegistration, ProgressSeminarRegistration, TeachingCreditRegistration, + ThesisEvaluation, ProgressSeminarEvaluation, + ThesisExaminerPanel, ThesisExaminerCandidate, ThesisEvaluationScore, PGThesisSubmission, + ComprehensiveExam, ComprehensiveExamAttempt, + ComprehensiveExamConsent, ComprehensiveExamRPCComment, + OpenSeminar, OpenSeminarAttempt, + OpenSeminarConsent, OpenSeminarRPCComment, + TeachingCreditAllocation, TeachingCreditEvaluationResponse, + resolve_progress_seminar_credit, resolve_teaching_credit_credit, +) +from applications.programme_curriculum.models import ( + ThesisSlot, SeminarSlot as ProgressSeminarSlot, TeachingCreditSlot, +) +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 _student_programme_category(student): + """'PG', 'PHD', or None -- used to let admin screens that merge PG/PhD + requests together (semester numbering overlaps between the two) filter + by category.""" + try: + return student.batch_id.curriculum.programme.category + except AttributeError: + return None + + +def _thesis_reg_to_dict(reg): + """Serialize a ThesisRegistration instance to a plain dict.""" + if reg is None: + return None + slot = reg.thesis_slot + theses_list = [ + {'id': t.id, 'code': t.code, 'name': t.name, 'credit': t.credit} + for t in slot.theses.all() + ] + resolved_thesis = _catalog_entry_to_dict(_resolve_discipline_matched_entry(slot.theses, reg.student)) + return { + 'id': reg.id, + 'status': reg.status, + 'remarks': reg.remarks, + 'credits': reg.credits, + 'registered_on': reg.registered_on.isoformat(), + 'verified_on': reg.verified_on.isoformat() if reg.verified_on else None, + 'academic_session': reg.academic_session, + 'thesis_slot': { + 'id': slot.id, + 'name': slot.name, + 'info': slot.thesis_slot_info or '', + 'duration': slot.duration, + 'theses': theses_list, + 'resolved_thesis': resolved_thesis, + }, + 'student': { + 'id': reg.student.id.id, + 'name': reg.student.id.user.get_full_name(), + }, + 'semester_no': reg.semester.semester_no, + 'programme_category': _student_programme_category(reg.student), + } + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def student_thesis_enrollment_api(request): + """ + GET /stu/thesis-enrollment/ + Returns the current semester's ThesisSlot, the student's + ThesisTopic approval status, and any existing registration. + + POST /stu/thesis-enrollment/ + Creates a new ThesisRegistration for the current semester. + Requires thesis_topic to be dean_approved. + """ + user = request.user + try: + user_details = user.extrainfo + student = Student.objects.get(id=user_details) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) + except Exception as e: + return JsonResponse({'error': f'User setup error: {type(e).__name__}: {e}'}, status=400) + + try: + # Resolve current semester + if not student.batch_id or not student.batch_id.curriculum: + return JsonResponse({'error': 'Student batch or curriculum is not configured'}, status=400) + try: + semester = Semester.objects.get( + curriculum=student.batch_id.curriculum, + semester_no=student.curr_semester_no, + ) + except Semester.DoesNotExist: + return JsonResponse({'error': 'Current semester not found in curriculum'}, status=400) + + # Thesis topic info + topic = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + topic_data = thesis_to_dict(topic) if topic else None + + # ThesisSlot for this semester + thesis_slots = ThesisSlot.objects.filter(semester=semester) + thesis_slot = thesis_slots.first() # typically one per semester + + # Existing registration + try: + reg = ThesisRegistration.objects.get(student=student, semester=semester) + reg_data = _thesis_reg_to_dict(reg) + except ThesisRegistration.DoesNotExist: + reg = None + reg_data = None + + if request.method == 'GET': + slot_data = None + if thesis_slot: + slot_data = { + 'id': thesis_slot.id, + 'name': thesis_slot.name, + 'info': thesis_slot.thesis_slot_info or '', + 'duration': thesis_slot.duration, + 'theses': [ + {'id': t.id, 'code': t.code, 'name': t.name, 'credit': t.credit} + for t in thesis_slot.theses.all() + ], + '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 = [] + if reg is not None: + for ev in reg.evaluations.filter(announced=True).order_by('block_number'): + eval_blocks.append({ + 'id': ev.id, + 'block_number': ev.block_number, + 'total_blocks': reg.credits // 3, + 'grade': ev.grade, + 'remarks': ev.remarks, + 'announced_at': ev.announced_at.isoformat() if ev.announced_at else None, + }) + return JsonResponse({ + 'thesis_topic': topic_data, + 'current_semester_no': student.curr_semester_no, + 'thesis_slot': slot_data, + 'registration': reg_data, + 'evaluations': eval_blocks, + }, status=200) + + except Exception as e: + return JsonResponse({'error': f'Internal error: {type(e).__name__}: {e}'}, status=500) + + # POST: create registration + if reg is not None: + return JsonResponse( + {'error': 'Already registered for this semester', 'registration': reg_data}, + status=400, + ) + + if topic is None or topic.status != 'dean_approved': + return JsonResponse( + {'error': 'Thesis topic must be dean-approved before registering for a thesis slot'}, + status=403, + ) + + if thesis_slot is None: + return JsonResponse( + {'error': 'No thesis slot configured for your current semester'}, + status=400, + ) + + # Validate chosen credits + ALLOWED_THESIS_CREDITS = [3, 6, 9, 12] + try: + chosen_credits = int(request.data.get('credits', 6)) + except (TypeError, ValueError): + chosen_credits = 6 + if chosen_credits not in ALLOWED_THESIS_CREDITS: + return JsonResponse( + {'error': f'Invalid credit value. Choose from {ALLOWED_THESIS_CREDITS}'}, + status=400, + ) + + # Check max registration limit + current_count = ThesisRegistration.objects.filter( + thesis_slot=thesis_slot, status__in=['pending', 'verified'] + ).count() + if current_count >= thesis_slot.max_registration_limit: + return JsonResponse( + {'error': 'Thesis slot has reached maximum capacity'}, + status=400, + ) + + now = _dt.datetime.now() + # Build academic session string e.g. "2025-26" + year = now.year + month = now.month + if month >= 7: + session = f"{year}-{str(year + 1)[2:]}" + else: + session = f"{year - 1}-{str(year)[2:]}" + + reg = ThesisRegistration.objects.create( + student=student, + thesis_slot=thesis_slot, + thesis_topic=topic, + semester=semester, + credits=chosen_credits, + working_year=year, + academic_session=session, + status='pending', + ) + return JsonResponse(_thesis_reg_to_dict(reg), status=201) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_thesis_enrollment_list(request): + """ + GET /acadadmin/thesis-enrollments/?semester=&status= + Lists all ThesisRegistration entries. Supports optional filters: + ?semester= filter by semester number + ?status=pending|verified|rejected + """ + qs = ThesisRegistration.objects.select_related( + 'student__id__user', 'thesis_slot', 'thesis_topic', 'semester' + ).all().order_by('-registered_on') + + sem_no = request.GET.get('semester') + if sem_no: + qs = qs.filter(semester__semester_no=sem_no) + + status_filter = request.GET.get('status') + if status_filter: + qs = qs.filter(status=status_filter) + + data = [] + for reg in qs: + entry = _thesis_reg_to_dict(reg) + # Also include thesis topic approval status for admin view + entry['topic_status'] = reg.thesis_topic.status if reg.thesis_topic else None + data.append(entry) + + return JsonResponse({'registrations': data}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_verify_enrollments(request): + """ + POST /acadadmin/thesis-enrollments/verify/ + Body: { "ids": [1, 2, 3] } + Marks the given ThesisRegistration records as 'verified'. + """ + ids = request.data.get('ids', []) + if not ids: + return JsonResponse({'error': 'No registration IDs provided'}, status=400) + + now = _dt.datetime.now(_dt.timezone.utc) + regs = ThesisRegistration.objects.filter(id__in=ids, status='pending').select_related('thesis_slot') + count = 0 + with transaction.atomic(): + for reg in regs: + reg.status = 'verified' + reg.verified_on = now + reg.save(update_fields=['status', 'verified_on']) + if reg.thesis_slot.evaluation_type == 'decimal': + # Single overall score (PG's final thesis semester) -- no block + # split, average of supervisor_score/examiner_score lands in + # numeric_grade once ThesisEvaluationScore has both. + evaluation, _created = ThesisEvaluation.objects.get_or_create( + registration=reg, + block_number=1, + ) + ThesisEvaluationScore.objects.get_or_create(evaluation=evaluation) + else: + # Block-wise S/X (PhD, or PG sem 2/3): one block per 3 credits. + total_blocks = reg.credits // 3 + for blk in range(1, total_blocks + 1): + ThesisEvaluation.objects.get_or_create( + registration=reg, + block_number=blk, + ) + count += 1 + return JsonResponse({'verified_count': count}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_reject_enrollments(request): + """ + POST /acadadmin/thesis-enrollments/reject/ + Body: { "ids": [1, 2], "remarks": "Reason for rejection" } + Marks the given ThesisRegistration records as 'rejected'. + """ + ids = request.data.get('ids', []) + remarks = request.data.get('remarks', '') + if not ids: + return JsonResponse({'error': 'No registration IDs provided'}, status=400) + + updated = ThesisRegistration.objects.filter(id__in=ids, status='pending').update( + status='rejected', + remarks=remarks, + ) + return JsonResponse({'rejected_count': updated}, status=200) + + +# =========================================================================== +# PG Decimal Thesis Grading -- Supervisor Score + Batch-Wide Examiner Panel +# +# Applies only to ThesisRegistrations whose thesis_slot.evaluation_type is +# 'decimal' (PG's final thesis semester). Flow: supervisor scores each +# student out of 100 (this is the "forward to HOD" step) -> once every +# student in the batch has a supervisor score, HOD nominates 4 Indian +# examiner candidates for the WHOLE BATCH -> Dean ranks them and invites the +# top candidate -> whoever accepts first examines every student in the +# batch -> each student's numeric_grade = round((supervisor+examiner)/2, 1). +# =========================================================================== + +from applications.academic_procedures.utils import ( + send_examiner_panel_invitation_email, + send_examiner_panel_scoring_email, + advance_examiner_panel_invitation, +) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@parser_classes([MultiPartParser, FormParser]) +def pg_thesis_submit(request): + """ + POST /stu/pg-thesis-submit/ + PG student uploads synopsis + full thesis report. Deliberately separate + from thesis_submit (PhD's Dean Panel/Director/foreign-examiner workflow + doesn't apply here) -- the supervisor and the batch's accepted examiner + reference these files directly while scoring, no approval chain of its + own. One submission per ThesisTopic, final -- once submitted it cannot + be changed. + """ + user = request.user + try: + student = Student.objects.get(id=user.extrainfo) + except Student.DoesNotExist: + return Response({'error': 'Student record not found.'}, status=404) + if _student_programme_category(student) != 'PG': + return Response( + {'error': 'This thesis submission workflow is for PG students only.'}, + status=403, + ) + thesis = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + if thesis is None: + return Response({'error': 'No thesis found for given submission.'}, status=400) + + if PGThesisSubmission.objects.filter(thesis=thesis).exists(): + return Response({'error': 'Thesis has already been submitted and cannot be changed.'}, status=400) + + syn = request.FILES.get('synopsis') + rpt = request.FILES.get('thesis_report') + if not syn or not rpt: + return Response({'error': 'Missing fields'}, status=400) + if syn.size > 5 * 1024 * 1024: + return Response({'error': 'File too large'}, status=400) + if rpt.size > 25 * 1024 * 1024: + return Response({'error': 'File too large'}, status=400) + # Client-declared content type is untrustworthy on its own, but combined + # with the hardcoded .pdf extension in upload_pg_synopsis/upload_pg_report + # it keeps a renamed non-PDF file from ever being stored/served as + # something a browser would render (stored XSS via file upload). + if syn.content_type != 'application/pdf' or rpt.content_type != 'application/pdf': + return Response({'error': 'Both files must be PDFs'}, status=400) + + sub = PGThesisSubmission.objects.create(thesis=thesis, synopsis=syn, thesis_report=rpt) + + return Response({'submission_id': sub.id}, status=201) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def pg_thesis_submission_status(request): + """ + GET /stu/pg-thesis-submission-status/ + Returns the requesting student's own PG thesis submission (if any). + """ + user = request.user + try: + student = Student.objects.get(id=user.extrainfo) + except Student.DoesNotExist: + return Response({'error': 'Student record not found.'}, status=404) + + thesis = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + if thesis is None: + return Response({'submission': None}, status=200) + + sub = PGThesisSubmission.objects.filter(thesis=thesis).first() + if sub is None: + return Response({'submission': None}, status=200) + + return Response({ + 'submission': { + 'id': sub.id, + 'submitted_at': sub.submitted_at.isoformat(), + 'synopsis_url': sub.synopsis.url if sub.synopsis else None, + 'thesis_report_url': sub.thesis_report.url if sub.thesis_report else None, + }, + }, status=200) + + +def _maybe_finalize_numeric_grade(evaluation): + """If both supervisor and examiner scores are in, compute and store the + averaged numeric_grade on the ThesisEvaluation.""" + score_inputs = getattr(evaluation, 'score_inputs', None) + if score_inputs is None: + return + if score_inputs.supervisor_score is not None and score_inputs.examiner_score is not None: + avg = (score_inputs.supervisor_score + score_inputs.examiner_score) / 2 + evaluation.numeric_grade = round(avg, 1) + evaluation.save(update_fields=['numeric_grade']) + + +def _thesis_examiner_candidate_to_dict(c): + return { + 'id': c.id, + 'name': c.name, + 'position': c.position, + 'address': c.address, + 'phone': c.phone, + 'fax': c.fax, + 'email': c.email, + 'priority': c.priority, + 'status': c.status, + 'last_sent': c.last_sent.isoformat() if c.last_sent else None, + 'expires_at': c.expires_at.isoformat() if c.expires_at else None, + } + + +def _thesis_examiner_panel_to_dict(panel): + batch = panel.batch + return { + 'id': panel.id, + 'batch_id': batch.id, + 'batch_name': str(batch), + 'discipline_acronym': batch.discipline.acronym if batch.discipline else None, + 'year': batch.year, + 'group_name': f"{batch.discipline.acronym} {batch.year}" if batch.discipline else str(batch.year), + 'status': panel.status, + 'hod_submitted_at': panel.hod_submitted_at.isoformat() if panel.hod_submitted_at else None, + 'dean_invited_at': panel.dean_invited_at.isoformat() if panel.dean_invited_at else None, + 'candidates': [_thesis_examiner_candidate_to_dict(c) for c in panel.candidates.all().order_by('priority')], + 'accepted_candidate': ( + _thesis_examiner_candidate_to_dict(panel.accepted_candidate) + if panel.accepted_candidate else None + ), + } + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def supervisor_thesis_decimal_scores(request): + """ + GET /academic-procedures/api/supervisor/thesis-decimal-scores/ + Lists the requesting supervisor's decimal-mode ThesisEvaluations. + POST /academic-procedures/api/supervisor/thesis-decimal-scores/ + Body: { "evaluation_id": , "score": <0-100> } + Records the supervisor's score -- this is the "forward to HOD" step. + """ + user = request.user + try: + faculty = Faculty.objects.get(id=user.extrainfo) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) + + if request.method == 'GET': + evaluations = ThesisEvaluation.objects.filter( + registration__thesis_topic__supervisor=faculty, + registration__thesis_slot__evaluation_type='decimal', + ).select_related( + 'registration__student__id__user', 'registration__semester', + 'registration__thesis_topic', 'registration__thesis_topic__pg_submission', 'score_inputs', + ) + data = [] + for ev in evaluations: + score_inputs = getattr(ev, 'score_inputs', None) + submission = getattr(ev.registration.thesis_topic, 'pg_submission', None) + data.append({ + 'evaluation_id': ev.id, + 'student_name': ev.registration.student.id.user.get_full_name(), + 'student_roll': ev.registration.student.id.id, + 'semester_no': ev.registration.semester.semester_no, + 'credits': ev.registration.credits, + 'supervisor_score': score_inputs.supervisor_score if score_inputs else None, + 'examiner_score': score_inputs.examiner_score if score_inputs else None, + 'numeric_grade': ev.numeric_grade, + 'synopsis_url': submission.synopsis.url if submission and submission.synopsis else None, + 'thesis_report_url': submission.thesis_report.url if submission and submission.thesis_report else None, + }) + return JsonResponse({'evaluations': data}, status=200) + + # POST + evaluation_id = request.data.get('evaluation_id') + try: + score = round(float(request.data.get('score')), 1) + except (TypeError, ValueError): + return JsonResponse({'error': 'Invalid score'}, status=400) + if not (0 <= score <= 100): + return JsonResponse({'error': 'Score must be between 0 and 100'}, status=400) + + evaluation = get_object_or_404( + ThesisEvaluation, id=evaluation_id, + registration__thesis_topic__supervisor=faculty, + registration__thesis_slot__evaluation_type='decimal', + ) + if evaluation.numeric_grade is not None: + return JsonResponse({'error': 'This evaluation has already been finalized'}, status=403) + if not PGThesisSubmission.objects.filter(thesis=evaluation.registration.thesis_topic).exists(): + return JsonResponse( + {'error': 'Student must submit their thesis and synopsis before scoring'}, status=400 + ) + score_inputs, _created = ThesisEvaluationScore.objects.get_or_create(evaluation=evaluation) + score_inputs.supervisor_score = score + score_inputs.supervisor_scored_at = timezone.now() + score_inputs.save(update_fields=['supervisor_score', 'supervisor_scored_at']) + _maybe_finalize_numeric_grade(evaluation) + return JsonResponse({'detail': 'Score recorded'}, status=200) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def hod_examiner_panel_dashboard(request): + """ + GET /hod/thesis-examiner-panels/ + Lists (discipline, year) groups (in HOD's disciplines) with verified + decimal-mode thesis registrations -- e.g. CSE's "AI & ML" and "Data + Science" specialization batches admitted the same year are grouped + together for display -- each showing its own per-batch breakdown + (supervisor-forwarded completion, existing panel status). The grouping + is a UI convenience only: each specialization batch still gets its own + independent ThesisExaminerPanel (own 4 examiners, own Dean ranking, own + accepted examiner), matching the paper form (one sheet per + specialization). Grouping just lets HOD nominate all of them in one sitting. + """ + hod_disciplines = get_hod_disciplines(request.user) + if not hod_disciplines: + return JsonResponse({'groups': []}, status=200) + + regs = ThesisRegistration.objects.filter( + status='verified', + thesis_slot__evaluation_type='decimal', + student__batch_id__discipline__acronym__in=hod_disciplines, + ).select_related( + 'student__batch_id__discipline', 'student__id__user', 'thesis_topic', + 'thesis_topic__supervisor__id__user', 'thesis_topic__co_supervisor__id__user', + ) + + evaluations_by_reg_id = { + ev.registration_id: ev + for ev in ThesisEvaluation.objects.filter(registration__in=regs).select_related('score_inputs') + } + + batches = {} + for reg in regs: + batch = reg.student.batch_id + entry = batches.setdefault(batch.id, { + 'batch_id': batch.id, 'batch_name': str(batch.name), + 'discipline_id': batch.discipline_id, 'discipline_acronym': batch.discipline.acronym, + 'year': batch.year, 'total': 0, 'forwarded': 0, 'students': [], + }) + entry['total'] += 1 + evaluation = evaluations_by_reg_id.get(reg.id) + if evaluation and getattr(evaluation, 'score_inputs', None) and evaluation.score_inputs.supervisor_score is not None: + entry['forwarded'] += 1 + + supervisors = [] + if reg.thesis_topic: + if reg.thesis_topic.supervisor: + supervisors.append(reg.thesis_topic.supervisor.id.user.get_full_name()) + if reg.thesis_topic.co_supervisor: + supervisors.append(reg.thesis_topic.co_supervisor.id.user.get_full_name()) + entry['students'].append({ + 'roll_no': reg.student.id.id, + 'name': reg.student.id.user.get_full_name(), + 'supervisors': ' and '.join(supervisors) or None, + 'thesis_title': reg.thesis_topic.research_theme if reg.thesis_topic else None, + }) + + panels_by_batch_id = { + p.batch_id: p + for p in ThesisExaminerPanel.objects.filter(batch_id__in=batches.keys()) + } + for entry in batches.values(): + panel = panels_by_batch_id.get(entry['batch_id']) + entry['ready_for_panel'] = entry['total'] > 0 and entry['forwarded'] == entry['total'] + entry['panel_status'] = panel.status if panel else None + entry['panel_id'] = panel.id if panel else None + + groups = {} + for entry in batches.values(): + key = (entry['discipline_id'], entry['year']) + group = groups.setdefault(key, { + 'discipline_id': entry['discipline_id'], + 'discipline_acronym': entry['discipline_acronym'], + 'year': entry['year'], + 'group_name': f"{entry['discipline_acronym']} {entry['year']}", + 'batches': [], + }) + group['batches'].append(entry) + + return JsonResponse({'groups': list(groups.values())}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def hod_submit_examiner_panel(request): + """ + POST /hod/thesis-examiner-panels/submit/ + Body: { "discipline_id": , "year": , "batches": [ + { "batch_id": , "candidates": [ {name, position, address, phone, fax, email} x4 ] }, + ... + ] } + Every specialization batch in this discipline+year that's ready for + nomination (fully supervisor-scored, no panel yet) must be included and + is submitted together in one action -- but each batch still gets its own + independent ThesisExaminerPanel (own 4 examiners, own Dean ranking, own + accepted examiner), matching the paper form (one sheet per specialization). + """ + user = request.user + try: + faculty = Faculty.objects.get(id=user.extrainfo) + except Faculty.DoesNotExist: + return JsonResponse({'error': 'Faculty record not found'}, status=404) + + discipline_id = request.data.get('discipline_id') + discipline = get_object_or_404(Discipline, id=discipline_id) + try: + year = int(request.data.get('year')) + except (TypeError, ValueError): + return JsonResponse({'error': 'Invalid year'}, status=400) + + hod_disciplines = get_hod_disciplines(user) + if discipline.acronym not in hod_disciplines: + return JsonResponse({'error': "You are not HOD of this discipline"}, status=403) + + batches_data = request.data.get('batches', []) + if not batches_data: + return JsonResponse({'error': 'No batches provided'}, status=400) + for b in batches_data: + if len(b.get('candidates', [])) != 4: + return JsonResponse( + {'error': 'Exactly 4 examiner candidates are required for each specialization'}, status=400 + ) + + regs = ThesisRegistration.objects.filter( + status='verified', thesis_slot__evaluation_type='decimal', + student__batch_id__discipline=discipline, student__batch_id__year=year, + ).select_related('student__batch_id') + + evaluations_by_reg_id = { + ev.registration_id: ev + for ev in ThesisEvaluation.objects.filter(registration__in=regs).select_related('score_inputs') + } + + batch_totals = defaultdict(lambda: {'total': 0, 'forwarded': 0}) + for reg in regs: + bid = reg.student.batch_id_id + batch_totals[bid]['total'] += 1 + evaluation = evaluations_by_reg_id.get(reg.id) + if evaluation and getattr(evaluation, 'score_inputs', None) and evaluation.score_inputs.supervisor_score is not None: + batch_totals[bid]['forwarded'] += 1 + + batches_with_panels = set( + ThesisExaminerPanel.objects.filter(batch_id__in=batch_totals.keys()).values_list('batch_id', flat=True) + ) + ready_batch_ids = { + bid for bid, t in batch_totals.items() + if t['total'] > 0 and t['total'] == t['forwarded'] + and bid not in batches_with_panels + } + submitted_batch_ids = {b.get('batch_id') for b in batches_data} + if submitted_batch_ids != ready_batch_ids: + return JsonResponse( + {'error': "Submission must include exactly the specialization batches ready for " + "nomination in this discipline/year -- no partial submission"}, + status=400, + ) + + created_panel_ids = [] + try: + with transaction.atomic(): + for b in batches_data: + batch = get_object_or_404(Batch, id=b['batch_id']) + panel = ThesisExaminerPanel.objects.create(batch=batch) + for idx, c in enumerate(b['candidates'], start=1): + ThesisExaminerCandidate.objects.create( + panel=panel, + name=c.get('name', ''), + position=c.get('position', ''), + address=c.get('address', ''), + phone=c.get('phone', ''), + fax=c.get('fax', ''), + email=c.get('email', ''), + priority=idx, + ) + panel.hod_submitted_by = faculty + panel.hod_submitted_at = timezone.now() + panel.status = 'dean_pending' + panel.save(update_fields=['hod_submitted_by', 'hod_submitted_at', 'status']) + created_panel_ids.append(panel.id) + except IntegrityError: + # One of these batches already got a panel from a concurrent + # submission between the readiness check above and this insert -- + # the whole atomic block rolled back, so nothing was half-created. + return JsonResponse( + {'error': 'One of these specializations was already submitted by another request. Refresh and try again.'}, + status=409, + ) + + for dean_user in _dean_academic_users(): + academics_module_notif( + request.user, dean_user, + f'Thesis examiner panels pending your ranking ({discipline.acronym} {year})', + ) + + return JsonResponse( + {'detail': 'Examiner panels submitted to Dean', 'panel_ids': created_panel_ids}, status=200 + ) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_examiner_panel_dashboard(request): + """GET /dean/thesis-examiner-panels/ -- panels awaiting Dean action. + Each panel is one specialization batch's independent process; the + frontend groups them by discipline+year (see each panel's group_name) + so Dean can rank every specialization in a discipline+year on one screen. + """ + panels = ThesisExaminerPanel.objects.exclude(status='hod_pending') \ + .select_related('batch__discipline').prefetch_related('candidates') + return JsonResponse({'panels': [_thesis_examiner_panel_to_dict(p) for p in panels]}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_rank_and_invite_examiner_panel(request): + """ + POST /dean/thesis-examiner-panels/rank-and-invite/ + Body: { "panel_id": , "ranked_candidate_ids": [id1, id2, id3, id4] } + Sets the Dean's priority order (1-4) and immediately invites the + top-ranked candidate (no Director step for PG). + """ + panel_id = request.data.get('panel_id') + ranked_ids = request.data.get('ranked_candidate_ids', []) + panel = get_object_or_404(ThesisExaminerPanel, id=panel_id) + + if panel.status != 'dean_pending': + return JsonResponse({'error': 'This panel is not awaiting Dean ranking'}, status=400) + + candidates = list(panel.candidates.all()) + if sorted(c.id for c in candidates) != sorted(ranked_ids): + return JsonResponse( + {'error': "ranked_candidate_ids must include exactly this panel's candidates"}, status=400 + ) + + with transaction.atomic(): + # Two-phase: reassigning priorities to a new permutation in place can + # momentarily collide with a not-yet-updated row's current value -- + # (panel, priority) is a unique constraint enforced immediately, not + # deferred, and priority also has a DB-level CHECK (priority >= 0) so + # negative placeholders aren't an option -- move everything out of + # the 1-4 range first, then set the real values. + for offset, cid in enumerate(ranked_ids, start=1): + ThesisExaminerCandidate.objects.filter(id=cid, panel=panel).update(priority=1000 + offset) + for idx, cid in enumerate(ranked_ids, start=1): + ThesisExaminerCandidate.objects.filter(id=cid, panel=panel).update(priority=idx) + + panel.dean_reviewed_by = request.user + panel.dean_invited_at = timezone.now() + panel.status = 'invited' + panel.save(update_fields=['dean_reviewed_by', 'dean_invited_at', 'status']) + + top_candidate = panel.candidates.order_by('priority').first() + top_candidate.status = 'invited' + top_candidate.last_sent = timezone.now() + top_candidate.expires_at = timezone.now() + datetime.timedelta(days=INVITATION_TIMEOUT_DAYS) + top_candidate.save(update_fields=['status', 'last_sent', 'expires_at']) + try: + send_examiner_panel_invitation_email(top_candidate) + except Exception: + logger.exception(f"Failed to send examiner panel invitation to {top_candidate.email}") + + return JsonResponse({'detail': 'Ranked and invitation sent'}, status=200) + + +@api_view(['POST']) +@permission_classes([AllowAny]) +def examiner_panel_invitation_action(request, token, action): + """GET, token-authenticated -- external examiner has no Fusion account.""" + candidate = get_object_or_404(ThesisExaminerCandidate, token=token) + if candidate.is_expired() or candidate.is_finalized(): + return Response({'error': 'Invalid/expired'}, status=403) + if action == 'accept': + if candidate.status != 'invited': + return Response({'error': 'This invitation has not been sent yet'}, status=403) + candidate.status = 'accepted' + candidate.save(update_fields=['status']) + panel = candidate.panel + panel.accepted_candidate = candidate + panel.status = 'accepted' + panel.save(update_fields=['accepted_candidate', 'status']) + try: + send_examiner_panel_scoring_email(candidate) + except Exception: + logger.exception(f"Failed to send examiner scoring email for token {candidate.token}") + return Response({'detail': 'Accepted'}, status=200) + if action == 'reject': + candidate.status = 'rejected' + candidate.save(update_fields=['status']) + advance_examiner_panel_invitation(candidate.panel) + return Response({'detail': 'Rejected'}, status=200) + return Response({'error': 'Unknown action'}, status=400) + + +@api_view(['GET']) +@permission_classes([AllowAny]) +def examiner_panel_batch_detail(request, token): + """List every student in the panel's batch needing an examiner score.""" + candidate = get_object_or_404(ThesisExaminerCandidate, token=token) + if candidate.status != 'accepted': + return Response({'error': 'This invitation has not been accepted'}, status=403) + + regs = ThesisRegistration.objects.filter( + status='verified', thesis_slot__evaluation_type='decimal', + student__batch_id=candidate.panel.batch, + ).select_related('student__id__user', 'thesis_topic', 'thesis_topic__pg_submission') + + evaluations_by_reg_id = { + ev.registration_id: ev + for ev in ThesisEvaluation.objects.filter(registration__in=regs).select_related('score_inputs') + } + + students = [] + for reg in regs: + evaluation = evaluations_by_reg_id.get(reg.id) + score_inputs = getattr(evaluation, 'score_inputs', None) if evaluation else None + submission = getattr(reg.thesis_topic, 'pg_submission', None) if reg.thesis_topic else None + students.append({ + 'evaluation_id': evaluation.id if evaluation else None, + 'student_name': reg.student.id.user.get_full_name(), + 'student_roll': reg.student.id.id, + 'credits': reg.credits, + 'examiner_score': score_inputs.examiner_score if score_inputs else None, + 'synopsis_url': submission.synopsis.url if submission and submission.synopsis else None, + 'thesis_report_url': submission.thesis_report.url if submission and submission.thesis_report else None, + }) + + return Response({ + 'batch_name': str(candidate.panel.batch), + 'examiner_name': candidate.name, + 'students': students, + }, status=200) + + +@api_view(['POST']) +@permission_classes([AllowAny]) +def examiner_panel_submit_score(request, token): + """Body: { "evaluation_id": , "score": <0-100> }""" + candidate = get_object_or_404(ThesisExaminerCandidate, token=token) + if candidate.status != 'accepted': + return Response({'error': 'This invitation has not been accepted'}, status=403) + + evaluation_id = request.data.get('evaluation_id') + try: + score = round(float(request.data.get('score')), 1) + except (TypeError, ValueError): + return Response({'error': 'Invalid score'}, status=400) + if not (0 <= score <= 100): + return Response({'error': 'Score must be between 0 and 100'}, status=400) + + evaluation = get_object_or_404( + ThesisEvaluation, id=evaluation_id, + registration__student__batch_id=candidate.panel.batch, + registration__thesis_slot__evaluation_type='decimal', + ) + if evaluation.numeric_grade is not None: + return Response({'error': 'This evaluation has already been finalized'}, status=403) + score_inputs, _created = ThesisEvaluationScore.objects.get_or_create(evaluation=evaluation) + score_inputs.examiner_candidate = candidate + score_inputs.examiner_score = score + score_inputs.examiner_scored_at = timezone.now() + score_inputs.save(update_fields=['examiner_candidate', 'examiner_score', 'examiner_scored_at']) + _maybe_finalize_numeric_grade(evaluation) + + return Response({'detail': 'Score recorded'}, status=200) + + +# =========================================================================== +# 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 _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': 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, + 'programme_category': _student_programme_category(reg.student), + } + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def student_progress_seminar_enrollment_api(request): + """ + 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: + 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) + + 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) + + topic = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + topic_approved = topic is not None and topic.status == 'dean_approved' + + 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 + + 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): + """ + 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(semester__semester_no=sem_no) + + status_filter = request.GET.get('status') + if status_filter: + qs = qs.filter(status=status_filter) + + data = [] + for reg in qs: + entry = _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_progress_seminar_enrollments(request): + """ + POST /acadadmin/progress-seminar-enrollments/verify/ + Body: { "ids": [1, 2, 3] } + 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 registration IDs provided'}, status=400) + + now = _dt.datetime.now(_dt.timezone.utc) + regs = ProgressSeminarRegistration.objects.filter(id__in=ids, status='pending') + count = 0 + for reg in regs: + reg.status = 'verified' + reg.save(update_fields=['status']) + ProgressSeminarEvaluation.objects.get_or_create(registration=reg) + count += 1 + _progress_seminar_notify( + sender=request.user, + recipient=reg.student.id.user, + verb='Progress Seminar registration verified', + description="Your Progress Seminar registration for this semester has been verified.", + ) + return JsonResponse({'verified_count': count}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['acadadmin']) +def admin_reject_progress_seminar_enrollments(request): + """ + 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 registration IDs provided'}, status=400) + + pending_qs = ProgressSeminarRegistration.objects.filter(id__in=ids, status='pending') + to_notify = list(pending_qs.select_related('student__id__user')) + updated = pending_qs.update(status='rejected', remarks=remarks) + for reg in to_notify: + _progress_seminar_notify( + sender=request.user, + recipient=reg.student.id.user, + verb='Progress Seminar registration rejected', + description=f"Your Progress Seminar registration for this semester was rejected. " + f"Remarks: {remarks or '—'}", + ) + return JsonResponse({'rejected_count': updated}, status=200) + + +# =========================================================================== +# 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, + 'programme_category': _student_programme_category(reg.student), + } + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def student_teaching_credit_enrollment_api(request): + """ + 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: + 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) + + 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) + + comprehensive_exam_passed = ComprehensiveExam.objects.filter( + student=student, status='passed' + ).exists() + + slot = TeachingCreditSlot.objects.filter(semester=semester).first() + + 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 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) + + 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 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, + ) + + 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, + ) + + 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 or PG (M.Tech/M.Des) programme -- + the two categories that use this lightweight self-submit-and-verify + registration flow instead of the UG-style pre-registration/allocation/ + final-registration pipeline. + 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)'). + PG detection falls back to the curriculum's Programme.category since PG + students don't have an equivalent programme/batch-name shorthand.""" + programme_norm = (student.programme or '').upper().replace('.', '') + batch_name = student.batch_id.name if student.batch_id else '' + if programme_norm == 'PHD' or batch_name.upper().startswith('PHD'): + return True + try: + return student.batch_id.curriculum.programme.category == 'PG' + except AttributeError: + return False + + +def _resolve_phd_student(request): + """Returns (student, error_response). error_response is a JsonResponse + if the requester isn't a valid PhD/PG 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/PG 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, + }, + ) + + _phd_course_registration_notify( + sender=request.user, + recipient=_academic_office_users(), + verb='PhD course registration request pending approval', + description=f"{student.id.user.get_full_name()} has requested registration in " + f"{course.code} - {course.name} ({slot.name}).", + ) + + 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, + 'programme_category': _student_programme_category(r.student), + } 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, + 'programme_category': _student_programme_category(r.student), + } 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']) + _phd_course_registration_notify( + sender=request.user, + recipient=req.student.id.user, + verb='PhD course registration request rejected', + description=f"Your registration request for {req.course.code} was rejected. " + f"Remarks: {remarks or '—'}", + ) + 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']) + _phd_course_registration_notify( + sender=request.user, + recipient=req.student.id.user, + verb='PhD course registration request rejected', + description=f"Your registration request for {req.course.code} was rejected: " + f"already registered for this course.", + ) + results.append({'id': req_id, 'status': 'error', 'detail': 'Already registered'}) + continue + + try: + # Nested atomic block (savepoint): a unique-constraint collision + # here (e.g. two different course_slot requests for the same + # actual course approved concurrently) must only roll back this + # one item, not the whole batch's already-applied results. + with transaction.atomic(): + 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', + ) + except IntegrityError: + 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']) + _phd_course_registration_notify( + sender=request.user, + recipient=req.student.id.user, + verb='PhD course registration request rejected', + description=f"Your registration request for {req.course.code} was rejected: " + f"already registered for this course.", + ) + results.append({'id': req_id, 'status': 'error', 'detail': 'Already registered'}) + continue + + 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']) + _phd_course_registration_notify( + sender=request.user, + recipient=req.student.id.user, + verb='PhD course registration request approved', + description=f"Your registration request for {req.course.code} - " + f"{req.course.name} has been approved.", + ) + 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() + evaluation_type = reg.thesis_slot.evaluation_type + is_decimal = evaluation_type == 'decimal' + + score_inputs = getattr(ev, 'score_inputs', None) if is_decimal else None + submission = None + if is_decimal and reg.thesis_topic: + submission = PGThesisSubmission.objects.filter(thesis=reg.thesis_topic).first() + + return { + 'id': ev.id, + 'block_number': ev.block_number, + 'total_blocks': ev.total_blocks, + 'evaluation_type': evaluation_type, + 'grade': ev.grade, + 'numeric_grade': ev.numeric_grade, + 'supervisor_score': score_inputs.supervisor_score if score_inputs else None, + 'examiner_score': score_inputs.examiner_score if score_inputs else None, + 'synopsis_url': submission.synopsis.url if submission and submission.synopsis else None, + 'thesis_report_url': submission.thesis_report.url if submission and submission.thesis_report else None, + '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, + 'programme_category': _student_programme_category(reg.student), + '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', + 'score_inputs', + ).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" means different things per evaluation_type: blocks_sx uses + # `grade`, decimal uses the supervisor's raw score on ThesisEvaluationScore + # (numeric_grade itself only appears once the examiner also scores). + graded_param = request.GET.get('graded') + not_graded_q = Q(registration__thesis_slot__evaluation_type='decimal', score_inputs__supervisor_score__isnull=True) | \ + Q(registration__thesis_slot__evaluation_type='blocks_sx', grade__isnull=True) + if graded_param == 'false': + qs = qs.filter(not_graded_q) + elif graded_param == 'true': + qs = qs.exclude(not_graded_q) + + 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 + + Decimal-mode (PG final-thesis semester) evaluations never appear here -- + they skip admin verification entirely and go straight to the HOD/examiner + panel flow once the supervisor scores them. + """ + qs = ThesisEvaluation.objects.select_related( + 'registration__student__id__user', + 'registration__semester', + 'registration__thesis_slot', + 'submitted_by__id__user', + 'verified_by', + ).exclude( + registration__thesis_slot__evaluation_type='decimal', + ).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 + ).exclude( + registration__thesis_slot__evaluation_type='decimal', + ).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 Grades' + + # Headers: Name, Roll Number, Grade 1, Grade 2, ..., Remarks + headers = ['Student Name', 'Roll Number'] + headers.extend([f'Grade {b}' 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_{_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, Grade 1, Grade 2, ..., 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 'grade' in col: + # Extract the grade number from "grade N" or similar + match = re.search(r'grade\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 "Grade N" 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 + ).exclude( + registration__thesis_slot__evaluation_type='decimal', + ) + + # 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'Grade {block_num} is required for this student') + continue + + # If grade provided, validate it + if grade not in ('S', 'X'): + row_errors.append(f'Grade {block_num} 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 Grade {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', 'registration__thesis_slot', + ).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 + + if evaluation.registration.thesis_slot.evaluation_type == 'decimal': + errors.append({'index': idx, 'evaluation_id': eval_id, 'error': 'This is a decimal-mode thesis and cannot take an S/X grade'}) + continue + + if evaluation.verified or evaluation.announced: + errors.append({'index': idx, 'evaluation_id': eval_id, 'error': 'Grade already verified/announced; cannot be changed'}) + 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 eligibility -> Academic Office verifies -> +# Convener DPGC (HOD of the student's department) approves -> attempt 1 is +# auto-created (no committee to propose -- the student's existing RPC, +# fetched live via their ThesisTopic, doubles as the examination committee) +# -> RPC collectively records the result + qualitative comments, each member +# consenting like Progress Seminar (any panel edit resets everyone else's +# consent) -> Convener PGCS (also HOD) reviews the finalized result: reject +# sends it back to the RPC for fresh consensus, approve forwards to Dean +# Academic -> Dean Academic gives a forward-only final approval, closing the +# attempt as passed/failed. On failure with attempts remaining (max +# ComprehensiveExam.MAX_ATTEMPTS), the next attempt auto-creates starting +# directly at RPC review -- the Academic Office/DPGC eligibility gate is +# one-time on the exam as a whole, not per-attempt. + +def _exam_rpc_committee(student): + """The student's RPC (Progress Seminar committee, via their most recent + ThesisTopic) -- reused as the Comprehensive Exam examination committee. + Read-only here; RPC membership itself is managed via the Progress + Seminar flow (supervisor_review_api).""" + thesis_topic = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + if not thesis_topic: + return CommitteeMember.objects.none() + return CommitteeMember.objects.filter(thesis=thesis_topic).select_related( + 'member__id__user', 'member__id__department' + ) + + +def _rpc_committee_users(student): + """Users for the student's live RPC committee -- for notifying committee members.""" + return User.objects.filter( + pk__in=_exam_rpc_committee(student).values_list('member__id__user', flat=True) + ).distinct() + + +def _is_thesis_supervisor_or_co(faculty, student): + """Whether `faculty` is the supervisor/co-supervisor on the student's + most recent ThesisTopic -- used to gate Comprehensive Exam / Open + Seminar proposal so an unrelated faculty member can't self-assign as + supervisor for someone else's student.""" + thesis_topic = ThesisTopic.objects.filter(student=student).order_by('-created_at').first() + if not thesis_topic: + return False + return faculty.pk in (thesis_topic.supervisor_id, thesis_topic.co_supervisor_id) + + +def _comprehensive_exam_attempt_to_dict(a): + return { + 'id': a.id, + 'attempt_number': a.attempt_number, + 'status': a.status, + 'exam_date': a.exam_date.isoformat() if a.exam_date else None, + '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, + 'pgcs_remarks': a.pgcs_remarks, + 'pgcs_reviewed_at': a.pgcs_reviewed_at.isoformat() if a.pgcs_reviewed_at else None, + 'dean_approved_at': a.dean_approved_at.isoformat() if a.dean_approved_at else None, + 'consented_count': a.consents.filter( + consented=True, + member_id__in=_exam_rpc_committee(a.exam.student).values_list('member_id', flat=True), + ).count(), + 'committee_size': _exam_rpc_committee(a.exam.student).count(), + } + + +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 _can_set_exam_date(request, attempt): + """Supervisor/co-supervisor or any RPC member may set/update the exam date.""" + if _is_exam_supervisor_or_co(request, attempt.exam): + return True + try: + faculty = Faculty.objects.get(id__user=request.user) + except Faculty.DoesNotExist: + return False + return _exam_rpc_committee(attempt.exam.student).filter(member=faculty).exists() + + +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 RPC 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, + 'semester_no': exam.student.curr_semester_no, + '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, + # A freshly-`.create()`d instance holds whatever raw value was passed + # in (e.g. a plain date string) until reloaded from the DB, so this + # can't assume `.isoformat()` is always safe to call. + 'proposed_exam_date': ( + exam.proposed_exam_date.isoformat() + if hasattr(exam.proposed_exam_date, 'isoformat') + else exam.proposed_exam_date + ), + '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, + 'dpgc_remarks': exam.dpgc_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_rpc_committee(exam.student) + ], + '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) + + +# 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('attempts') + + 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. + """ + if getattr(getattr(request.user, 'extrainfo', None), 'user_type', None) != 'faculty': + return JsonResponse({'error': 'Only faculty can view student academic info.'}, status=403) + 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, proposed_exam_date } + 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. No committee is proposed -- + the student's existing RPC (see _exam_rpc_committee) doubles as the + examination committee. + """ + 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 not _is_thesis_supervisor_or_co(faculty, student): + return JsonResponse({'error': 'You are not this student\'s supervisor or co-supervisor'}, status=403) + + 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) + + 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', ''), + proposed_exam_date=data.get('proposed_exam_date') or None, + entry_qualification=entry_qualification, + credits_completed=_student_completed_credits(student), + current_cpi=student.cpi, + ) + + _comprehensive_exam_notify( + sender=request.user, + recipient=_academic_office_users(), + verb='Comprehensive Exam proposal pending verification', + description=f"{student.id.user.get_full_name()}'s Comprehensive Exam proposal is " + f"awaiting Academic Office eligibility verification.", + ) + + 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) + if not _is_exam_supervisor_or_co(request, exam): + return JsonResponse({'error': 'Not authorized'}, status=403) + 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 eligibility fields after an Academic Office or Convener (DPGC) + 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', 'dpgc_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 'proposed_exam_date' in data: + exam.proposed_exam_date = data['proposed_exam_date'] or None + 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 + + exam.status = 'academic_office_pending' + exam.credits_verified = False + exam.cpi_verified = False + exam.research_methodology_verified = False + exam.academic_office_remarks = '' + exam.dpgc_remarks = '' + exam.save() + + return JsonResponse(comprehensive_exam_to_dict(exam), status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_set_exam_date(request, attempt_pk): + """ + POST /supervisor/comprehensive-exam/attempt//set-exam-date/ + Body: { exam_date } + Settable by the supervisor/co-supervisor or any RPC member, any time + before Dean Academic's final approval -- including while the RPC is + still finalizing their report. + """ + attempt = get_object_or_404(ComprehensiveExamAttempt, pk=attempt_pk) + if attempt.status in ('passed', 'failed'): + return JsonResponse({'error': 'Attempt is already closed'}, status=403) + if not _can_set_exam_date(request, attempt): + return JsonResponse({'error': 'Not authorized'}, status=403) + + exam_date = request.data.get('exam_date') + if not exam_date: + return JsonResponse({'error': 'exam_date is required'}, status=400) + attempt.exam_date = exam_date + attempt.save() + + return JsonResponse(comprehensive_exam_to_dict(attempt.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 (used + by Teaching Credit's course-choice pickers). 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) + + +# 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( + 'attempts' + ).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) + if exam.credits_completed < exam.required_credits: + return JsonResponse({ + 'error': f'Credits completed ({exam.credits_completed}) is below the ' + f'{exam.required_credits} required for this entry qualification.', + }, status=400) + if exam.current_cpi is None or exam.current_cpi < ComprehensiveExam.MIN_CPI: + return JsonResponse({ + 'error': f'Current CPI ({exam.current_cpi}) is below the required minimum ' + f'of {ComprehensiveExam.MIN_CPI}.', + }, status=400) + exam.status = 'dpgc_pending' + exam.save() + student = exam.student + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + _comprehensive_exam_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Comprehensive Exam pending your (DPGC) approval', + description=f"{student.id.user.get_full_name()}'s Comprehensive Exam has been " + f"verified by Academic Office and is awaiting Convener (DPGC) approval.", + ) + _comprehensive_exam_notify( + sender=request.user, + recipient=exam.supervisor.id.user, + verb='Comprehensive Exam verified by Academic Office', + description=f"{student.id.user.get_full_name()}'s Comprehensive Exam has been " + f"verified by Academic Office and forwarded to Convener (DPGC).", + ) + else: + exam.status = 'academic_office_rejected' + exam.save() + _comprehensive_exam_notify( + sender=request.user, + recipient=exam.supervisor.id.user, + verb='Comprehensive Exam rejected by Academic Office', + description=f"Academic Office rejected {exam.student.id.user.get_full_name()}'s " + f"Comprehensive Exam proposal. Remarks: {exam.academic_office_remarks or '—'}", + ) + + return JsonResponse(comprehensive_exam_to_dict(exam), status=200) + + +# 4. Convener DPGC (HOD of the student's department stands in) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def hod_dpgc_comprehensive_exam_dashboard(request): + """GET /hod/comprehensive-exam/dpgc-dashboard/ -> exams pending DPGC approval, + plus a history of already-decided ones, scoped to the HOD's own discipline.""" + hod_disciplines = get_hod_disciplines(request.user) + + def _scoped(qs): + result = [] + for exam in qs: + student = 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: + result.append(exam) + return result + + pending_qs = ComprehensiveExam.objects.filter(status='dpgc_pending').select_related( + 'student__id__user', 'student__batch_id__discipline', 'supervisor__id__user' + ).prefetch_related('attempts') + pending = [comprehensive_exam_to_dict(e) for e in _scoped(pending_qs)] + + history_qs = ComprehensiveExam.objects.filter(dpgc_by__isnull=False).select_related( + 'student__id__user', 'student__batch_id__discipline', 'supervisor__id__user', 'dpgc_by', + ).prefetch_related('attempts').order_by('-dpgc_at') + history = [ + { + **comprehensive_exam_to_dict(e), + 'decision': 'Rejected' if e.status == 'dpgc_rejected' else 'Approved', + 'decided_by': e.dpgc_by.get_full_name() if e.dpgc_by else None, + 'decided_at': e.dpgc_at.isoformat() if e.dpgc_at else None, + 'remarks': e.dpgc_remarks, + } + for e in _scoped(history_qs) + ] + + return JsonResponse({'pending': pending, 'history': history}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def hod_dpgc_approve_comprehensive_exam(request, pk): + """ + POST /hod/comprehensive-exam//dpgc-approve/ + Body: { approve: true|false, remarks } + Approving auto-creates attempt 1, starting directly at RPC review -- + there is no committee to propose, the student's RPC is fetched live. + """ + exam = get_object_or_404(ComprehensiveExam, pk=pk, status='dpgc_pending') + student = exam.student + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + if not is_hod_of_discipline(request.user, acronym): + return JsonResponse({'error': 'Not authorized'}, status=403) + + data = request.data + exam.dpgc_remarks = data.get('remarks', '') + exam.dpgc_by = request.user + exam.dpgc_at = timezone.now() + + if data.get('approve'): + exam.status = 'in_progress' + exam.save() + ComprehensiveExamAttempt.objects.get_or_create( + exam=exam, attempt_number=exam.current_attempt_number, + defaults={'exam_date': exam.proposed_exam_date}, + ) + _comprehensive_exam_notify( + sender=request.user, + recipient=_rpc_committee_users(student), + verb='Comprehensive Exam pending your review', + description=f"{student.id.user.get_full_name()}'s Comprehensive Exam has been " + f"approved by Convener (DPGC) and is awaiting RPC review.", + ) + _comprehensive_exam_notify( + sender=request.user, + recipient=exam.supervisor.id.user, + verb='Comprehensive Exam approved by Convener (DPGC)', + description=f"{student.id.user.get_full_name()}'s Comprehensive Exam has been " + f"approved by Convener (DPGC) and forwarded to the RPC.", + ) + else: + exam.status = 'dpgc_rejected' + exam.save() + _comprehensive_exam_notify( + sender=request.user, + recipient=exam.supervisor.id.user, + verb='Comprehensive Exam rejected by Convener (DPGC)', + description=f"Convener (DPGC) rejected {student.id.user.get_full_name()}'s " + f"Comprehensive Exam. Remarks: {exam.dpgc_remarks or '—'}", + ) + + return JsonResponse(comprehensive_exam_to_dict(exam), status=200) + + +# 5. RPC (the student's existing committee, fetched live) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def rpc_comprehensive_exam_list(request): + """GET /faculty/comprehensive-exam/rpc/ -> attempts where the requester is an RPC member.""" + faculty = get_object_or_404(Faculty, id__user=request.user) + + thesis_ids = ThesisTopic.objects.filter(committee__member=faculty).values_list('id', flat=True) + student_ids = ThesisTopic.objects.filter(id__in=thesis_ids).values_list('student_id', flat=True) + + qs = ComprehensiveExamAttempt.objects.filter(exam__student_id__in=student_ids).select_related( + 'exam__student__id__user', 'exam__supervisor__id__user' + ).distinct() + + def serialize(attempts): + return [ + { + **_comprehensive_exam_attempt_to_dict(a), + 'exam_id': a.exam.id, + 'student_roll': a.exam.student.id.id, + 'student_name': a.exam.student.id.user.get_full_name(), + 'my_consent_given': ComprehensiveExamConsent.objects.filter( + attempt=a, member=faculty, consented=True + ).exists(), + } + for a in attempts + ] + + return JsonResponse({ + 'pending': serialize(qs.filter(status='rpc_pending')), + 'history': serialize(qs.exclude(status='rpc_pending')), + }, status=200) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def rpc_comprehensive_exam_detail(request, attempt_pk): + """GET /faculty/comprehensive-exam/rpc//""" + faculty = get_object_or_404(Faculty, id__user=request.user) + attempt = get_object_or_404(ComprehensiveExamAttempt, pk=attempt_pk) + if not _exam_rpc_committee(attempt.exam.student).filter(member=faculty).exists(): + return JsonResponse({'error': 'Not on committee'}, status=403) + + committee = [] + for cm in _exam_rpc_committee(attempt.exam.student): + fac = cm.member + extra = fac.id + consented = ComprehensiveExamConsent.objects.filter(attempt=attempt, member=fac, consented=True).exists() + committee.append({ + 'id': extra.id, + 'name': f"{extra.user.first_name} {extra.user.last_name}", + 'discipline': extra.department.name if extra.department else '', + 'consented': consented, + }) + + comments = [ + { + 'member': c.member.id.user.get_full_name(), + 'text': c.text, + 'timestamp': c.timestamp.isoformat(), + } + for c in attempt.rpc_comments.all() + ] + + my_comment = ComprehensiveExamRPCComment.objects.filter(attempt=attempt, member=faculty).first() + is_consented = ComprehensiveExamConsent.objects.filter(attempt=attempt, member=faculty, consented=True).exists() + + exam = attempt.exam + payload = { + **_comprehensive_exam_attempt_to_dict(attempt), + 'exam_id': exam.id, + 'student_name': exam.student.id.user.get_full_name(), + 'student_roll': exam.student.id.id, + 'student_discipline': exam.student.specialization, + 'possible_thesis_title': exam.possible_thesis_title, + 'supervisor': { + 'id': exam.supervisor.id.id, + 'name': str(exam.supervisor), + }, + 'co_supervisor': ( + {'id': exam.co_supervisor.id.id, 'name': str(exam.co_supervisor)} + if exam.co_supervisor else None + ), + 'committee': committee, + 'committee_size': len(committee), + 'consented_count': sum(1 for m in committee if m['consented']), + 'comments': comments, + 'my_comment': my_comment.text if my_comment else '', + 'is_consented': is_consented, + } + return JsonResponse(payload, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def rpc_comprehensive_exam_consent(request, attempt_pk): + """ + POST /faculty/comprehensive-exam/rpc//consent/ + Body: { result, fundamentals_comment, problem_identification_comment, + plan_of_work_comment, suggestions_comment, + additional_literature_comment, exam_date, comment, milestone_plan (file) } + Accepts either JSON (no file) or multipart (to attach milestone_plan) -- + DRF's default parsers handle both, unlike the file-only endpoints + elsewhere in this app that pin MultiPartParser/FormParser explicitly. + Any edit to the shared panel resets everyone else's consent -- mirrors + Progress Seminar's rpc_consent. + """ + faculty = get_object_or_404(Faculty, id__user=request.user) + attempt = get_object_or_404(ComprehensiveExamAttempt, pk=attempt_pk, status='rpc_pending') + if not _exam_rpc_committee(attempt.exam.student).filter(member=faculty).exists(): + return JsonResponse({'error': 'Not on committee'}, status=403) + + data = request.data + if data.get('result') and data['result'] not in dict(ComprehensiveExamAttempt.RESULT_CHOICES): + return JsonResponse({'error': 'Invalid result value'}, status=400) + + panel_fields = [ + 'result', 'fundamentals_comment', 'problem_identification_comment', + 'plan_of_work_comment', 'suggestions_comment', 'additional_literature_comment', + ] + + changed = any( + field in data and getattr(attempt, field) != data[field] + for field in panel_fields + ) + if data.get('exam_date'): + old_exam_date = attempt.exam_date.isoformat() if attempt.exam_date else None + if data['exam_date'] != old_exam_date: + changed = True + if request.FILES.get('milestone_plan'): + changed = True + if changed: + ComprehensiveExamConsent.objects.filter(attempt=attempt).update(consented=False) + + for field in panel_fields: + if field in data: + setattr(attempt, field, data[field]) + if data.get('exam_date'): + attempt.exam_date = data['exam_date'] + if request.FILES.get('milestone_plan'): + attempt.milestone_plan_upload = request.FILES['milestone_plan'] + attempt.save() + + if 'comment' in data: + ComprehensiveExamRPCComment.objects.update_or_create( + attempt=attempt, member=faculty, defaults={'text': data['comment']}, + ) + + consent_obj, _created = ComprehensiveExamConsent.objects.get_or_create(attempt=attempt, member=faculty) + consent_obj.consented = True + consent_obj.save() + + return JsonResponse({'message': 'Consent & data recorded.'}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def rpc_comprehensive_exam_finalize(request, attempt_pk): + """ + POST /faculty/comprehensive-exam/rpc//finalize/ + Requires every RPC member to have consented and a result to have been + recorded; forwards to Convener PGCS. + """ + faculty = get_object_or_404(Faculty, id__user=request.user) + attempt = get_object_or_404(ComprehensiveExamAttempt, pk=attempt_pk, status='rpc_pending') + if not _exam_rpc_committee(attempt.exam.student).filter(member=faculty).exists(): + return JsonResponse({'error': 'Not on committee'}, status=403) + + if not attempt.result: + return JsonResponse({'error': 'Record a result before finalizing'}, status=400) + + current_committee_ids = _exam_rpc_committee(attempt.exam.student).values_list('member_id', flat=True) + total = len(current_committee_ids) + yes = ComprehensiveExamConsent.objects.filter( + attempt=attempt, consented=True, member_id__in=current_committee_ids, + ).count() + if total == 0 or yes < total: + return JsonResponse({'error': 'Not all RPC members have consented'}, status=400) + + attempt.status = 'pgcs_pending' + attempt.reported_by = request.user + attempt.reported_at = timezone.now() + # Starting a fresh PGCS review cycle -- an earlier rejection's + # reviewed_by/at/remarks no longer apply and would otherwise make this + # attempt look like already-decided history while it's still pending. + attempt.pgcs_reviewed_by = None + attempt.pgcs_reviewed_at = None + attempt.pgcs_remarks = '' + attempt.save() + + exam = attempt.exam + student = exam.student + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + _comprehensive_exam_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Comprehensive Exam pending your (PGCS) review', + description=f"The RPC has finalized {student.id.user.get_full_name()}'s Comprehensive " + f"Exam (attempt {attempt.attempt_number}); awaiting Convener (PGCS) review.", + ) + _comprehensive_exam_notify( + sender=request.user, + recipient=exam.supervisor.id.user, + verb='Comprehensive Exam RPC review finalized', + description=f"The RPC has finalized {student.id.user.get_full_name()}'s Comprehensive " + f"Exam (attempt {attempt.attempt_number}); forwarded to Convener (PGCS).", + ) + + return JsonResponse(comprehensive_exam_to_dict(attempt.exam), status=200) + + +# 6. Convener PGCS (HOD of the student's department stands in) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def hod_pgcs_comprehensive_exam_dashboard(request): + """GET /hod/comprehensive-exam/pgcs-dashboard/ -> attempts pending PGCS review, + plus a history of already-decided ones, scoped to the HOD's own discipline.""" + hod_disciplines = get_hod_disciplines(request.user) + + def _scoped(qs): + result = [] + 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: + result.append(attempt) + return result + + pending_qs = ComprehensiveExamAttempt.objects.filter(status='pgcs_pending').select_related( + 'exam__student__id__user', 'exam__student__batch_id__discipline', 'exam__supervisor__id__user' + ).prefetch_related('exam__attempts') + pending = [comprehensive_exam_to_dict(a.exam) for a in _scoped(pending_qs)] + + history_qs = ComprehensiveExamAttempt.objects.filter(pgcs_reviewed_by__isnull=False).select_related( + 'exam__student__id__user', 'exam__student__batch_id__discipline', 'exam__supervisor__id__user', + 'pgcs_reviewed_by', + ).prefetch_related('exam__attempts').order_by('-pgcs_reviewed_at') + history = [ + { + **comprehensive_exam_to_dict(a.exam), + 'decision': 'Rejected' if a.status == 'rpc_pending' else 'Approved', + 'decided_by': a.pgcs_reviewed_by.get_full_name() if a.pgcs_reviewed_by else None, + 'decided_at': a.pgcs_reviewed_at.isoformat() if a.pgcs_reviewed_at else None, + 'attempt_number': a.attempt_number, + 'remarks': a.pgcs_remarks, + } + for a in _scoped(history_qs) + ] + + return JsonResponse({'pending': pending, 'history': history}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def hod_pgcs_review_comprehensive_exam(request, attempt_pk): + """ + POST /hod/comprehensive-exam/attempt//pgcs-review/ + Body: { approve: true|false, remarks } + Rejecting sends it back to the RPC for fresh consensus (all consents reset). + """ + attempt = get_object_or_404(ComprehensiveExamAttempt, pk=attempt_pk, status='pgcs_pending') + student = attempt.exam.student + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + if not is_hod_of_discipline(request.user, acronym): + return JsonResponse({'error': 'Not authorized'}, status=403) + + data = request.data + attempt.pgcs_reviewed_by = request.user + attempt.pgcs_reviewed_at = timezone.now() + if data.get('approve'): + attempt.pgcs_remarks = '' + attempt.status = 'dean_pending' + attempt.save() + _comprehensive_exam_notify( + sender=request.user, + recipient=_dean_academic_users(), + verb='Comprehensive Exam pending your final approval', + description=f"{student.id.user.get_full_name()}'s Comprehensive Exam has been " + f"approved by Convener (PGCS) and is awaiting your final approval.", + ) + _comprehensive_exam_notify( + sender=request.user, + recipient=attempt.exam.supervisor.id.user, + verb='Comprehensive Exam approved by Convener (PGCS)', + description=f"{student.id.user.get_full_name()}'s Comprehensive Exam has been " + f"approved by Convener (PGCS) and forwarded to Dean Academic.", + ) + else: + attempt.pgcs_remarks = data.get('remarks', '') + attempt.status = 'rpc_pending' + attempt.save() + ComprehensiveExamConsent.objects.filter(attempt=attempt).update(consented=False) + _comprehensive_exam_notify( + sender=request.user, + recipient=_rpc_committee_users(student), + verb='Comprehensive Exam sent back by Convener (PGCS)', + description=f"Convener (PGCS) sent {student.id.user.get_full_name()}'s Comprehensive " + f"Exam back to the RPC for fresh consensus. Remarks: {attempt.pgcs_remarks or '—'}", + ) + + return JsonResponse(comprehensive_exam_to_dict(attempt.exam), status=200) + + +# 7. Dean Academic (forward-only final approval) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_comprehensive_exam_dashboard(request): + """GET /dean/comprehensive-exam/dashboard/ -> attempts approved by PGCS, pending final approval.""" + qs = ComprehensiveExamAttempt.objects.filter(status='dean_pending').select_related( + 'exam__student__id__user', 'exam__supervisor__id__user' + ).prefetch_related('exam__attempts') + + return JsonResponse({ + 'pending': [comprehensive_exam_to_dict(a.exam) for a in qs], + }, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_approve_comprehensive_exam(request, attempt_pk): + """ + POST /dean/comprehensive-exam/attempt//approve/ + Forward-only -- closes the attempt as passed/failed (whichever the RPC + already decided). On failure with attempts remaining, auto-creates the + next attempt starting directly at RPC review. + """ + attempt = get_object_or_404(ComprehensiveExamAttempt, pk=attempt_pk, status='dean_pending') + exam = attempt.exam + + attempt.dean_approved_by = request.user + attempt.dean_approved_at = timezone.now() + attempt.status = attempt.result + attempt.save() + + if attempt.result == 'passed': + exam.status = 'passed' + exam.save() + result_desc = "passed the Comprehensive Exam." + elif exam.current_attempt_number < ComprehensiveExam.MAX_ATTEMPTS: + exam.current_attempt_number += 1 + exam.save() + ComprehensiveExamAttempt.objects.get_or_create(exam=exam, attempt_number=exam.current_attempt_number) + result_desc = (f"not cleared attempt {attempt.attempt_number} of the Comprehensive Exam. " + f"A new attempt has been created, starting at RPC review.") + else: + exam.status = 'failed_final' + exam.save() + result_desc = "failed the Comprehensive Exam with all attempts exhausted." + + student = exam.student + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + for recipient in [student.id.user, exam.supervisor.id.user]: + _comprehensive_exam_notify( + sender=request.user, + recipient=recipient, + verb='Comprehensive Exam result declared', + description=f"{student.id.user.get_full_name()} has {result_desc}", + ) + _comprehensive_exam_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Comprehensive Exam result declared', + description=f"{student.id.user.get_full_name()} has {result_desc}", + ) + + return JsonResponse(comprehensive_exam_to_dict(exam), status=200) + + +# =========================================================================== +# Open Seminar +# =========================================================================== +# Workflow: Supervisor proposes eligibility -> Convener DPGC (HOD of the +# student's department) reviews -> Dean Academic appoints the Dean Nominee +# and approves -> attempt 1 auto-creates, starting directly at RPC review +# (no committee to propose -- the student's existing RPC, fetched live via +# their ThesisTopic, doubles as the examination committee) -> RPC +# collectively records the result + comments, each member consenting like +# Comprehensive Exam/Progress Seminar -> Convener DPGC reviews the finalized +# result a second time: reject sends it back to the RPC for fresh +# consensus, approve forwards to Dean Academic -> Dean Academic's dashboard +# shows the committee's verdict together with the Dean Nominee's +# confidential report, and gives a forward-only final approval, closing the +# attempt as satisfactory/not_satisfactory. On not_satisfactory, the next +# attempt auto-creates starting directly at RPC review -- the Convener/Dean +# early gate (and Dean Nominee appointment) is one-time on the OpenSeminar +# as a whole, not per-attempt. + +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 _can_set_seminar_date(request, attempt): + """Supervisor/co-supervisor or any RPC member may set/update the seminar date.""" + if _is_open_seminar_supervisor_or_co(request, attempt.open_seminar): + return True + try: + faculty = Faculty.objects.get(id__user=request.user) + except Faculty.DoesNotExist: + return False + return _exam_rpc_committee(attempt.open_seminar.student).filter(member=faculty).exists() + + +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 graded-and-announced + ThesisEvaluation blocks; teaching_credits sums each satisfactorily-completed + TeachingCreditAllocation semester at its own catalog credit value (see + resolve_teaching_credit_credit); rpc_recommended_open_seminar reads the + latest approved seminar's rec_open field. + """ + 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') + + # Registered credits (ThesisRegistration.credits) are just what the student + # signed up for -- actual earned credit only counts once a block is graded + # Satisfactory and the result announced (each block = 3 credits). + thesis_research_credits = ThesisEvaluation.objects.filter( + registration__student=student, grade='S', announced=True, + ).count() * 3 + + # Registering for teaching credit (TeachingCreditRegistration) or being + # allocated a course (TeachingCreditAllocation) isn't earning the credit -- + # only a semester marked 'completed'/'satisfactory' counts, at that + # semester's own catalog credit value. + teaching_credits = sum( + resolve_teaching_credit_credit(student, alloc.semester) + for alloc in TeachingCreditAllocation.objects.filter( + student=student, status='completed', result='satisfactory', + ) + ) + + return { + 'course_work_credits': course_work_credits, + 'progress_seminar_credits': progress_seminar_credits, + 'thesis_research_credits': thesis_research_credits, + 'teaching_credits': teaching_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, + 'seminar_date': a.seminar_date.isoformat() if a.seminar_date else None, + 'result': a.result, + 'committee_comments': a.committee_comments, + 'reported_at': a.reported_at.isoformat() if a.reported_at else None, + 'hod_review_remarks': a.hod_review_remarks, + 'hod_reviewed_at': a.hod_reviewed_at.isoformat() if a.hod_reviewed_at else None, + 'dean_approved_at': a.dean_approved_at.isoformat() if a.dean_approved_at else None, + 'dean_nominee': ( + {'id': a.dean_nominee.id.id, 'name': str(a.dean_nominee)} + if a.dean_nominee else None + ), + 'dn_submitted_at': a.dn_submitted_at.isoformat() if a.dn_submitted_at else None, + 'consented_count': a.consents.filter( + consented=True, + member_id__in=_exam_rpc_committee(a.open_seminar.student).values_list('member_id', flat=True), + ).count(), + 'committee_size': _exam_rpc_committee(a.open_seminar.student).count(), + 'rpc_comments': [ + { + 'member': c.member.id.user.get_full_name(), + 'text': c.text, + 'timestamp': c.timestamp.isoformat(), + } + for c in a.rpc_comments.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 RPC committee & attempts). Confidential + Dean-Nominee fields are only included for Dean/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, + 'semester_no': seminar.student.curr_semester_no, + '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, + # A freshly-`.create()`d instance holds whatever raw value was passed + # in (e.g. a plain date string) until reloaded from the DB, so this + # can't assume `.isoformat()` is always safe to call. + 'proposed_date': ( + seminar.proposed_date.isoformat() + if hasattr(seminar.proposed_date, 'isoformat') + else seminar.proposed_date + ), + 'course_work_credits': seminar.course_work_credits, + 'progress_seminar_credits': seminar.progress_seminar_credits, + 'thesis_research_credits': seminar.thesis_research_credits, + 'teaching_credits': seminar.teaching_credits, + 'total_credits': seminar.total_credits, + 'semesters_completed': seminar.semesters_completed, + 'rpc_recommended_open_seminar': seminar.rpc_recommended_open_seminar, + 'first_draft_document_url': seminar.first_draft_document.url if seminar.first_draft_document else None, + 'hod_remarks': seminar.hod_remarks, + 'dean_remarks': seminar.dean_remarks, + 'status': seminar.status, + 'current_attempt_number': seminar.current_attempt_number, + '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_rpc_committee(seminar.student) + ], + 'attempts': [ + _open_seminar_attempt_to_dict(a, include_confidential) + for a in seminar.attempts.order_by('attempt_number') + ], + } + + +# 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 -- + 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').prefetch_related('attempts') + + 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, + first_draft_document (file) } + course_work/progress_seminar/thesis_research/teaching credits, semesters_completed, + and rpc_recommended_open_seminar are computed server-side. No committee + is proposed -- the student's existing RPC (see _exam_rpc_committee) + doubles as the examination committee. + """ + 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 not _is_thesis_supervisor_or_co(faculty, student): + return JsonResponse({'error': 'You are not this student\'s supervisor or co-supervisor'}, status=403) + + if OpenSeminar.objects.filter(student=student).exists(): + return JsonResponse({'error': 'Open Seminar already exists for this student'}, status=400) + + if not ComprehensiveExam.objects.filter(student=student, status='passed').exists(): + return JsonResponse( + {'error': 'Comprehensive Examination must be passed before proposing Open Seminar.'}, + status=403, + ) + + eligibility = _compute_open_seminar_eligibility(student) + if not eligibility['rpc_recommended_open_seminar']: + return JsonResponse( + {'error': 'The RPC has not recommended this student for Open Seminar yet.'}, + status=403, + ) + + 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', ''), + proposed_date=data.get('proposed_date') or None, + first_draft_document=request.FILES.get('first_draft_document'), + **eligibility, + ) + + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + _open_seminar_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Open Seminar constitution pending your review', + description=f"{student.id.user.get_full_name()}'s Open Seminar has been proposed and " + f"is awaiting Convener (DPGC) review.", + ) + + 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) + if not _is_open_seminar_supervisor_or_co(request, seminar): + return JsonResponse({'error': 'Not authorized'}, status=403) + 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 eligibility fields after a Convener (DPGC) or Dean Academic + rejection, and resends for Convener (DPGC) review. + """ + 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) + if seminar.status not in ('hod_rejected', 'dean_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 + if 'proposed_date' in data: + seminar.proposed_date = data['proposed_date'] or None + if request.FILES.get('first_draft_document'): + seminar.first_draft_document = request.FILES['first_draft_document'] + + # Re-derive from the student's own records rather than trusting client input. + eligibility = _compute_open_seminar_eligibility(seminar.student) + if not eligibility['rpc_recommended_open_seminar']: + return JsonResponse( + {'error': 'The RPC has not recommended this student for Open Seminar yet.'}, + status=403, + ) + for field, value in eligibility.items(): + setattr(seminar, field, value) + + seminar.status = 'hod_pending' + seminar.hod_remarks = '' + seminar.dean_remarks = '' + seminar.save() + + return JsonResponse(open_seminar_to_dict(seminar), status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def supervisor_set_seminar_date(request, attempt_pk): + """ + POST /supervisor/open-seminar/attempt//set-seminar-date/ + Body: { seminar_date } + Settable by the supervisor/co-supervisor or any RPC member, any time + before Dean Academic's final approval -- including while the RPC is + still finalizing their report. + """ + attempt = get_object_or_404(OpenSeminarAttempt, pk=attempt_pk) + if attempt.status in ('satisfactory', 'not_satisfactory'): + return JsonResponse({'error': 'Attempt is already closed'}, status=403) + if not _can_set_seminar_date(request, attempt): + return JsonResponse({'error': 'Not authorized'}, status=403) + + seminar_date = request.data.get('seminar_date') + if not seminar_date: + return JsonResponse({'error': 'seminar_date is required'}, status=400) + attempt.seminar_date = seminar_date + attempt.save() + + return JsonResponse(open_seminar_to_dict(attempt.open_seminar), status=200) + + +# 3. Convener DPGC, early review (HOD of the student's department stands in) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def hod_dpgc_open_seminar_dashboard(request): + """GET /hod/open-seminar/dpgc-dashboard/ -> seminars pending DPGC review, + plus a history of already-decided ones, scoped to the HOD's own discipline.""" + hod_disciplines = get_hod_disciplines(request.user) + + def _scoped(qs): + result = [] + for seminar in qs: + student = seminar.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: + result.append(seminar) + return result + + pending_qs = OpenSeminar.objects.filter(status='hod_pending').select_related( + 'student__id__user', 'student__batch_id__discipline', 'supervisor__id__user' + ) + pending = [open_seminar_to_dict(s) for s in _scoped(pending_qs)] + + history_qs = OpenSeminar.objects.filter(hod_by__isnull=False).select_related( + 'student__id__user', 'student__batch_id__discipline', 'supervisor__id__user', 'hod_by', + ).order_by('-hod_at') + history = [ + { + **open_seminar_to_dict(s), + 'decision': 'Rejected' if s.status == 'hod_rejected' else 'Approved', + 'decided_by': s.hod_by.get_full_name() if s.hod_by else None, + 'decided_at': s.hod_at.isoformat() if s.hod_at else None, + 'remarks': s.hod_remarks, + } + for s in _scoped(history_qs) + ] + + return JsonResponse({'pending': pending, 'history': history}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def hod_dpgc_review_open_seminar(request, pk): + """ + POST /hod/open-seminar//dpgc-review/ + Body: { approve: true|false, remarks } + """ + seminar = get_object_or_404(OpenSeminar, pk=pk, status='hod_pending') + student = seminar.student + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + if not is_hod_of_discipline(request.user, acronym): + return JsonResponse({'error': 'Not authorized'}, status=403) + + data = request.data + seminar.hod_remarks = data.get('remarks', '') + seminar.hod_by = request.user + seminar.hod_at = timezone.now() + seminar.status = 'dean_pending' if data.get('approve') else 'hod_rejected' + seminar.save() + + if data.get('approve'): + _open_seminar_notify( + sender=request.user, + recipient=_dean_academic_users(), + verb='Open Seminar pending nominee appointment', + description=f"{student.id.user.get_full_name()}'s Open Seminar has been approved by " + f"Convener (DPGC) and needs a Dean Nominee appointed.", + ) + _open_seminar_notify( + sender=request.user, + recipient=seminar.supervisor.id.user, + verb='Open Seminar approved by Convener (DPGC)', + description=f"{student.id.user.get_full_name()}'s Open Seminar has been approved by " + f"Convener (DPGC) and forwarded to Dean Academic.", + ) + else: + _open_seminar_notify( + sender=request.user, + recipient=seminar.supervisor.id.user, + verb='Open Seminar rejected by Convener (DPGC)', + description=f"Convener (DPGC) rejected {student.id.user.get_full_name()}'s Open " + f"Seminar. Remarks: {seminar.hod_remarks or '—'}", + ) + + return JsonResponse(open_seminar_to_dict(seminar), status=200) + + +# 4. Dean Academic (appoints the Dean Nominee early; forward-only final approval) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_open_seminar_dashboard(request): + """ + GET /dean/open-seminar/dashboard/ + -> pending nominee appointments + pending final approvals (the latter + include the committee's verdict together with the Dean Nominee's + confidential report, shown side by side). + """ + pending_appointment = OpenSeminar.objects.filter(status='dean_pending').select_related( + 'student__id__user', 'supervisor__id__user' + ).prefetch_related('attempts') + pending_final = OpenSeminarAttempt.objects.filter(status='dean_pending').select_related( + 'open_seminar__student__id__user', 'open_seminar__supervisor__id__user' + ).prefetch_related('open_seminar__attempts') + + return JsonResponse({ + 'pending_appointment': [open_seminar_to_dict(s) for s in pending_appointment], + 'pending_final': [open_seminar_to_dict(a.open_seminar, include_confidential=True) for a in pending_final], + }, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_appoint_nominee_open_seminar(request, pk): + """ + POST /dean/open-seminar//appoint-nominee/ + Body: { approve: true|false, dean_nominee_id, remarks } + Approving requires appointing a Dean Nominee and auto-creates attempt 1, + starting directly at RPC review -- there is no committee to propose, + the student's RPC is fetched live. + """ + seminar = get_object_or_404(OpenSeminar, pk=pk, status='dean_pending') + data = request.data + + seminar.dean_remarks = data.get('remarks', '') + seminar.dean_by = request.user + seminar.dean_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) + + conflicted_ids = {seminar.supervisor_id} + if seminar.co_supervisor_id: + conflicted_ids.add(seminar.co_supervisor_id) + conflicted_ids.update(_exam_rpc_committee(seminar.student).values_list('member_id', flat=True)) + if dean_nominee_id in conflicted_ids: + return JsonResponse({ + 'error': 'The Dean Nominee must be independent of the student\'s supervisor, ' + 'co-supervisor, and RPC committee.', + }, status=400) + + seminar.status = 'in_progress' + seminar.save() + OpenSeminarAttempt.objects.get_or_create( + open_seminar=seminar, attempt_number=seminar.current_attempt_number, + defaults={'seminar_date': seminar.proposed_date, 'dean_nominee_id': dean_nominee_id}, + ) + student = seminar.student + nominee = Faculty.objects.filter(pk=dean_nominee_id).select_related('id__user').first() + if nominee: + _open_seminar_notify( + sender=request.user, + recipient=nominee.id.user, + verb='You have been appointed Dean Nominee', + description=f"You have been appointed Dean Nominee for " + f"{student.id.user.get_full_name()}'s Open Seminar.", + ) + _open_seminar_notify( + sender=request.user, + recipient=_rpc_committee_users(student), + verb='Open Seminar pending your review', + description=f"{student.id.user.get_full_name()}'s Open Seminar has been approved by " + f"Dean Academic and is awaiting RPC review.", + ) + _open_seminar_notify( + sender=request.user, + recipient=seminar.supervisor.id.user, + verb='Open Seminar approved by Dean Academic', + description=f"{student.id.user.get_full_name()}'s Open Seminar has been approved by " + f"Dean Academic and forwarded to the RPC.", + ) + else: + seminar.status = 'dean_rejected' + seminar.save() + _open_seminar_notify( + sender=request.user, + recipient=seminar.supervisor.id.user, + verb='Open Seminar rejected by Dean Academic', + description=f"Dean Academic rejected {seminar.student.id.user.get_full_name()}'s " + f"Open Seminar. Remarks: {seminar.dean_remarks or '—'}", + ) + + return JsonResponse(open_seminar_to_dict(seminar), status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@role_required(['Dean Academic']) +def dean_approve_open_seminar(request, attempt_pk): + """ + POST /dean/open-seminar/attempt//approve/ + Forward-only -- closes the attempt as satisfactory/not_satisfactory + (whichever the RPC already decided). On not_satisfactory, auto-creates + the next attempt starting directly at RPC review (no new Dean Nominee). + """ + attempt = get_object_or_404(OpenSeminarAttempt, pk=attempt_pk, status='dean_pending') + seminar = attempt.open_seminar + + attempt.dean_approved_by = request.user + attempt.dean_approved_at = timezone.now() + attempt.status = attempt.result + attempt.save() + + if attempt.result == 'satisfactory': + seminar.status = 'satisfactory' + seminar.save() + result_desc = "completed the Open Seminar satisfactorily." + else: + next_number = seminar.current_attempt_number + 1 + seminar.current_attempt_number = next_number + seminar.save() + OpenSeminarAttempt.objects.get_or_create(open_seminar=seminar, attempt_number=next_number) + result_desc = (f"not cleared attempt {attempt.attempt_number} of the Open Seminar. " + f"A new attempt has been created, starting at RPC review.") + + student = seminar.student + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + for recipient in [student.id.user, seminar.supervisor.id.user]: + _open_seminar_notify( + sender=request.user, + recipient=recipient, + verb='Open Seminar result declared', + description=f"{student.id.user.get_full_name()} has {result_desc}", + ) + _open_seminar_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Open Seminar result declared', + description=f"{student.id.user.get_full_name()} has {result_desc}", + ) + + return JsonResponse(open_seminar_to_dict(seminar), status=200) + + +# 5. RPC (the student's existing committee, fetched live) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def rpc_open_seminar_list(request): + """GET /faculty/open-seminar/rpc/ -> attempts where the requester is an RPC member.""" + faculty = get_object_or_404(Faculty, id__user=request.user) + + thesis_ids = ThesisTopic.objects.filter(committee__member=faculty).values_list('id', flat=True) + student_ids = ThesisTopic.objects.filter(id__in=thesis_ids).values_list('student_id', flat=True) + + qs = OpenSeminarAttempt.objects.filter(open_seminar__student_id__in=student_ids).select_related( + 'open_seminar__student__id__user', 'open_seminar__supervisor__id__user' + ).distinct() + + def serialize(attempts): + return [ + { + **_open_seminar_attempt_to_dict(a), + 'seminar_id': a.open_seminar.id, + 'student_roll': a.open_seminar.student.id.id, + 'student_name': a.open_seminar.student.id.user.get_full_name(), + 'my_consent_given': OpenSeminarConsent.objects.filter( + attempt=a, member=faculty, consented=True + ).exists(), + } + for a in attempts + ] + + return JsonResponse({ + 'pending': serialize(qs.filter(status='rpc_pending')), + 'history': serialize(qs.exclude(status='rpc_pending')), + }, status=200) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def rpc_open_seminar_detail(request, attempt_pk): + """GET /faculty/open-seminar/rpc//""" + faculty = get_object_or_404(Faculty, id__user=request.user) + attempt = get_object_or_404(OpenSeminarAttempt, pk=attempt_pk) + if not _exam_rpc_committee(attempt.open_seminar.student).filter(member=faculty).exists(): + return JsonResponse({'error': 'Not on committee'}, status=403) + + committee = [] + for cm in _exam_rpc_committee(attempt.open_seminar.student): + fac = cm.member + extra = fac.id + consented = OpenSeminarConsent.objects.filter(attempt=attempt, member=fac, consented=True).exists() + committee.append({ + 'id': extra.id, + 'name': f"{extra.user.first_name} {extra.user.last_name}", + 'discipline': extra.department.name if extra.department else '', + 'consented': consented, + }) + + comments = [ + { + 'member': c.member.id.user.get_full_name(), + 'text': c.text, + 'timestamp': c.timestamp.isoformat(), + } + for c in attempt.rpc_comments.all() + ] + + my_comment = OpenSeminarRPCComment.objects.filter(attempt=attempt, member=faculty).first() + is_consented = OpenSeminarConsent.objects.filter(attempt=attempt, member=faculty, consented=True).exists() + + seminar = attempt.open_seminar + payload = { + **_open_seminar_attempt_to_dict(attempt), + 'seminar_id': seminar.id, + 'student_name': seminar.student.id.user.get_full_name(), + 'student_roll': seminar.student.id.id, + 'student_discipline': seminar.student.specialization, + 'possible_thesis_title': seminar.possible_thesis_title, + 'supervisor': {'id': seminar.supervisor.id.id, 'name': str(seminar.supervisor)}, + 'co_supervisor': ( + {'id': seminar.co_supervisor.id.id, 'name': str(seminar.co_supervisor)} + if seminar.co_supervisor else None + ), + 'committee': committee, + 'committee_size': len(committee), + 'consented_count': sum(1 for m in committee if m['consented']), + 'comments': comments, + 'my_comment': my_comment.text if my_comment else '', + 'is_consented': is_consented, + } + return JsonResponse(payload, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def rpc_open_seminar_consent(request, attempt_pk): + """ + POST /faculty/open-seminar/rpc//consent/ + Body: { result, committee_comments, seminar_date, comment } + Any edit to the shared panel resets everyone else's consent -- mirrors + Progress Seminar's rpc_consent / Comprehensive Exam's RPC consent. + """ + faculty = get_object_or_404(Faculty, id__user=request.user) + attempt = get_object_or_404(OpenSeminarAttempt, pk=attempt_pk, status='rpc_pending') + if not _exam_rpc_committee(attempt.open_seminar.student).filter(member=faculty).exists(): + return JsonResponse({'error': 'Not on committee'}, status=403) + + data = request.data + if data.get('result') and data['result'] not in dict(OpenSeminarAttempt.RESULT_CHOICES): + return JsonResponse({'error': 'Invalid result value'}, status=400) + + panel_fields = ['result', 'committee_comments'] + + changed = any( + field in data and getattr(attempt, field) != data[field] + for field in panel_fields + ) + if changed: + OpenSeminarConsent.objects.filter(attempt=attempt).update(consented=False) + + for field in panel_fields: + if field in data: + setattr(attempt, field, data[field]) + if data.get('seminar_date'): + attempt.seminar_date = data['seminar_date'] + attempt.save() + + if 'comment' in data: + OpenSeminarRPCComment.objects.update_or_create( + attempt=attempt, member=faculty, defaults={'text': data['comment']}, + ) + + consent_obj, _created = OpenSeminarConsent.objects.get_or_create(attempt=attempt, member=faculty) + consent_obj.consented = True + consent_obj.save() + + return JsonResponse({'message': 'Consent & data recorded.'}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def rpc_open_seminar_finalize(request, attempt_pk): + """ + POST /faculty/open-seminar/rpc//finalize/ + Requires every RPC member to have consented and a result to have been + recorded; forwards to Convener (DPGC). + """ + faculty = get_object_or_404(Faculty, id__user=request.user) + attempt = get_object_or_404(OpenSeminarAttempt, pk=attempt_pk, status='rpc_pending') + if not _exam_rpc_committee(attempt.open_seminar.student).filter(member=faculty).exists(): + return JsonResponse({'error': 'Not on committee'}, status=403) + + if not attempt.result: + return JsonResponse({'error': 'Record a result before finalizing'}, status=400) + + current_committee_ids = _exam_rpc_committee(attempt.open_seminar.student).values_list('member_id', flat=True) + total = len(current_committee_ids) + yes = OpenSeminarConsent.objects.filter( + attempt=attempt, consented=True, member_id__in=current_committee_ids, + ).count() + if total == 0 or yes < total: + return JsonResponse({'error': 'Not all RPC members have consented'}, status=400) + + attempt.status = 'hod_review_pending' + attempt.reported_by = request.user + attempt.reported_at = timezone.now() + # Starting a fresh Convener (DPGC) review cycle -- an earlier rejection's + # reviewed_by/at/remarks no longer apply and would otherwise make this + # attempt look like already-decided history while it's still pending. + attempt.hod_reviewed_by = None + attempt.hod_reviewed_at = None + attempt.hod_review_remarks = '' + attempt.save() + + seminar = attempt.open_seminar + student = seminar.student + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + _open_seminar_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Open Seminar pending your (post-RPC) review', + description=f"The RPC has finalized {student.id.user.get_full_name()}'s Open Seminar " + f"(attempt {attempt.attempt_number}); awaiting Convener (DPGC) review.", + ) + _open_seminar_notify( + sender=request.user, + recipient=seminar.supervisor.id.user, + verb='Open Seminar RPC review finalized', + description=f"The RPC has finalized {student.id.user.get_full_name()}'s Open Seminar " + f"(attempt {attempt.attempt_number}); forwarded to Convener (DPGC).", + ) + + return JsonResponse(open_seminar_to_dict(attempt.open_seminar), status=200) + + +# 6. Convener DPGC, second review (HOD of the student's department stands in) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def hod_review_open_seminar_dashboard(request): + """GET /hod/open-seminar/review-dashboard/ -> attempts pending post-RPC review, + plus a history of already-decided ones, scoped to the HOD's own discipline.""" + hod_disciplines = get_hod_disciplines(request.user) + + def _scoped(qs): + result = [] + for attempt in qs: + student = attempt.open_seminar.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: + result.append(attempt) + return result + + pending_qs = OpenSeminarAttempt.objects.filter(status='hod_review_pending').select_related( + 'open_seminar__student__id__user', 'open_seminar__student__batch_id__discipline', 'open_seminar__supervisor__id__user' + ) + pending = [open_seminar_to_dict(a.open_seminar) for a in _scoped(pending_qs)] + + history_qs = OpenSeminarAttempt.objects.filter(hod_reviewed_by__isnull=False).select_related( + 'open_seminar__student__id__user', 'open_seminar__student__batch_id__discipline', + 'open_seminar__supervisor__id__user', 'hod_reviewed_by', + ).order_by('-hod_reviewed_at') + history = [ + { + **open_seminar_to_dict(a.open_seminar), + 'decision': 'Rejected' if a.status == 'rpc_pending' else 'Approved', + 'decided_by': a.hod_reviewed_by.get_full_name() if a.hod_reviewed_by else None, + 'decided_at': a.hod_reviewed_at.isoformat() if a.hod_reviewed_at else None, + 'attempt_number': a.attempt_number, + 'remarks': a.hod_review_remarks, + } + for a in _scoped(history_qs) + ] + + return JsonResponse({'pending': pending, 'history': history}, status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def hod_review_open_seminar(request, attempt_pk): + """ + POST /hod/open-seminar/attempt//review/ + Body: { approve: true|false, remarks } + Rejecting sends it back to the RPC for fresh consensus (all consents reset). + """ + attempt = get_object_or_404(OpenSeminarAttempt, pk=attempt_pk, status='hod_review_pending') + student = attempt.open_seminar.student + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + if not is_hod_of_discipline(request.user, acronym): + return JsonResponse({'error': 'Not authorized'}, status=403) + + data = request.data + attempt.hod_reviewed_by = request.user + attempt.hod_reviewed_at = timezone.now() + if data.get('approve'): + attempt.hod_review_remarks = '' + attempt.status = 'dean_pending' + attempt.save() + _open_seminar_notify( + sender=request.user, + recipient=_dean_academic_users(), + verb='Open Seminar pending your final approval', + description=f"{student.id.user.get_full_name()}'s Open Seminar has been approved by " + f"Convener (DPGC) and is awaiting your final approval.", + ) + _open_seminar_notify( + sender=request.user, + recipient=attempt.open_seminar.supervisor.id.user, + verb='Open Seminar approved by Convener (DPGC)', + description=f"{student.id.user.get_full_name()}'s Open Seminar has been approved by " + f"Convener (DPGC) and forwarded to Dean Academic.", + ) + else: + attempt.hod_review_remarks = data.get('remarks', '') + attempt.status = 'rpc_pending' + attempt.save() + OpenSeminarConsent.objects.filter(attempt=attempt).update(consented=False) + _open_seminar_notify( + sender=request.user, + recipient=_rpc_committee_users(student), + verb='Open Seminar sent back by Convener (DPGC)', + description=f"Convener (DPGC) sent {student.id.user.get_full_name()}'s Open Seminar " + f"back to the RPC for fresh consensus. Remarks: {attempt.hod_review_remarks or '—'}", + ) + + return JsonResponse(open_seminar_to_dict(attempt.open_seminar), status=200) + + +# 7. 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) + + if attempt.dn_submitted_at: + return JsonResponse({'error': 'Report already submitted'}, 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, + ) + + 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) + + data = request.data + choice_1 = data.get('choice_1') + if not choice_1: + return JsonResponse({'error': 'choice_1 is required'}, status=400) + + if TeachingCreditAllocation.objects.filter(student=student, semester=semester).exists(): + return JsonResponse({'error': 'Already registered for teaching credit this semester'}, status=400) + + 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, + ) + + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + _teaching_credit_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Teaching Credit registration pending allocation', + description=f"{student.id.user.get_full_name()} has submitted teaching credit course " + f"choices for your allocation.", + ) + + return JsonResponse(teaching_credit_to_dict(reg), status=201) + + +@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) + try: + student = Student.objects.get(id=request.user.extrainfo) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) + + if reg.student_id != student.pk: + return JsonResponse({'error': 'Not authorized'}, status=403) + + return JsonResponse(teaching_credit_to_dict(reg), status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def student_resubmit_teaching_credit(request, pk): + """ + POST /stu/teaching-credit//resubmit/ + Edits choices after HOD sends it back, resends for HOD decision. + """ + 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 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) + + 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() + + acronym = student.batch_id.discipline.acronym if student.batch_id and student.batch_id.discipline else None + _teaching_credit_notify( + sender=request.user, + recipient=_hod_users_for_discipline(acronym), + verb='Teaching Credit registration resubmitted', + description=f"{student.id.user.get_full_name()} has resubmitted teaching credit course " + f"choices for your allocation.", + ) + + 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: + student = Student.objects.get(id=request.user.extrainfo) + except Student.DoesNotExist: + return JsonResponse({'error': 'Student record not found'}, status=404) + + 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) + + qs = TeachingCreditAllocation.objects.filter( + status__in=['allocated', 'completed'], + allocated_course_id__in=registered_course_ids, + ).exclude(id__in=already_evaluated) + + return JsonResponse({'targets': [teaching_credit_to_dict(r) for r in qs]}, status=200) + + +@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) + + +# 2. HOD + +@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) + + qs = TeachingCreditAllocation.objects.filter( + status__in=['pending', 'allocated'] + ).select_related('student__id__user', 'student__batch_id__discipline') + + 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)) + + return JsonResponse({'pending': pending, 'awaiting_completion': awaiting_completion}, status=200) + + +@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 + + if not _is_hod_of_student(user, reg.student): + return JsonResponse({'error': 'Not authorized'}, status=403) + + data = request.data + reg.decided_by = user + reg.decided_at = timezone.now() + + 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' + reg.save() + _teaching_credit_notify( + sender=user, + recipient=reg.student.id.user, + verb='Teaching Credit course allocated', + description=f"You have been allocated {reg.allocated_course.code} - " + f"{reg.allocated_course.name} for teaching credit.", + ) + else: + reg.hod_remarks = data.get('remarks', '') + reg.status = 'sent_back' + reg.save() + _teaching_credit_notify( + sender=user, + recipient=reg.student.id.user, + verb='Teaching Credit registration sent back', + description=f"Your teaching credit choices were sent back by the HOD. " + f"Remarks: {reg.hod_remarks or '—'}", + ) + + return JsonResponse(teaching_credit_to_dict(reg), status=200) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def hod_complete_teaching_credit(request, pk): + """ + 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 + + if not _is_hod_of_student(user, reg.student): + return JsonResponse({'error': 'Not authorized'}, status=403) + + result = request.data.get('result') + if result not in ('satisfactory', 'not_satisfactory'): + return JsonResponse({'error': 'result must be satisfactory or not_satisfactory'}, status=400) + + if not reg.evaluations.exists(): + return JsonResponse( + {'error': 'At least one student evaluation is required before completing this registration.'}, + status=400, + ) + + reg.result = result + reg.status = 'completed' + reg.completed_by = user + reg.completed_at = timezone.now() + reg.save() + + _teaching_credit_notify( + sender=user, + recipient=reg.student.id.user, + verb='Teaching Credit result declared', + description=f"Your teaching credit registration has been marked " + f"{'satisfactory' if result == 'satisfactory' else 'not satisfactory'} by the HOD.", + ) + + return JsonResponse(teaching_credit_to_dict(reg, include_evaluations=True), status=200) + + +# 3. Supervisor (read-only) + +@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) + + student_ids = ThesisTopic.objects.filter( + Q(supervisor=faculty) | Q(co_supervisor=faculty) + ).values_list('student_id', flat=True) + + qs = TeachingCreditAllocation.objects.filter(student_id__in=student_ids) + return JsonResponse({'registrations': [teaching_credit_to_dict(r) for r in qs]}, status=200) + + +# 4. Academic Office (read-only) + +@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/cron.py b/FusionIIIT/applications/academic_procedures/cron.py new file mode 100644 index 000000000..5afa61a8b --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/cron.py @@ -0,0 +1,19 @@ +from django_cron import CronJobBase, Schedule +from datetime import datetime + +class EmailPrinterCron(CronJobBase): + RUN_EVERY_MINS = 1000 # Runs every 2 minutes + + schedule = Schedule(run_every_mins=RUN_EVERY_MINS) + code = 'academic_procedures.email_printer_cron' # Unique code + + def do(self): + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + print(f"Email sent at {now}") + +# NOTE: the PhD thesis review-invitation lifecycle (send/expire/cascade/remind) +# used to live here as a django_cron job, but django_cron isn't an installed +# dependency in this project (only django-crontab/Celery are) so it never ran. +# It's now implemented as a real Celery beat task: +# applications/academic_procedures/tasks.py::process_review_invitations, +# registered in Fusion/settings/common.py CELERY_BEAT_SCHEDULE. \ No newline at end of file diff --git a/FusionIIIT/applications/academic_procedures/migrations/0019_committeemember_publicationcount_seminarcomment_seminarconsent_seminarentry_thesistopic.py b/FusionIIIT/applications/academic_procedures/migrations/0019_committeemember_publicationcount_seminarcomment_seminarconsent_seminarentry_thesistopic.py new file mode 100644 index 000000000..374e2b03e --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0019_committeemember_publicationcount_seminarcomment_seminarconsent_seminarentry_thesistopic.py @@ -0,0 +1,117 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0001_initial'), + ('globals', '0004_extrainfo_last_selected_role'), + ('academic_procedures', '0015_auto_20250709_1240'), + ] + + operations = [ + migrations.CreateModel( + name='ThesisTopic', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('supervisor_consented', models.BooleanField(default=False)), + ('co_supervisor_consented', models.BooleanField(default=False)), + ('category', models.CharField(choices=[('Regular', 'Regular'), ('Sponsored', 'Sponsored'), ('External', 'External')], max_length=20)), + ('broad_area', models.CharField(max_length=200)), + ('research_theme', models.TextField()), + ('external_name', models.CharField(blank=True, max_length=100)), + ('external_email', models.EmailField(blank=True, max_length=254)), + ('external_discipline', models.CharField(blank=True, max_length=100)), + ('external_institution', models.CharField(blank=True, max_length=200)), + ('pg_single', models.PositiveIntegerField(default=0)), + ('pg_shared', models.PositiveIntegerField(default=0)), + ('phd_single', models.PositiveIntegerField(default=0)), + ('phd_shared', models.PositiveIntegerField(default=0)), + ('status', models.CharField(choices=[('supervisor_pending', 'Pending with Supervisor'), ('hod_pending', 'Approved by Supervisor, Pending with HOD'), ('hod_rejected', 'Rejected by HOD, Returned to Supervisor'), ('dean_pending', 'Approved by HOD, Pending with Dean'), ('dean_rejected', 'Rejected by Dean, Returned to HOD'), ('dean_approved', 'Approved by Dean')], default='supervisor_pending', max_length=30)), + ('hod_remarks', models.TextField(blank=True)), + ('dean_remarks', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('co_supervisor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='theses_cosupervised', to='globals.faculty')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='academic_information.student')), + ('supervisor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='theses_supervised', to='globals.faculty')), + ], + ), + migrations.CreateModel( + name='SeminarEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.PositiveSmallIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('rpc_pending', 'Pending RPC Consent'), ('rpc_approved', 'Approved')], default='draft', max_length=20)), + ('seminar_date', models.DateField(blank=True, null=True)), + ('seminar_time', models.TimeField(blank=True, null=True)), + ('seminar_venue', models.CharField(blank=True, max_length=200)), + ('summary_prev', models.TextField(blank=True)), + ('summary_curr', models.TextField(blank=True)), + ('future_plan', models.TextField(blank=True)), + ('upload_doc', models.FileField(blank=True, null=True, upload_to='seminar_docs/')), + ('quality', models.CharField(blank=True, choices=[('Excellent', 'Excellent'), ('Good', 'Good'), ('Sat', 'Satisfactory'), ('Unsat', 'Unsatisfactory')], max_length=20)), + ('quantity', models.CharField(blank=True, choices=[('Enough', 'Enough'), ('Just', 'Just Sufficient'), ('Insuff', 'Insufficient')], max_length=20)), + ('overall_grade', models.CharField(blank=True, choices=[('S', 'S'), ('X', 'X')], max_length=2)), + ('expected_period', models.CharField(blank=True, choices=[('1', '1 year'), ('2', '2 years'), ('3', '3 years'), ('4', '4 years')], max_length=2)), + ('rec_assist', models.CharField(blank=True, choices=[('Yes', 'Yes'), ('No', 'No'), ('NA', 'Not Applicable')], max_length=3)), + ('rec_enhance', models.CharField(blank=True, choices=[('Yes', 'Yes'), ('No', 'No'), ('NA', 'Not Applicable')], max_length=3)), + ('rec_repeat', models.CharField(blank=True, choices=[('Yes', 'Yes'), ('NA', 'Not Applicable')], max_length=3)), + ('rec_open', models.CharField(blank=True, choices=[('Yes', 'Yes'), ('No', 'No')], max_length=3)), + ('thesis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seminars', to='academic_procedures.thesistopic')), + ], + ), + migrations.CreateModel( + name='SeminarConsent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('consented', models.BooleanField(default=False)), + ('timestamp', models.DateTimeField(auto_now=True)), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='globals.faculty')), + ('seminar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consents', to='academic_procedures.seminarentry')), + ], + options={ + 'unique_together': {('seminar', 'member')}, + }, + ), + migrations.CreateModel( + name='SeminarComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField()), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='globals.faculty')), + ('seminar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='academic_procedures.seminarentry')), + ], + options={ + 'ordering': ['-timestamp'], + 'unique_together': {('seminar', 'member')}, + }, + ), + migrations.CreateModel( + name='PublicationCount', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(choices=[('Journal', 'Journal'), ('Conference', 'Conference'), ('Submitted', 'Submitted')], max_length=50)), + ('submitted', models.PositiveIntegerField(default=0)), + ('accepted', models.PositiveIntegerField(default=0)), + ('published', models.PositiveIntegerField(default=0)), + ('seminar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pub_counts', to='academic_procedures.seminarentry')), + ], + options={ + 'unique_together': {('seminar', 'category')}, + }, + ), + migrations.CreateModel( + name='CommitteeMember', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='globals.faculty')), + ('thesis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='committee', to='academic_procedures.thesistopic')), + ], + options={ + 'unique_together': {('thesis', 'member')}, + }, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0020_reviewinvitation_thesissubmission.py b/FusionIIIT/applications/academic_procedures/migrations/0020_reviewinvitation_thesissubmission.py new file mode 100644 index 000000000..c6f400035 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0020_reviewinvitation_thesissubmission.py @@ -0,0 +1,74 @@ +import applications.academic_procedures.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('academic_procedures', '0018_auto_20260106_1355'), + ('academic_procedures', '0019_committeemember_publicationcount_seminarcomment_seminarconsent_seminarentry_thesistopic'), + ] + + operations = [ + migrations.CreateModel( + name='ThesisSubmission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file_token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, db_index=True)), + ('synopsis', models.FileField(upload_to=applications.academic_procedures.models.upload_synopsis)), + ('thesis_report', models.FileField(upload_to=applications.academic_procedures.models.upload_report)), + ('submitted_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('supervisor_approved_at', models.DateTimeField(blank=True, null=True)), + ('director_approved_at', models.DateTimeField(blank=True, null=True)), + ('status', models.CharField(choices=[('submitted', 'Submitted'), ('supervisor_review', 'Supervisor Review'), ('director_review', 'Director Review'), ('in_review', 'In External Review'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='submitted', max_length=30, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('director', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='directed_subs', to=settings.AUTH_USER_MODEL)), + ('supervisor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='supervised_subs', to=settings.AUTH_USER_MODEL)), + ('thesis', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='submission', to='academic_procedures.thesistopic')), + ], + options={ + 'ordering': ['-submitted_at'], + }, + ), + migrations.CreateModel( + name='ReviewInvitation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('prof_name', models.CharField(max_length=255, db_index=True)), + ('prof_position', models.CharField(max_length=255)), + ('prof_address', models.TextField()), + ('prof_phone', models.CharField(max_length=20)), + ('prof_email', models.EmailField(max_length=254, db_index=True)), + ('prof_time_ranking', models.PositiveSmallIntegerField(blank=True, null=True)), + ('priority', models.PositiveSmallIntegerField(default=0, db_index=True)), + ('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, db_index=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('completed', 'Completed'), ('expired', 'Expired')], default='pending', max_length=20, db_index=True)), + ('last_sent', models.DateTimeField(blank=True, null=True)), + ('review_form_sent', models.DateTimeField(blank=True, null=True)), + ('expires_at', models.DateTimeField(blank=True, null=True, db_index=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='academic_procedures.thesissubmission')), + ], + options={ + 'ordering': ['submission', 'priority'], + 'unique_together': {('submission', 'priority')}, + }, + ), + migrations.AddIndex( + model_name='reviewinvitation', + index=models.Index(fields=['submission', 'status'], name='academic_pr_submiss_858405_idx'), + ), + migrations.AddIndex( + model_name='reviewinvitation', + index=models.Index(fields=['status', 'last_sent'], name='academic_pr_status_7509a8_idx'), + ), + migrations.AddIndex( + model_name='thesissubmission', + index=models.Index(fields=['status', 'submitted_at'], name='academic_pr_status_953719_idx'), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0021_thesis_registration_models.py b/FusionIIIT/applications/academic_procedures/migrations/0021_thesis_registration_models.py new file mode 100644 index 000000000..6e0b92ecc --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0021_thesis_registration_models.py @@ -0,0 +1,53 @@ +# Generated by Django 3.1.5 on 2026-03-06 11:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0038_thesis_registration_models'), + ('academic_information', '0002_thesis_registration_models'), + ('academic_procedures', '0020_reviewinvitation_thesissubmission'), + ] + + operations = [ + migrations.CreateModel( + name='ThesisRegistration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('working_year', models.IntegerField(blank=True, null=True)), + ('academic_session', models.CharField(blank=True, max_length=9, null=True)), + ('status', models.CharField(choices=[('pending', 'Pending Verification'), ('verified', 'Verified'), ('rejected', 'Rejected')], default='pending', max_length=20)), + ('registered_on', models.DateTimeField(auto_now_add=True)), + ('verified_on', models.DateTimeField(blank=True, null=True)), + ('remarks', models.CharField(blank=True, max_length=500)), + ('semester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.semester')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='thesis_registrations', to='academic_information.student')), + ('thesis_slot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registrations', to='programme_curriculum.thesisslot')), + ('thesis_topic', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_registrations', to='academic_procedures.thesistopic')), + ], + options={ + 'db_table': 'ThesisRegistration', + 'unique_together': {('student', 'semester')}, + }, + ), + migrations.CreateModel( + name='ProgressSeminarRegistration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('working_year', models.IntegerField(blank=True, null=True)), + ('status', models.CharField(choices=[('pending', 'Pending Verification'), ('verified', 'Verified'), ('rejected', 'Rejected')], default='pending', max_length=20)), + ('registered_on', models.DateTimeField(auto_now_add=True)), + ('remarks', models.CharField(blank=True, max_length=500)), + ('progress_seminar_slot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registrations', to='programme_curriculum.progressseminarslot')), + ('semester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.semester')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress_seminar_registrations', to='academic_information.student')), + ], + options={ + 'db_table': 'ProgressSeminarRegistration', + 'unique_together': {('student', 'semester')}, + }, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0022_thesis_registration_add_credits.py b/FusionIIIT/applications/academic_procedures/migrations/0022_thesis_registration_add_credits.py new file mode 100644 index 000000000..5880362c1 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0022_thesis_registration_add_credits.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-07 18:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0021_thesis_registration_models'), + ] + + operations = [ + migrations.AddField( + model_name='thesisregistration', + name='credits', + field=models.PositiveSmallIntegerField(choices=[(3, '3 Credits'), (6, '6 Credits'), (9, '9 Credits'), (12, '12 Credits')], default=6, help_text='Credits the student is registering for this semester (3/6/9/12)'), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0023_thesis_evaluation_models.py b/FusionIIIT/applications/academic_procedures/migrations/0023_thesis_evaluation_models.py new file mode 100644 index 000000000..0f2dd2b7c --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0023_thesis_evaluation_models.py @@ -0,0 +1,102 @@ +# Manually authored migration — 0023_thesis_evaluation_models +# Creates ThesisEvaluation and ProgressSeminarEvaluation tables. +# Generated to match models added in academic_procedures/models.py. + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0022_thesis_registration_add_credits'), + ('globals', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # ------------------------------------------------------------------ # + # ThesisEvaluation # + # ------------------------------------------------------------------ # + migrations.CreateModel( + name='ThesisEvaluation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('block_number', models.PositiveSmallIntegerField( + help_text='Sequential block index starting at 1 (max = registration.credits ÷ 3)', + )), + ('grade', models.CharField( + blank=True, choices=[('S', 'Satisfactory'), ('X', 'Unsatisfactory')], + max_length=1, null=True, + )), + ('submitted_at', models.DateTimeField(blank=True, null=True)), + ('remarks', models.TextField(blank=True)), + ('verified', models.BooleanField(default=False)), + ('verified_at', models.DateTimeField(blank=True, null=True)), + ('announced', models.BooleanField(default=False)), + ('announced_at', models.DateTimeField(blank=True, null=True)), + ('registration', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='evaluations', + to='academic_procedures.ThesisRegistration', + )), + ('submitted_by', models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='thesis_grades_submitted', + to='globals.Faculty', + )), + ('verified_by', models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='thesis_grades_verified', + to=settings.AUTH_USER_MODEL, + )), + ], + options={ + 'db_table': 'ThesisEvaluation', + 'ordering': ['registration', 'block_number'], + 'unique_together': {('registration', 'block_number')}, + }, + ), + # ------------------------------------------------------------------ # + # ProgressSeminarEvaluation # + # ------------------------------------------------------------------ # + migrations.CreateModel( + name='ProgressSeminarEvaluation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('grade', models.CharField( + blank=True, choices=[('S', 'Satisfactory'), ('X', 'Unsatisfactory')], + max_length=1, null=True, + )), + ('submitted_at', models.DateTimeField(blank=True, null=True)), + ('remarks', models.TextField(blank=True)), + ('verified', models.BooleanField(default=False)), + ('verified_at', models.DateTimeField(blank=True, null=True)), + ('announced', models.BooleanField(default=False)), + ('announced_at', models.DateTimeField(blank=True, null=True)), + ('registration', models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='evaluation', + to='academic_procedures.ProgressSeminarRegistration', + )), + ('submitted_by', models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='seminar_grades_submitted', + to='globals.Faculty', + )), + ('verified_by', models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='seminar_grades_verified', + to=settings.AUTH_USER_MODEL, + )), + ], + options={ + 'db_table': 'ProgressSeminarEvaluation', + }, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0024_merge_20260314_1604.py b/FusionIIIT/applications/academic_procedures/migrations/0024_merge_20260314_1604.py new file mode 100644 index 000000000..590cf7423 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0024_merge_20260314_1604.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-03-14 16:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0019_swayamreplacementrequest'), + ('academic_procedures', '0023_thesis_evaluation_models'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0025_auto_20260705_1724.py b/FusionIIIT/applications/academic_procedures/migrations/0025_auto_20260705_1724.py new file mode 100644 index 000000000..dd8c22a6b --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0025_auto_20260705_1724.py @@ -0,0 +1,49 @@ +# Generated by Django 3.1.5 on 2026-07-05 17:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('academic_procedures', '0024_merge_20260314_1604'), + ] + + operations = [ + migrations.AlterModelOptions( + name='reviewinvitation', + options={'ordering': ['submission', 'examiner_type', 'priority']}, + ), + migrations.AddField( + model_name='reviewinvitation', + name='examiner_type', + field=models.CharField(choices=[('indian', 'Indian'), ('foreign', 'Foreign')], db_index=True, default='indian', max_length=10), + ), + migrations.AddField( + model_name='thesissubmission', + name='dean', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deaned_subs', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='thesissubmission', + name='dean_approved_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='thesissubmission', + name='dean_invited_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='thesissubmission', + name='status', + field=models.CharField(choices=[('submitted', 'Submitted'), ('dean_panel_review', 'Pending Dean Panel Approval'), ('director_review', 'Pending Director Prioritization'), ('dean_invite_pending', 'Pending Dean Invitation'), ('in_review', 'In External Review'), ('completed', 'Review Completed'), ('approved', 'Approved'), ('rejected', 'Rejected')], db_index=True, default='submitted', max_length=30), + ), + migrations.AlterUniqueTogether( + name='reviewinvitation', + unique_together={('submission', 'examiner_type', 'priority')}, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0025_merge_20260805_2216.py b/FusionIIIT/applications/academic_procedures/migrations/0025_merge_20260805_2216.py new file mode 100644 index 000000000..bdb609a36 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0025_merge_20260805_2216.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-08-05 22:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0023_courseaddrequest_course_instructor'), + ('academic_procedures', '0024_merge_20260314_1604'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0026_merge_20260705_1854.py b/FusionIIIT/applications/academic_procedures/migrations/0026_merge_20260705_1854.py new file mode 100644 index 000000000..031d5eb00 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0026_merge_20260705_1854.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-07-05 18:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0020_backfill_course_registration_session'), + ('academic_procedures', '0025_auto_20260705_1724'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0027_reviewinvitation_prof_fax.py b/FusionIIIT/applications/academic_procedures/migrations/0027_reviewinvitation_prof_fax.py new file mode 100644 index 000000000..af1b39d7a --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0027_reviewinvitation_prof_fax.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-07-09 17:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0026_merge_20260705_1854'), + ] + + operations = [ + migrations.AddField( + model_name='reviewinvitation', + name='prof_fax', + field=models.CharField(blank=True, default='', max_length=20), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0028_examinerbankdetails_thesisreview.py b/FusionIIIT/applications/academic_procedures/migrations/0028_examinerbankdetails_thesisreview.py new file mode 100644 index 000000000..eec0c48cc --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0028_examinerbankdetails_thesisreview.py @@ -0,0 +1,47 @@ +# Generated by Django 3.1.5 on 2026-07-09 19:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0027_reviewinvitation_prof_fax'), + ] + + operations = [ + migrations.CreateModel( + name='ThesisReview', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('originality_presentation', models.TextField(blank=True, default='')), + ('quality_comparable', models.BooleanField(blank=True, null=True)), + ('new_ideas_original', models.BooleanField(blank=True, null=True)), + ('correction_severity', models.CharField(blank=True, choices=[('none', 'None'), ('minor', 'Minor'), ('major', 'Major')], default='', max_length=10)), + ('technical_content', models.TextField(blank=True, default='')), + ('highlights', models.TextField(blank=True, default='')), + ('suggestions', models.TextField(blank=True, default='')), + ('defense_questions', models.TextField(blank=True, default='')), + ('recommendation', models.CharField(choices=[('accept', 'Acceptable in present form for award of the PhD degree'), ('accept_with_corrections', 'Acceptable; suggested corrections/modifications to be incorporated'), ('needs_improvement', "Needs technical improvement to the examiner's satisfaction"), ('reject', 'Rejected -- thesis does not contain novel work')], max_length=30)), + ('submitted_at', models.DateTimeField(auto_now_add=True)), + ('invitation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='review', to='academic_procedures.reviewinvitation')), + ], + ), + migrations.CreateModel( + name='ExaminerBankDetails', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('beneficiary_name', models.CharField(blank=True, default='', max_length=255)), + ('bank_name', models.CharField(blank=True, default='', max_length=255)), + ('bank_address', models.TextField(blank=True, default='')), + ('account_no', models.CharField(blank=True, default='', max_length=50)), + ('ifsc_code', models.CharField(blank=True, default='', max_length=20)), + ('pan_no', models.CharField(blank=True, default='', max_length=20)), + ('iban_no', models.CharField(blank=True, default='', max_length=40)), + ('swift_code', models.CharField(blank=True, default='', max_length=20)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('invitation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='bank_details', to='academic_procedures.reviewinvitation')), + ], + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/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/migrations/0038_auto_20260721_1212.py b/FusionIIIT/applications/academic_procedures/migrations/0038_auto_20260721_1212.py new file mode 100644 index 000000000..b941bb1e1 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0038_auto_20260721_1212.py @@ -0,0 +1,170 @@ +# Generated by Django 3.1.5 on 2026-07-21 12:12 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0007_moduleaccess_thesis_research'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('academic_procedures', '0037_add_semester_to_progress_seminar_entry'), + ] + + operations = [ + migrations.CreateModel( + name='ComprehensiveExamConsent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('consented', models.BooleanField(default=False)), + ('timestamp', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='ComprehensiveExamRPCComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField()), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-timestamp'], + }, + ), + migrations.RemoveField( + model_name='floatedsubject', + name='attempt', + ), + migrations.RemoveField( + model_name='comprehensiveexam', + name='convener_at', + ), + migrations.RemoveField( + model_name='comprehensiveexam', + name='convener_by', + ), + migrations.RemoveField( + model_name='comprehensiveexam', + name='convener_remarks', + ), + migrations.RemoveField( + model_name='comprehensiveexamattempt', + name='hod_remarks', + ), + migrations.RemoveField( + model_name='comprehensiveexamattempt', + name='hod_reviewed_at', + ), + migrations.RemoveField( + model_name='comprehensiveexamattempt', + name='hod_reviewed_by', + ), + migrations.RemoveField( + model_name='comprehensiveexamattempt', + name='oral_exam_date', + ), + migrations.RemoveField( + model_name='comprehensiveexamattempt', + name='supervisor_confirmation_remarks', + ), + migrations.RemoveField( + model_name='comprehensiveexamattempt', + name='written_exam_date', + ), + migrations.AddField( + model_name='comprehensiveexam', + name='dpgc_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='comprehensiveexam', + name='dpgc_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comprehensive_exams_dpgc_reviewed', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='comprehensiveexam', + name='dpgc_remarks', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='comprehensiveexamattempt', + name='dean_approved_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='comprehensiveexamattempt', + name='dean_approved_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comprehensive_attempts_dean_approved', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='comprehensiveexamattempt', + name='exam_date', + field=models.DateField(blank=True, help_text='Settable by the supervisor or any RPC member; may be updated again when RPC finalizes the report.', null=True), + ), + migrations.AddField( + model_name='comprehensiveexamattempt', + name='pgcs_remarks', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='comprehensiveexamattempt', + name='pgcs_reviewed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='comprehensiveexamattempt', + name='pgcs_reviewed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comprehensive_attempts_pgcs_reviewed', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='comprehensiveexam', + name='status', + field=models.CharField(choices=[('academic_office_pending', 'Pending Academic Office Verification'), ('academic_office_rejected', 'Rejected by Academic Office'), ('dpgc_pending', 'Pending Convener (DPGC) Approval'), ('dpgc_rejected', 'Rejected by Convener (DPGC)'), ('in_progress', 'Approved by DPGC — In Progress'), ('passed', 'Passed'), ('failed_final', 'Failed — Attempts Exhausted')], default='academic_office_pending', max_length=30), + ), + migrations.AlterField( + model_name='comprehensiveexamattempt', + name='reported_by', + field=models.ForeignKey(blank=True, help_text='Whichever RPC member finalized the panel (moved rpc_pending -> pgcs_pending).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comprehensive_attempts_reported', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='comprehensiveexamattempt', + name='status', + field=models.CharField(choices=[('rpc_pending', 'Pending RPC Consensus'), ('pgcs_pending', 'RPC Finalized — Pending Convener (PGCS) Review'), ('dean_pending', 'Approved by PGCS — Pending Dean Academic'), ('passed', 'Passed'), ('failed', 'Failed')], default='rpc_pending', max_length=25), + ), + migrations.DeleteModel( + name='ComprehensiveExamCommitteeMember', + ), + migrations.DeleteModel( + name='FloatedSubject', + ), + migrations.AddField( + model_name='comprehensiveexamrpccomment', + name='attempt', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rpc_comments', to='academic_procedures.comprehensiveexamattempt'), + ), + migrations.AddField( + model_name='comprehensiveexamrpccomment', + name='member', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='globals.faculty'), + ), + migrations.AddField( + model_name='comprehensiveexamconsent', + name='attempt', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consents', to='academic_procedures.comprehensiveexamattempt'), + ), + migrations.AddField( + model_name='comprehensiveexamconsent', + name='member', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='globals.faculty'), + ), + migrations.AlterUniqueTogether( + name='comprehensiveexamrpccomment', + unique_together={('attempt', 'member')}, + ), + migrations.AlterUniqueTogether( + name='comprehensiveexamconsent', + unique_together={('attempt', 'member')}, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0039_comprehensiveexam_proposed_exam_date.py b/FusionIIIT/applications/academic_procedures/migrations/0039_comprehensiveexam_proposed_exam_date.py new file mode 100644 index 000000000..1fdfa7733 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0039_comprehensiveexam_proposed_exam_date.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-07-21 14:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0038_auto_20260721_1212'), + ] + + operations = [ + migrations.AddField( + model_name='comprehensiveexam', + name='proposed_exam_date', + field=models.DateField(blank=True, help_text="Set by the supervisor at proposal time; seeds attempt 1's exam_date once DPGC approves.", null=True), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0040_auto_20260722_1521.py b/FusionIIIT/applications/academic_procedures/migrations/0040_auto_20260722_1521.py new file mode 100644 index 000000000..ea57414f3 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0040_auto_20260722_1521.py @@ -0,0 +1,226 @@ +# Generated by Django 3.1.5 on 2026-07-22 15: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_procedures', '0039_comprehensiveexam_proposed_exam_date'), + ] + + operations = [ + migrations.CreateModel( + name='OpenSeminarConsent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('consented', models.BooleanField(default=False)), + ('timestamp', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='OpenSeminarRPCComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField()), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['-timestamp'], + }, + ), + migrations.RemoveField( + model_name='openseminarattempt', + name='convener_at', + ), + migrations.RemoveField( + model_name='openseminarattempt', + name='convener_by', + ), + migrations.RemoveField( + model_name='openseminarattempt', + name='convener_remarks', + ), + migrations.RemoveField( + model_name='openseminarattempt', + name='course_work_credits', + ), + migrations.RemoveField( + model_name='openseminarattempt', + name='first_draft_sent_to_dean', + ), + migrations.RemoveField( + model_name='openseminarattempt', + name='progress_seminar_credits', + ), + migrations.RemoveField( + model_name='openseminarattempt', + name='proposed_date', + ), + migrations.RemoveField( + model_name='openseminarattempt', + name='rpc_recommended_open_seminar', + ), + migrations.RemoveField( + model_name='openseminarattempt', + name='semesters_completed', + ), + migrations.RemoveField( + model_name='openseminarattempt', + name='teaching_credits', + ), + migrations.RemoveField( + model_name='openseminarattempt', + name='thesis_research_credits', + ), + migrations.AddField( + model_name='openseminar', + name='course_work_credits', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='openseminar', + name='dean_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='openseminar', + name='dean_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='open_seminars_dean_approved', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='openseminar', + name='dean_remarks', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='openseminar', + name='first_draft_sent_to_dean', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='openseminar', + name='hod_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='openseminar', + name='hod_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='open_seminars_hod_reviewed', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='openseminar', + name='hod_remarks', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='openseminar', + name='progress_seminar_credits', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='openseminar', + name='proposed_date', + field=models.DateField(blank=True, help_text="Set by the supervisor at proposal time; seeds attempt 1's seminar_date once Dean approves.", null=True), + ), + migrations.AddField( + model_name='openseminar', + name='rpc_recommended_open_seminar', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='openseminar', + name='semesters_completed', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='openseminar', + name='teaching_credits', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='openseminar', + name='thesis_research_credits', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='openseminarattempt', + name='dean_approved_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='openseminarattempt', + name='dean_approved_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='open_seminar_attempts_dean_approved', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='openseminarattempt', + name='hod_review_remarks', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='openseminarattempt', + name='hod_reviewed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='openseminarattempt', + name='hod_reviewed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='open_seminar_attempts_hod_reviewed', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='openseminarattempt', + name='seminar_date', + field=models.DateField(blank=True, help_text='Settable by the supervisor or any RPC member; may be updated again when RPC finalizes.', null=True), + ), + migrations.AlterField( + model_name='openseminar', + name='status', + field=models.CharField(choices=[('hod_pending', 'Pending Convener (DPGC) Review'), ('hod_rejected', 'Rejected by Convener (DPGC)'), ('dean_pending', 'Pending Dean Academic Approval'), ('dean_rejected', 'Rejected by Dean Academic'), ('in_progress', 'Approved — In Progress'), ('satisfactory', 'Satisfactory')], default='hod_pending', max_length=20), + ), + migrations.AlterField( + model_name='openseminarattempt', + name='reported_by', + field=models.ForeignKey(blank=True, help_text='Whichever RPC member finalized the panel (moved rpc_pending -> hod_review_pending).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='open_seminar_attempts_reported', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='openseminarattempt', + name='status', + field=models.CharField(choices=[('rpc_pending', 'Pending RPC Consensus'), ('hod_review_pending', 'RPC Finalized — Pending Convener (DPGC) Review'), ('dean_pending', 'Approved by Convener — Pending Dean Academic'), ('satisfactory', 'Satisfactory'), ('not_satisfactory', 'Not Satisfactory')], default='rpc_pending', max_length=25), + ), + migrations.DeleteModel( + name='OpenSeminarCommitteeMember', + ), + migrations.AddField( + model_name='openseminarrpccomment', + name='attempt', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rpc_comments', to='academic_procedures.openseminarattempt'), + ), + migrations.AddField( + model_name='openseminarrpccomment', + name='member', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='globals.faculty'), + ), + migrations.AddField( + model_name='openseminarconsent', + name='attempt', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consents', to='academic_procedures.openseminarattempt'), + ), + migrations.AddField( + model_name='openseminarconsent', + name='member', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='globals.faculty'), + ), + migrations.AlterUniqueTogether( + name='openseminarrpccomment', + unique_together={('attempt', 'member')}, + ), + migrations.AlterUniqueTogether( + name='openseminarconsent', + unique_together={('attempt', 'member')}, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0042_auto_20260802_0732.py b/FusionIIIT/applications/academic_procedures/migrations/0042_auto_20260802_0732.py new file mode 100644 index 000000000..d3f5e67f4 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0042_auto_20260802_0732.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.5 on 2026-08-02 07:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + NOTE: first_draft_sent_to_dean (bool) -> first_draft_document (FileField) + is a deliberate, unavoidable loss of historical signal for any existing + OpenSeminar row: a "was it sent" flag carries no actual file to migrate + into the new field, so pre-existing True rows simply lose that signal + (become an empty file field) once this runs. + """ + + dependencies = [ + ('academic_procedures', '0040_auto_20260722_1521'), + ] + + operations = [ + migrations.RemoveField( + model_name='openseminar', + name='first_draft_sent_to_dean', + ), + migrations.AddField( + model_name='openseminar', + name='first_draft_document', + field=models.FileField(blank=True, null=True, upload_to='open_seminar_first_drafts/'), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0043_pg_thesis_examiner_panel_decimal_grading.py b/FusionIIIT/applications/academic_procedures/migrations/0043_pg_thesis_examiner_panel_decimal_grading.py new file mode 100644 index 000000000..b3a502f51 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0043_pg_thesis_examiner_panel_decimal_grading.py @@ -0,0 +1,82 @@ +# Generated by Django 3.1.5 on 2026-08-03 16:06 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('globals', '0008_announcement'), + ('programme_curriculum', '0046_thesisslot_evaluation_type'), + ('academic_procedures', '0042_auto_20260802_0732'), + ] + + operations = [ + migrations.CreateModel( + name='ThesisExaminerCandidate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('position', models.CharField(blank=True, max_length=255)), + ('address', models.TextField(blank=True)), + ('phone', models.CharField(blank=True, max_length=20)), + ('fax', models.CharField(blank=True, max_length=20)), + ('email', models.EmailField(max_length=254)), + ('priority', models.PositiveSmallIntegerField(default=0)), + ('token', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ('status', models.CharField(choices=[('pending', 'Not Yet Invited'), ('invited', 'Invited'), ('accepted', 'Accepted'), ('rejected', 'Declined'), ('expired', 'Expired')], default='pending', max_length=20)), + ('last_sent', models.DateTimeField(blank=True, null=True)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['panel', 'priority'], + }, + ), + migrations.AddField( + model_name='thesisevaluation', + name='numeric_grade', + field=models.DecimalField(blank=True, decimal_places=1, help_text='Decimal-mode final grade: average of supervisor_score and examiner_score', max_digits=3, null=True), + ), + migrations.CreateModel( + name='ThesisExaminerPanel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('hod_pending', 'Awaiting HOD Nomination'), ('dean_pending', 'Awaiting Dean Ranking'), ('invited', 'Invitations Sent'), ('accepted', 'Examiner Confirmed'), ('all_declined', 'All Candidates Declined')], default='hod_pending', max_length=20)), + ('hod_submitted_at', models.DateTimeField(blank=True, null=True)), + ('dean_invited_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('accepted_candidate', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='academic_procedures.thesisexaminercandidate')), + ('batch', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='thesis_examiner_panel', to='programme_curriculum.batch')), + ('dean_reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_panels_ranked', to=settings.AUTH_USER_MODEL)), + ('hod_submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_panels_nominated', to='globals.faculty')), + ], + ), + migrations.AddField( + model_name='thesisexaminercandidate', + name='panel', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='candidates', to='academic_procedures.thesisexaminerpanel'), + ), + migrations.CreateModel( + name='ThesisEvaluationScore', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('supervisor_score', models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True)), + ('supervisor_scored_at', models.DateTimeField(blank=True, null=True)), + ('examiner_score', models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True)), + ('examiner_scored_at', models.DateTimeField(blank=True, null=True)), + ('evaluation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='score_inputs', to='academic_procedures.thesisevaluation')), + ('examiner_candidate', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scores_given', to='academic_procedures.thesisexaminercandidate')), + ], + ), + migrations.AlterUniqueTogether( + name='thesisexaminercandidate', + unique_together={('panel', 'priority')}, + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0044_pgthesissubmission.py b/FusionIIIT/applications/academic_procedures/migrations/0044_pgthesissubmission.py new file mode 100644 index 000000000..099ed4645 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0044_pgthesissubmission.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.5 on 2026-08-04 10:58 + +import applications.academic_procedures.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0043_pg_thesis_examiner_panel_decimal_grading'), + ] + + operations = [ + migrations.CreateModel( + name='PGThesisSubmission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file_token', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ('synopsis', models.FileField(upload_to=applications.academic_procedures.models.upload_pg_synopsis)), + ('thesis_report', models.FileField(upload_to=applications.academic_procedures.models.upload_pg_report)), + ('submitted_at', models.DateTimeField(auto_now_add=True)), + ('thesis', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='pg_submission', to='academic_procedures.thesistopic')), + ], + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0045_widen_thesisevaluation_numeric_grade.py b/FusionIIIT/applications/academic_procedures/migrations/0045_widen_thesisevaluation_numeric_grade.py new file mode 100644 index 000000000..e93767ee0 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0045_widen_thesisevaluation_numeric_grade.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-08-04 20:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0044_pgthesissubmission'), + ] + + operations = [ + migrations.AlterField( + model_name='thesisevaluation', + name='numeric_grade', + field=models.DecimalField(blank=True, decimal_places=1, help_text='Decimal-mode final grade: average of supervisor_score and examiner_score', max_digits=4, null=True), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0046_merge_20260804_2334.py b/FusionIIIT/applications/academic_procedures/migrations/0046_merge_20260804_2334.py new file mode 100644 index 000000000..602bae185 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0046_merge_20260804_2334.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-08-04 23:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0023_courseaddrequest_course_instructor'), + ('academic_procedures', '0045_widen_thesisevaluation_numeric_grade'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/academic_procedures/migrations/0047_merge_20260805_2220.py b/FusionIIIT/applications/academic_procedures/migrations/0047_merge_20260805_2220.py new file mode 100644 index 000000000..eaefc53e5 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0047_merge_20260805_2220.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-08-05 22:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0046_merge_20260804_2334'), + ('academic_procedures', '0025_merge_20260805_2216'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/academic_procedures/models.py b/FusionIIIT/applications/academic_procedures/models.py index 1b0d70319..935682654 100644 --- a/FusionIIIT/applications/academic_procedures/models.py +++ b/FusionIIIT/applications/academic_procedures/models.py @@ -1,10 +1,11 @@ import datetime +from decimal import Decimal from django.db import models from django.contrib.postgres.fields import ArrayField from django.contrib.auth import get_user_model from applications.academic_information.models import Course, Student, Curriculum -from applications.programme_curriculum.models import Course as Courses, Semester, CourseSlot, Batch +from applications.programme_curriculum.models import Course as Courses, Semester, CourseSlot, Batch, ThesisSlot, SeminarSlot as ProgressSeminarSlot, TeachingCreditSlot from applications.globals.models import DepartmentInfo, ExtraInfo, Faculty from django.utils import timezone @@ -241,22 +242,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 @@ -1121,4 +1106,1298 @@ class FeedbackFilled(models.Model): filled_at = models.DateTimeField(auto_now_add=True) class Meta: - unique_together = ("student", "semester_no") \ No newline at end of file + unique_together = ("student", "semester_no") + + +# ============================================================================ +# PhD-SPECIFIC MODELS (Added for PhD student management) +# ============================================================================ + +class ThesisTopic(models.Model): + """Central thesis record with student submission fields and approval status.""" + STATUS_CHOICES = [ + ('supervisor_pending', 'Pending with Supervisor'), + ('hod_pending', 'Approved by Supervisor, Pending with HOD'), + ('hod_rejected', 'Rejected by HOD, Returned to Supervisor'), + ('dean_pending', 'Approved by HOD, Pending with Dean'), + ('dean_rejected', 'Rejected by Dean, Returned to HOD'), + ('dean_approved', 'Approved by Dean'), + ] + + student = models.ForeignKey(Student, on_delete=models.CASCADE) + supervisor = models.ForeignKey(Faculty, related_name='theses_supervised', on_delete=models.CASCADE) + co_supervisor = models.ForeignKey(Faculty, related_name='theses_cosupervised', on_delete=models.CASCADE, null=True, blank=True) + supervisor_consented = models.BooleanField(default=False) + co_supervisor_consented = models.BooleanField(default=False) + + category = models.CharField(max_length=20, choices=[ + ('Regular', 'Regular'), + ('Sponsored', 'Sponsored'), + ('External', 'External') + ]) + broad_area = models.CharField(max_length=200) + research_theme = models.TextField() + + external_name = models.CharField(max_length=100, blank=True) + external_email = models.EmailField(blank=True) + external_discipline = models.CharField(max_length=100, blank=True) + external_institution = models.CharField(max_length=200, blank=True) + + pg_single = models.PositiveIntegerField(default=0) + pg_shared = models.PositiveIntegerField(default=0) + phd_single = models.PositiveIntegerField(default=0) + phd_shared = models.PositiveIntegerField(default=0) + + status = models.CharField(max_length=30, choices=STATUS_CHOICES, default='supervisor_pending') + hod_remarks = models.TextField(blank=True) + dean_remarks = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + name = self.student.id.user.get_full_name() + theme = self.research_theme[:30] + return f"{name} — {theme}" + + +class CommitteeMember(models.Model): + """RPC committee member for each thesis.""" + thesis = models.ForeignKey(ThesisTopic, related_name='committee', on_delete=models.CASCADE) + member = models.ForeignKey(Faculty, on_delete=models.CASCADE) + + class Meta: + unique_together = ('thesis', 'member') + + def __str__(self): + return f"{self.member} on {self.thesis}" + + +class 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 = [ + ('draft', 'Draft'), + ('rpc_pending', 'Pending RPC Consent'), + ('rpc_approved','Approved'), + ] + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') + + # Logistics + seminar_date = models.DateField(null=True, blank=True) + seminar_time = models.TimeField(null=True, blank=True) + seminar_venue = models.CharField(max_length=200, blank=True) + + # Summaries + summary_prev = models.TextField(blank=True) + summary_curr = models.TextField(blank=True) + future_plan = models.TextField(blank=True) + upload_doc = models.FileField(upload_to='seminar_docs/', null=True, blank=True) + + # 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, + choices=[('Excellent','Excellent'), + ('Good','Good'), + ('Sat','Satisfactory'), + ('Unsat','Unsatisfactory')], + blank=True + ) + quantity = models.CharField( + max_length=20, + choices=[('Enough','Enough'), + ('Just','Just Sufficient'), + ('Insuff','Insufficient')], + blank=True + ) + overall_grade = models.CharField( + max_length=2, + choices=[('S','S'), ('X','X')], + blank=True + ) + expected_period = models.CharField( + max_length=2, + choices=[('1','1 year'), + ('2','2 years'), + ('3','3 years'), + ('4','4 years')], + blank=True + ) + rec_assist = models.CharField( + max_length=3, + choices=[('Yes','Yes'), + ('No','No'), + ('NA','Not Applicable')], + blank=True + ) + rec_enhance = models.CharField( + max_length=3, + choices=[('Yes','Yes'), + ('No','No'), + ('NA','Not Applicable')], + blank=True + ) + rec_repeat = models.CharField( + max_length=3, + choices=[('Yes','Yes'), + ('NA','Not Applicable')], + blank=True + ) + rec_open = models.CharField( + max_length=3, + choices=[('Yes','Yes'), + ('No','No')], + blank=True + ) + + def __str__(self): + return f"Seminar {self.version} for {self.thesis}" + + +class ProgressSeminarConsent(models.Model): + """RPC member consent for seminar.""" + 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) + + class Meta: + unique_together = ('seminar','member') + + +class ProgressSeminarComment(models.Model): + """RPC member comments on seminar.""" + 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) + + class Meta: + unique_together = ('seminar','member') + ordering = ['-timestamp'] + + +import uuid + +def upload_synopsis(instance, filename): + """Upload path for thesis synopsis.""" + ext = filename.split('.')[-1] + return f"synopsis/{instance.file_token}.{ext}" + +def upload_report(instance, filename): + """Upload path for thesis report.""" + ext = filename.split('.')[-1] + return f"reports/{instance.file_token}.{ext}" + + +class ThesisSubmission(models.Model): + """PhD Thesis submission with file uploads.""" + STATUS_CHOICES = [ + ('submitted', 'Submitted'), + ('dean_panel_review', 'Pending Dean Panel Approval'), + ('director_review', 'Pending Director Prioritization'), + ('dean_invite_pending', 'Pending Dean Invitation'), + ('in_review', 'In External Review'), + ('completed', 'Review Completed'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ] + + thesis = models.OneToOneField(ThesisTopic, on_delete=models.CASCADE, related_name='submission') + file_token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + synopsis = models.FileField(upload_to=upload_synopsis) + thesis_report = models.FileField(upload_to=upload_report) + submitted_at = models.DateTimeField(auto_now_add=True, db_index=True) + supervisor = models.ForeignKey('auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='supervised_subs') + supervisor_approved_at = models.DateTimeField(null=True, blank=True) + dean = models.ForeignKey('auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='deaned_subs') + dean_approved_at = models.DateTimeField(null=True, blank=True) + dean_invited_at = models.DateTimeField(null=True, blank=True) + 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) + + class Meta: + ordering = ['-submitted_at'] + indexes = [ + models.Index(fields=['status', 'submitted_at']), + ] + + def __str__(self): + return f"Submission for {self.thesis.research_theme}" + + +class ReviewInvitation(models.Model): + """External reviewer invitation for thesis.""" + EXAMINER_TYPE_CHOICES = [ + ('indian', 'Indian'), + ('foreign', 'Foreign'), + ] + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('accepted', 'Accepted'), + ('rejected', 'Rejected'), + ('completed', 'Completed'), + ('expired', 'Expired'), + ] + submission = models.ForeignKey(ThesisSubmission, on_delete=models.CASCADE, related_name='invitations') + examiner_type = models.CharField(max_length=10, choices=EXAMINER_TYPE_CHOICES, default='indian', db_index=True) + prof_name = models.CharField(max_length=255, db_index=True) + prof_position = models.CharField(max_length=255) + prof_address = models.TextField() + prof_phone = models.CharField(max_length=20) + prof_fax = models.CharField(max_length=20, blank=True, default='') + prof_email = models.EmailField(db_index=True) + prof_time_ranking = models.PositiveSmallIntegerField(null=True, blank=True) + priority = models.PositiveSmallIntegerField(default=0, db_index=True) + token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', db_index=True) + last_sent = models.DateTimeField(null=True, blank=True) + review_form_sent= models.DateTimeField(null=True, blank=True) + expires_at = models.DateTimeField(null=True, blank=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [('submission', 'examiner_type', 'priority')] + ordering = ['submission', 'examiner_type', 'priority'] + indexes = [ + models.Index(fields=['submission', 'status']), + models.Index(fields=['status', 'last_sent']), + ] + + def is_expired(self): + """Check if the invitation has expired.""" + return self.expires_at and timezone.now() >= self.expires_at + + def is_finalized(self): + """Check if the invitation is in a final state.""" + return self.status in ['completed', 'expired', 'rejected'] + + +class ThesisReview(models.Model): + """An examiner's formal evaluation, mirroring the institute's official + 'Examination Report of PhD Student' form.""" + CORRECTION_CHOICES = [ + ('none', 'None'), + ('minor', 'Minor'), + ('major', 'Major'), + ] + RECOMMENDATION_CHOICES = [ + ('accept', 'Acceptable in present form for award of the PhD degree'), + ('accept_with_corrections', 'Acceptable; suggested corrections/modifications to be incorporated'), + ('needs_improvement', "Needs technical improvement to the examiner's satisfaction"), + ('reject', 'Rejected -- thesis does not contain novel work'), + ] + + invitation = models.OneToOneField(ReviewInvitation, on_delete=models.CASCADE, related_name='review') + + # A. General features of thesis + originality_presentation = models.TextField(blank=True, default='') + quality_comparable = models.BooleanField(null=True, blank=True) + new_ideas_original = models.BooleanField(null=True, blank=True) + + # B. Comments + correction_severity = models.CharField(max_length=10, choices=CORRECTION_CHOICES, blank=True, default='') + technical_content = models.TextField(blank=True, default='') + highlights = models.TextField(blank=True, default='') + + # C, D + suggestions = models.TextField(blank=True, default='') + defense_questions = models.TextField(blank=True, default='') + + # E. Specific recommendation + recommendation = models.CharField(max_length=30, choices=RECOMMENDATION_CHOICES) + + submitted_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Review by {self.invitation.prof_name} ({self.recommendation})" + + +class ExaminerBankDetails(models.Model): + """Honorarium payment details for an external examiner. + + Kept as its own model, deliberately isolated from ThesisReview / + ReviewInvitation serialization -- never include this in dashboard or + listing API responses. + """ + invitation = models.OneToOneField(ReviewInvitation, on_delete=models.CASCADE, related_name='bank_details') + + beneficiary_name = models.CharField(max_length=255, blank=True, default='') + bank_name = models.CharField(max_length=255, blank=True, default='') + bank_address = models.TextField(blank=True, default='') + account_no = models.CharField(max_length=50, blank=True, default='') + # Indian examiners provide IFSC + PAN; foreign examiners provide IBAN + SWIFT. + ifsc_code = models.CharField(max_length=20, blank=True, default='') + pan_no = models.CharField(max_length=20, blank=True, default='') + iban_no = models.CharField(max_length=40, blank=True, default='') + swift_code = models.CharField(max_length=20, blank=True, default='') + + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Bank details for {self.invitation.prof_name}" + + def __str__(self): + return f"{self.prof_name} - {self.submission.thesis.research_theme} ({self.status})" + + +def upload_pg_synopsis(instance, filename): + """Upload path for a PG thesis synopsis. + + Extension is hardcoded rather than taken from the client-supplied + filename -- both are validated as PDF in the view, but deriving the + stored extension from user input would let a renamed file (e.g. + "x.html") get served back with a browser-inferred content type, + opening a stored-XSS path when a supervisor/examiner opens the link. + """ + return f"pg_thesis/synopsis/{instance.file_token}.pdf" + + +def upload_pg_report(instance, filename): + """Upload path for a PG thesis report. See upload_pg_synopsis.""" + return f"pg_thesis/report/{instance.file_token}.pdf" + + +class PGThesisSubmission(models.Model): + """PG (M.Tech/M.Des) final thesis submission -- synopsis + full report. + + Deliberately separate from ThesisSubmission: PhD's Dean Panel / Director / + foreign-examiner workflow doesn't apply to PG. This simpler model just + holds the uploaded documents that the supervisor (SupervisorThesisDecimalScores) + and the batch's accepted examiner (ThesisExaminerPanel) reference while + scoring the student's decimal-mode ThesisEvaluation. + """ + thesis = models.OneToOneField(ThesisTopic, on_delete=models.CASCADE, related_name='pg_submission') + file_token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + synopsis = models.FileField(upload_to=upload_pg_synopsis) + thesis_report = models.FileField(upload_to=upload_pg_report) + submitted_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"PG thesis submission — {self.thesis.student} ({self.submitted_at:%Y-%m-%d})" + + +# =========================================================================== +# Thesis Slot & Progress Seminar Semester-Level Registration +# =========================================================================== + +class ThesisRegistration(models.Model): + """Records a PhD student's semester-level thesis slot enrollment. + + Analogous to course_registration / FinalRegistration for courses. + One record per student per semester; admin verifies after submission. + """ + STATUS_CHOICES = [ + ('pending', 'Pending Verification'), + ('verified', 'Verified'), + ('rejected', 'Rejected'), + ] + + THESIS_CREDIT_CHOICES = [(3, '3 Credits'), (6, '6 Credits'), (9, '9 Credits'), (12, '12 Credits')] + + student = models.ForeignKey(Student, on_delete=models.CASCADE, + related_name='thesis_registrations') + thesis_slot = models.ForeignKey(ThesisSlot, on_delete=models.CASCADE, + related_name='registrations') + thesis_topic = models.ForeignKey('ThesisTopic', on_delete=models.SET_NULL, + null=True, blank=True, + related_name='thesis_registrations') + semester = models.ForeignKey(Semester, on_delete=models.CASCADE) + credits = models.PositiveSmallIntegerField( + choices=THESIS_CREDIT_CHOICES, + default=6, + help_text='Credits the student is registering for this semester (3/6/9/12)', + ) + working_year = models.IntegerField(null=True, blank=True) + academic_session = models.CharField(max_length=9, null=True, blank=True) # e.g. "2025-26" + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + registered_on = models.DateTimeField(auto_now_add=True) + verified_on = models.DateTimeField(null=True, blank=True) + remarks = models.CharField(max_length=500, blank=True) + + class Meta: + unique_together = ('student', 'semester') + db_table = 'ThesisRegistration' + + def __str__(self): + return f"{self.student} — {self.thesis_slot.name} ({self.semester})" + + +class ProgressSeminarRegistration(models.Model): + """Records a PhD student's semester-level progress seminar enrollment. + + Analogous to ThesisRegistration; one record per student per semester. + """ + STATUS_CHOICES = [ + ('pending', 'Pending Verification'), + ('verified', 'Verified'), + ('rejected', 'Rejected'), + ] + + student = models.ForeignKey(Student, on_delete=models.CASCADE, + related_name='progress_seminar_registrations') + progress_seminar_slot = models.ForeignKey(ProgressSeminarSlot, on_delete=models.CASCADE, + related_name='registrations') + semester = models.ForeignKey(Semester, on_delete=models.CASCADE) + working_year = models.IntegerField(null=True, blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + registered_on = models.DateTimeField(auto_now_add=True) + remarks = models.CharField(max_length=500, blank=True) + + class Meta: + unique_together = ('student', 'semester') + db_table = 'ProgressSeminarRegistration' + + def __str__(self): + return f"{self.student} — {self.progress_seminar_slot.name} ({self.semester})" + + +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 +# =========================================================================== + +class ThesisEvaluation(models.Model): + """Grade record for one evaluation block within a ThesisRegistration. + + A student who registers for N credits gets N÷3 blocks (1 block per 3 credits). + e.g. 12 credits → 4 blocks, each graded S or X independently. + Blocks are auto-created when the admin verifies the ThesisRegistration. + """ + GRADE_CHOICES = [('S', 'Satisfactory'), ('X', 'Unsatisfactory')] + + registration = models.ForeignKey( + ThesisRegistration, + on_delete=models.CASCADE, + related_name='evaluations', + ) + block_number = models.PositiveSmallIntegerField( + help_text='Sequential block index starting at 1 (max = registration.credits ÷ 3)', + ) + + # Grade — null until supervisor submits. Blocks mode (PhD, PG sem 2/3) + # uses `grade`; decimal mode (PG's final thesis semester) uses + # `numeric_grade` instead, computed from ThesisEvaluationScore once both + # the supervisor and examiner scores are in. A row only ever populates one. + grade = models.CharField( + max_length=1, choices=GRADE_CHOICES, null=True, blank=True, + ) + numeric_grade = models.DecimalField( + max_digits=4, decimal_places=1, null=True, blank=True, + help_text='Decimal-mode final grade: average of supervisor_score and examiner_score', + ) + submitted_by = models.ForeignKey( + Faculty, null=True, blank=True, + on_delete=models.SET_NULL, related_name='thesis_grades_submitted', + ) + submitted_at = models.DateTimeField(null=True, blank=True) + remarks = models.TextField(blank=True) + + # Admin lifecycle + verified = models.BooleanField(default=False) + verified_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='thesis_grades_verified', + ) + verified_at = models.DateTimeField(null=True, blank=True) + + announced = models.BooleanField(default=False) + announced_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = ('registration', 'block_number') + ordering = ['registration', 'block_number'] + db_table = 'ThesisEvaluation' + + def __str__(self): + g = self.grade or '—' + return ( + f"Block {self.block_number}/{self.registration.credits // 3} " + f"| {self.registration.student} | Sem {self.registration.semester.semester_no} " + f"| Grade: {g}" + ) + + @property + def total_blocks(self): + if self.registration.thesis_slot.evaluation_type == 'decimal': + return 1 + return self.registration.credits // 3 + + +class ThesisExaminerPanel(models.Model): + """A batch-wide (i.e. per-specialization, not per-student) examiner panel + for PG's decimal-graded final thesis semester -- mirrors the real paper + form, which is one sheet of 4 examiners per specialization. HOD nominates + 4 Indian examiner candidates for the whole batch; Dean ranks and invites + them; whichever candidate accepts first examines every student in that + batch. Multiple specialization batches within the same discipline+year + (e.g. CSE's "AI & ML" and "Data Science") each get their own independent + panel -- the HOD nomination screen and Dean ranking screen just group + those panels together for convenience (see hod_examiner_panel_dashboard / + dean_examiner_panel_dashboard), they don't merge the underlying process. + """ + STATUS_CHOICES = [ + ('hod_pending', 'Awaiting HOD Nomination'), + ('dean_pending', 'Awaiting Dean Ranking'), + ('invited', 'Invitations Sent'), + ('accepted', 'Examiner Confirmed'), + ('all_declined', 'All Candidates Declined'), + ] + + batch = models.OneToOneField(Batch, on_delete=models.CASCADE, related_name='thesis_examiner_panel') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='hod_pending') + hod_submitted_by = models.ForeignKey(Faculty, null=True, blank=True, + on_delete=models.SET_NULL, related_name='thesis_panels_nominated') + hod_submitted_at = models.DateTimeField(null=True, blank=True) + dean_reviewed_by = models.ForeignKey('auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='thesis_panels_ranked') + dean_invited_at = models.DateTimeField(null=True, blank=True) + accepted_candidate = models.OneToOneField( + 'ThesisExaminerCandidate', null=True, blank=True, + on_delete=models.SET_NULL, related_name='+', + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Examiner panel — {self.batch} [{self.status}]" + + +class ThesisExaminerCandidate(models.Model): + """One HOD-nominated examiner candidate within a ThesisExaminerPanel. + Same token-based accept/decline pattern as ReviewInvitation, minus the + Indian/foreign split -- PG examiners are Indian-only. + """ + STATUS_CHOICES = [ + ('pending', 'Not Yet Invited'), + ('invited', 'Invited'), + ('accepted', 'Accepted'), + ('rejected', 'Declined'), + ('expired', 'Expired'), + ] + + panel = models.ForeignKey(ThesisExaminerPanel, on_delete=models.CASCADE, related_name='candidates') + name = models.CharField(max_length=255) + position = models.CharField(max_length=255, blank=True) + address = models.TextField(blank=True) + phone = models.CharField(max_length=20, blank=True) + fax = models.CharField(max_length=20, blank=True) + email = models.EmailField() + priority = models.PositiveSmallIntegerField(default=0) + token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + last_sent = models.DateTimeField(null=True, blank=True) + expires_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 = ('panel', 'priority') + ordering = ['panel', 'priority'] + + def is_expired(self): + return bool(self.expires_at and timezone.now() >= self.expires_at) + + def is_finalized(self): + """Unlike ReviewInvitation.is_finalized() (which excludes 'accepted', + since an accepted PhD reviewer still has more steps ahead), 'accepted' + counts as finalized here on purpose: an examiner candidate who has + accepted shouldn't be able to accept/reject again. Don't assume parity + between the two if refactoring to share logic.""" + return self.status in ('accepted', 'rejected', 'expired') + + def __str__(self): + return f"{self.name} — {self.panel.batch} [{self.status}]" + + +class ThesisEvaluationScore(models.Model): + """Raw supervisor/examiner input scores (out of 100) for a decimal-mode + ThesisEvaluation. ThesisEvaluation.numeric_grade holds the averaged + result once both scores are in; this table only ever holds rows for + decimal-mode evaluations, so nothing here is sparse. + """ + evaluation = models.OneToOneField(ThesisEvaluation, on_delete=models.CASCADE, related_name='score_inputs') + supervisor_score = models.DecimalField(max_digits=4, decimal_places=1, null=True, blank=True) + supervisor_scored_at = models.DateTimeField(null=True, blank=True) + examiner_candidate = models.ForeignKey(ThesisExaminerCandidate, null=True, blank=True, + on_delete=models.SET_NULL, related_name='scores_given') + examiner_score = models.DecimalField(max_digits=4, decimal_places=1, null=True, blank=True) + examiner_scored_at = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return f"Scores for {self.evaluation} (sup={self.supervisor_score}, exam={self.examiner_score})" + + +class ProgressSeminarEvaluation(models.Model): + """Grade record for a ProgressSeminarRegistration. + + Progress seminars are fixed at 3 credits → always exactly 1 evaluation block. + """ + GRADE_CHOICES = [('S', 'Satisfactory'), ('X', 'Unsatisfactory')] + + registration = models.OneToOneField( + ProgressSeminarRegistration, + on_delete=models.CASCADE, + related_name='evaluation', + ) + + grade = models.CharField( + max_length=1, choices=GRADE_CHOICES, null=True, blank=True, + ) + submitted_by = models.ForeignKey( + Faculty, null=True, blank=True, + on_delete=models.SET_NULL, related_name='seminar_grades_submitted', + ) + submitted_at = models.DateTimeField(null=True, blank=True) + remarks = models.TextField(blank=True) + + verified = models.BooleanField(default=False) + verified_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='seminar_grades_verified', + ) + verified_at = models.DateTimeField(null=True, blank=True) + + announced = models.BooleanField(default=False) + announced_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = 'ProgressSeminarEvaluation' + + def __str__(self): + g = self.grade or '—' + return ( + f"{self.registration.student} | Sem {self.registration.semester.semester_no} " + f"| Grade: {g}" + ) + + +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 + + +DEFAULT_TEACHING_CREDIT = 0 +# Mirrors DEFAULT_PROGRESS_SEMINAR_CREDIT's reasoning: 0, not a guess, so a +# missing/unlinked TeachingCreditRegistration under-counts rather than +# fabricates a plausible-looking credit value. + + +def resolve_teaching_credit_catalog_entry(student, semester): + """Resolve the programme_curriculum.TeachingCredit catalog row (code/name/credit) + that applies to a student's teaching credit in a given semester, via their + TeachingCreditRegistration -> TeachingCreditSlot -> teaching_credits M2M, + preferring the student's own discipline when a slot serves more than one. + Returns None if no registration/catalog match exists. + + This is the single source of truth for "how many credits is one semester of + teaching credit worth" -- do not hardcode that number elsewhere; call this instead.""" + if not semester: + return None + reg = TeachingCreditRegistration.objects.filter( + student=student, semester=semester + ).select_related('teaching_credit_slot').prefetch_related( + 'teaching_credit_slot__teaching_credits' + ).first() + if not reg or not reg.teaching_credit_slot: + return None + discipline = getattr(getattr(student, 'batch_id', None), 'discipline', None) + manager = reg.teaching_credit_slot.teaching_credits + entry = (manager.filter(discipline=discipline).first() if discipline else None) or manager.first() + return entry + + +def resolve_teaching_credit_credit(student, semester): + """Credit value for one semester of teaching credit, sourced from the catalog + via resolve_teaching_credit_catalog_entry(), falling back to + DEFAULT_TEACHING_CREDIT only when no catalog entry can be found.""" + entry = resolve_teaching_credit_catalog_entry(student, semester) + return entry.credit if entry and entry.credit else DEFAULT_TEACHING_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 & DPGC approval. + + One record per student. The exam itself — RPC review, dates, result — is + tracked per-attempt in ComprehensiveExamAttempt, since a student may + retake it up to MAX_ATTEMPTS times. There is no separate examination + committee here: the student's existing RPC (Progress Seminar committee, + CommitteeMember via their ThesisTopic) doubles as the examination + committee and is read live, never proposed/stored here. + """ + 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'), + ('dpgc_pending', 'Pending Convener (DPGC) Approval'), + ('dpgc_rejected', 'Rejected by Convener (DPGC)'), + ('in_progress', 'Approved by DPGC — In Progress'), + ('passed', 'Passed'), + ('failed_final', 'Failed — Attempts Exhausted'), + ] + + MAX_ATTEMPTS = 2 + MIN_CPI = Decimal('7.00') + + 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) + proposed_exam_date = models.DateField( + null=True, blank=True, + help_text="Set by the supervisor at proposal time; seeds attempt 1's exam_date once DPGC approves.", + ) + + 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 (DPGC) approval — HOD of the student's department stands in for + # the DPGC convener. + dpgc_remarks = models.TextField(blank=True) + dpgc_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='comprehensive_exams_dpgc_reviewed', + ) + dpgc_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 ComprehensiveExamAttempt(models.Model): + """One attempt (max ComprehensiveExam.MAX_ATTEMPTS) of the exam: RPC + review, dates, result. + + The RPC (the student's existing committee) collectively records the + result + qualitative comments, each member individually consenting — + mirroring ProgressSeminarEntry/ProgressSeminarConsent. Convener (PGCS, + also HOD) then reviews the RPC's finalized result before forwarding to + Dean Academic for final approval. + """ + STATUS_CHOICES = [ + ('rpc_pending', 'Pending RPC Consensus'), + ('pgcs_pending', 'RPC Finalized — Pending Convener (PGCS) Review'), + ('dean_pending', 'Approved by PGCS — Pending Dean Academic'), + ('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='rpc_pending') + + exam_date = models.DateField(null=True, blank=True, help_text="Settable by the supervisor or any RPC member; may be updated again when RPC finalizes the report.") + + # Result — mirrors the official "Comprehensive Examination Report" form; + # filled collectively by the RPC (shared panel, like Progress Seminar). + 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', + help_text="Whichever RPC member finalized the panel (moved rpc_pending -> pgcs_pending).", + ) + reported_at = models.DateTimeField(null=True, blank=True) + + # Convener (PGCS) review — HOD of the student's department stands in. + pgcs_remarks = models.TextField(blank=True) + pgcs_reviewed_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='comprehensive_attempts_pgcs_reviewed', + ) + pgcs_reviewed_at = models.DateTimeField(null=True, blank=True) + + # Dean Academic — forward-only final approval, no remarks/rejection. + dean_approved_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='comprehensive_attempts_dean_approved', + ) + dean_approved_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 ComprehensiveExamConsent(models.Model): + """RPC member consent for a comprehensive exam attempt's shared result panel.""" + attempt = models.ForeignKey(ComprehensiveExamAttempt, related_name='consents', on_delete=models.CASCADE) + member = models.ForeignKey(Faculty, on_delete=models.CASCADE) + consented = models.BooleanField(default=False) + timestamp = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('attempt', 'member') + + +class ComprehensiveExamRPCComment(models.Model): + """RPC member's personal comment on a comprehensive exam attempt.""" + attempt = models.ForeignKey(ComprehensiveExamAttempt, related_name='rpc_comments', on_delete=models.CASCADE) + member = models.ForeignKey(Faculty, on_delete=models.CASCADE) + text = models.TextField() + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('attempt', 'member') + ordering = ['-timestamp'] + + +# =========================================================================== +# Open Seminar +# =========================================================================== + +class OpenSeminar(models.Model): + """PhD Open Seminar — parent record, one per student. + + Holds the one-time gate: eligibility snapshot + Convener (DPGC, HOD of + the student's department) review + Dean Academic approval (which + appoints the Dean Nominee). This only happens once per student -- unlike + ComprehensiveExam, retries skip straight back to RPC review, not through + this gate again. There is no separate examination committee here: the + student's existing RPC (Progress Seminar committee, CommitteeMember via + their ThesisTopic) doubles as the examination committee and is read + live, never proposed/stored here. + """ + STATUS_CHOICES = [ + ('hod_pending', 'Pending Convener (DPGC) Review'), + ('hod_rejected', 'Rejected by Convener (DPGC)'), + ('dean_pending', 'Pending Dean Academic Approval'), + ('dean_rejected', 'Rejected by Dean Academic'), + ('in_progress', 'Approved — 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) + proposed_date = models.DateField( + null=True, blank=True, + help_text="Set by the supervisor at proposal time; seeds attempt 1's seminar_date once Dean approves.", + ) + + # Eligibility snapshot -- computed once at proposal, not re-verified per + # attempt (moved here from the attempt, since this gate is one-time). + # 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) + rpc_recommended_open_seminar = models.BooleanField(default=False) + first_draft_document = models.FileField(upload_to='open_seminar_first_drafts/', null=True, blank=True) + + # Convener (DPGC) early review -- HOD of the student's department stands in. + hod_remarks = models.TextField(blank=True) + hod_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='open_seminars_hod_reviewed', + ) + hod_at = models.DateTimeField(null=True, blank=True) + + # Dean Academic early approval -- appoints the Dean Nominee (on attempt 1). + dean_remarks = models.TextField(blank=True) + dean_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='open_seminars_dean_approved', + ) + dean_at = models.DateTimeField(null=True, blank=True) + + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='hod_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 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 — {self.student.id.user.get_full_name()}" + + +class OpenSeminarAttempt(models.Model): + """One attempt (unlimited) of the Open Seminar: RPC review, verdict, and + (attempt 1 only) the Dean Nominee's separate confidential report. + + The RPC (the student's existing committee) collectively records the + result + comments, each member individually consenting — mirroring + ProgressSeminarEntry/ProgressSeminarConsent and ComprehensiveExamAttempt. + Convener (DPGC) then reviews the finalized result before forwarding to + Dean Academic for final approval. + """ + STATUS_CHOICES = [ + ('rpc_pending', 'Pending RPC Consensus'), + ('hod_review_pending', 'RPC Finalized — Pending Convener (DPGC) Review'), + ('dean_pending', 'Approved by Convener — Pending Dean Academic'), + ('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=25, choices=STATUS_CHOICES, default='rpc_pending') + + seminar_date = models.DateField( + null=True, blank=True, + help_text="Settable by the supervisor or any RPC member; may be updated again when RPC finalizes.", + ) + + # Committee's joint verdict -- filled collectively by the RPC (shared + # panel, like Progress Seminar / Comprehensive Exam). + 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', + help_text="Whichever RPC member finalized the panel (moved rpc_pending -> hod_review_pending).", + ) + reported_at = models.DateTimeField(null=True, blank=True) + + # Convener (DPGC) review, post-RPC -- HOD of the student's department stands in. + hod_review_remarks = models.TextField(blank=True) + hod_reviewed_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='open_seminar_attempts_hod_reviewed', + ) + hod_reviewed_at = models.DateTimeField(null=True, blank=True) + + # Dean Academic final approval -- forward-only, no remarks/rejection. + dean_approved_by = models.ForeignKey( + 'auth.User', null=True, blank=True, + on_delete=models.SET_NULL, related_name='open_seminar_attempts_dean_approved', + ) + dean_approved_at = models.DateTimeField(null=True, blank=True) + + # Dean Nominee -- ad-hoc faculty appointment, made once by the Dean at + # OpenSeminar's early-approval step (attempt 1 only; retries skip that + # gate and never get a new nominee). Submits their own confidential + # report independently -- 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 + # the dn_* fields. + dean_nominee = models.ForeignKey( + Faculty, null=True, blank=True, + on_delete=models.SET_NULL, related_name='open_seminar_nominations', + ) + 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'] + + def __str__(self): + return f"Open Seminar Attempt {self.attempt_number} — {self.open_seminar}" + + +class OpenSeminarConsent(models.Model): + """RPC member consent for an open seminar attempt's shared result panel.""" + attempt = models.ForeignKey(OpenSeminarAttempt, related_name='consents', on_delete=models.CASCADE) + member = models.ForeignKey(Faculty, on_delete=models.CASCADE) + consented = models.BooleanField(default=False) + timestamp = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('attempt', 'member') + + +class OpenSeminarRPCComment(models.Model): + """RPC member's personal comment on an open seminar attempt.""" + attempt = models.ForeignKey(OpenSeminarAttempt, related_name='rpc_comments', on_delete=models.CASCADE) + member = models.ForeignKey(Faculty, on_delete=models.CASCADE) + text = models.TextField() + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('attempt', 'member') + ordering = ['-timestamp'] + + +# =========================================================================== +# 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/tasks.py b/FusionIIIT/applications/academic_procedures/tasks.py new file mode 100644 index 000000000..bbd13d356 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/tasks.py @@ -0,0 +1,84 @@ +from __future__ import absolute_import, unicode_literals + +import logging +from datetime import timedelta + +from celery import shared_task +from django.utils import timezone + +from .models import ThesisSubmission, ReviewInvitation +from .utils import send_invitation_email, send_review_form_email, advance_invitation, INVITATION_TIMEOUT_DAYS + +logger = logging.getLogger(__name__) + +REMINDER_INTERVAL_DAYS = 3 + + +@shared_task() +def process_review_invitations(): + """ + Runs daily. For each in-review submission, and each examiner category + (Indian / Foreign) independently: + 1) If the lowest-priority invite in that category was never sent -> send it. + 2) If it has been pending past the timeout -> expire it and advance to + the next-ranked examiner in that category. + 3) If still pending -> resend a reminder every few days. + 4) If accepted -> send the daily review-form link. + """ + now = timezone.now() + submissions = ThesisSubmission.objects.filter(status='in_review') + logger.info(f"process_review_invitations starting for {submissions.count()} submissions") + + for sub in submissions: + for examiner_type in ('indian', 'foreign'): + invites = ( + ReviewInvitation.objects + .filter(submission=sub, examiner_type=examiner_type) + .order_by('priority') + ) + # This category already has a completed review -- nothing left + # to send/expire/remind for it. The other category (if still + # pending) continues independently below. + if invites.filter(status='completed').exists(): + continue + + # Exactly one examiner per category should be "live" at a time: + # the lowest-priority one who hasn't declined/expired. Acting on + # any invite beyond this one would mean contacting a lower-rank + # examiner while a higher-rank one is still legitimately pending. + inv = invites.exclude(status__in=['expired', 'rejected']).first() + if inv is None: + continue + + try: + if inv.last_sent is None: + inv.last_sent = now + inv.expires_at = now + timedelta(days=INVITATION_TIMEOUT_DAYS) + inv.save(update_fields=['last_sent', 'expires_at']) + send_invitation_email(inv) + logger.info(f"Sent initial invitation for token {inv.token}") + + elif inv.is_expired(): + inv.status = 'expired' + inv.save(update_fields=['status']) + logger.info(f"Expired invitation {inv.token} ({INVITATION_TIMEOUT_DAYS}-day timeout)") + advance_invitation(sub, examiner_type) + + elif inv.status == 'pending' and now >= inv.last_sent + timedelta(days=REMINDER_INTERVAL_DAYS): + send_invitation_email(inv) + inv.last_sent = now + inv.save(update_fields=['last_sent']) + logger.info(f"Sent reminder for token {inv.token}") + + elif inv.status == 'accepted' and ( + inv.review_form_sent is None or now >= inv.review_form_sent + timedelta(days=1) + ): + send_review_form_email(inv) + inv.review_form_sent = now + inv.save(update_fields=['review_form_sent']) + logger.info(f"Sent review-form link for token {inv.token}") + except Exception as e: + logger.exception(f"Error processing invitation {inv.token} for submission {sub.id}: {e}") + continue + + logger.info("process_review_invitations completed") diff --git a/FusionIIIT/applications/academic_procedures/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/utils.py b/FusionIIIT/applications/academic_procedures/utils.py new file mode 100644 index 000000000..df464b54a --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/utils.py @@ -0,0 +1,241 @@ +from datetime import timedelta + +from django.core.mail import EmailMultiAlternatives +from django.conf import settings +from django.template.loader import render_to_string +from django.utils import timezone +from django.utils.html import strip_tags +import logging + +logger = logging.getLogger(__name__) + +INVITATION_TIMEOUT_DAYS = 15 + +def send_invitation_email(inv): + """ + Send the initial invitation email to the professor with template rendering. + """ + try: + thesis_title = inv.submission.thesis.research_theme + frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:5173') + accept_url = f"{frontend_url}/thesis-invitation/{inv.token}/accept" + reject_url = f"{frontend_url}/thesis-invitation/{inv.token}/reject" + expires_at = inv.expires_at.strftime('%Y-%m-%d') if inv.expires_at else 'N/A' + + + context = { + 'prof_name': inv.prof_name, + 'thesis_title': thesis_title, + 'accept_url': accept_url, + 'reject_url': reject_url, + 'expires_at': expires_at, + } + + subject = f"Invitation to review: {thesis_title}" + html_content = render_to_string('email/invitation.html', context) + text_content = render_to_string('email/invitation.txt', context) + + msg = EmailMultiAlternatives( + subject, + text_content, + settings.DEFAULT_FROM_EMAIL, + [inv.prof_email], + ) + msg.attach_alternative(html_content, 'text/html') + msg.send() + logger.info(f"Invitation email sent to {inv.prof_email} for token {inv.token}") + except Exception as e: + logger.exception(f"Failed to send invitation email for token {inv.token}: {e}") + raise + + +def send_review_form_email(inv): + """ + Send the review form link after the professor has accepted the invitation. + """ + try: + thesis_title = inv.submission.thesis.research_theme + frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:5173') + review_url = f"{frontend_url}/thesis-evaluation/{inv.token}" + + context = { + 'prof_name': inv.prof_name, + 'thesis_title': thesis_title, + 'review_url': review_url, + } + + subject = f"Review form: {thesis_title}" + html_content = render_to_string('email/review_form.html', context) + text_content = render_to_string('email/review_form.txt', context) + + msg = EmailMultiAlternatives( + subject, + text_content, + settings.DEFAULT_FROM_EMAIL, + [inv.prof_email], + ) + msg.attach_alternative(html_content, 'text/html') + msg.send() + logger.info(f"Review form email sent to {inv.prof_email} for token {inv.token}") + except Exception as e: + logger.exception(f"Failed to send review form email for token {inv.token}: {e}") + raise + + +def send_thank_you_email(inv): + """ + Send a thank-you note once the professor submits their review. + """ + try: + thesis_title = inv.submission.thesis.research_theme + + context = { + 'prof_name': inv.prof_name, + 'thesis_title': thesis_title, + } + + subject = f"Thank you for reviewing: {thesis_title}" + html_content = render_to_string('email/thank_you.html', context) + text_content = render_to_string('email/thank_you.txt', context) + + msg = EmailMultiAlternatives( + subject, + text_content, + settings.DEFAULT_FROM_EMAIL, + [inv.prof_email], + ) + msg.attach_alternative(html_content, 'text/html') + msg.send() + logger.info(f"Thank you email sent to {inv.prof_email} for token {inv.token}") + except Exception as e: + logger.exception(f"Failed to send thank you email for token {inv.token}: {e}") + raise + + +def send_examiner_panel_invitation_email(candidate): + """ + Send the initial invitation email to a ThesisExaminerPanel candidate. + """ + try: + batch_name = str(candidate.panel.batch) + frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:5173') + accept_url = f"{frontend_url}/thesis-examiner-panel/{candidate.token}/accept" + reject_url = f"{frontend_url}/thesis-examiner-panel/{candidate.token}/reject" + expires_at = candidate.expires_at.strftime('%Y-%m-%d') if candidate.expires_at else 'N/A' + + context = { + 'prof_name': candidate.name, + 'batch_name': batch_name, + 'accept_url': accept_url, + 'reject_url': reject_url, + 'expires_at': expires_at, + } + + subject = f"Examiner invitation: {batch_name}" + html_content = render_to_string('email/examiner_panel_invitation.html', context) + text_content = render_to_string('email/examiner_panel_invitation.txt', context) + + msg = EmailMultiAlternatives( + subject, + text_content, + settings.DEFAULT_FROM_EMAIL, + [candidate.email], + ) + msg.attach_alternative(html_content, 'text/html') + msg.send() + logger.info(f"Examiner panel invitation sent to {candidate.email} for token {candidate.token}") + except Exception as e: + logger.exception(f"Failed to send examiner panel invitation for token {candidate.token}: {e}") + raise + + +def send_examiner_panel_scoring_email(candidate): + """ + Send the batch-scoring link after the candidate has accepted. + """ + try: + batch_name = str(candidate.panel.batch) + frontend_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:5173') + scoring_url = f"{frontend_url}/thesis-examiner-panel/{candidate.token}/score" + + context = { + 'prof_name': candidate.name, + 'batch_name': batch_name, + 'scoring_url': scoring_url, + } + + subject = f"Scoring form: {batch_name}" + html_content = render_to_string('email/examiner_panel_scoring.html', context) + text_content = render_to_string('email/examiner_panel_scoring.txt', context) + + msg = EmailMultiAlternatives( + subject, + text_content, + settings.DEFAULT_FROM_EMAIL, + [candidate.email], + ) + msg.attach_alternative(html_content, 'text/html') + msg.send() + logger.info(f"Examiner panel scoring link sent to {candidate.email} for token {candidate.token}") + except Exception as e: + logger.exception(f"Failed to send examiner panel scoring link for token {candidate.token}: {e}") + raise + + +def advance_examiner_panel_invitation(panel): + """ + Send the invitation to the next-ranked, not-yet-sent candidate for this + panel. Used when a candidate declines, so decline always falls through + to the next professor in the Dean's priority order. Returns the newly + invited candidate, or None (and marks the panel 'all_declined') if no + candidates remain. + """ + from .models import ThesisExaminerCandidate + + next_candidate = ( + ThesisExaminerCandidate.objects + .filter(panel=panel, last_sent__isnull=True) + .order_by('priority') + .first() + ) + if next_candidate is None: + panel.status = 'all_declined' + panel.save(update_fields=['status']) + logger.warning(f"No more examiner candidates left to invite for panel {panel.id} ({panel.batch})") + return None + + next_candidate.status = 'invited' + next_candidate.last_sent = timezone.now() + next_candidate.expires_at = timezone.now() + timedelta(days=INVITATION_TIMEOUT_DAYS) + next_candidate.save(update_fields=['status', 'last_sent', 'expires_at']) + send_examiner_panel_invitation_email(next_candidate) + return next_candidate + + +def advance_invitation(submission, examiner_type): + """ + Send the invitation to the next-ranked, not-yet-sent examiner of the + given type (indian/foreign) for this submission. Used both when an + examiner declines and by the daily timeout job, so a decline/timeout + always falls through to the next professor in the Director's priority + order for that category. + """ + from .models import ReviewInvitation + + next_inv = ( + ReviewInvitation.objects + .filter(submission=submission, examiner_type=examiner_type, last_sent__isnull=True) + .order_by('priority') + .first() + ) + if next_inv is None: + logger.warning( + f"No more {examiner_type} examiners left to invite for submission {submission.id}" + ) + return None + + next_inv.last_sent = timezone.now() + next_inv.expires_at = timezone.now() + timedelta(days=INVITATION_TIMEOUT_DAYS) + next_inv.save(update_fields=['last_sent', 'expires_at']) + send_invitation_email(next_inv) + return next_inv diff --git a/FusionIIIT/applications/academic_procedures/views.py b/FusionIIIT/applications/academic_procedures/views.py index 79ddb2f5b..acb18646e 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 @@ -2958,42 +2958,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/central_mess/handlers.py b/FusionIIIT/applications/central_mess/handlers.py index 882cbe4e4..73734b5c1 100644 --- a/FusionIIIT/applications/central_mess/handlers.py +++ b/FusionIIIT/applications/central_mess/handlers.py @@ -393,7 +393,7 @@ def add_mess_meeting_invitation(request): invitation_obj.save() message = "Mess Committee meeting on " + date_today + " at " + time + ".\n Venue: " + venue + ".\n Agenda: " + agenda for invi in members_mess: - central_mess_notif(request.user, invi.user, 'meeting_invitation', message) + central_mess_notif(request.user, invi.user, 'meeting_invitation', message, role=invi.designation.name) data = { 'status': 1, diff --git a/FusionIIIT/applications/complaint_system/views.py b/FusionIIIT/applications/complaint_system/views.py index 9d3dd7316..b746342a4 100644 --- a/FusionIIIT/applications/complaint_system/views.py +++ b/FusionIIIT/applications/complaint_system/views.py @@ -409,7 +409,7 @@ def user(request): # This is to allow the student student = 0 message = "A New Complaint has been lodged" - complaint_system_notif(request.user, caretaker_name.user,'lodge_comp_alert',obj1.id,student,message) + complaint_system_notif(request.user, caretaker_name.user,'lodge_comp_alert',obj1.id,student,message,role=dsgn) # complaint_system_notif(request.user, secincharge_name.staff_id.user,'lodge_comp_alert',obj1.id,1,message) messages.success(request,message) @@ -698,7 +698,7 @@ def caretaker(request): # This is to allow the student student = 1 message = "A New Complaint has been lodged" - complaint_system_notif(request.user, caretaker_name.user,'lodge_comp_alert',obj1.id,student,message) + complaint_system_notif(request.user, caretaker_name.user,'lodge_comp_alert',obj1.id,student,message,role=dsgn) # return render(request, "complaintModule/complaint_user.html", # {'history': history, 'comp_id': comp_id }) @@ -1120,7 +1120,7 @@ def supervisor(request): # This is to allow the student student = 1 message = "A New Complaint has been lodged" - complaint_system_notif(request.user, caretaker_name.user,'lodge_comp_alert',obj1.id,student,message) + complaint_system_notif(request.user, caretaker_name.user,'lodge_comp_alert',obj1.id,student,message,role=dsgn) # return render(request, "complaintModule/complaint_user.html", # {'history': history, 'comp_id': comp_id }) @@ -1550,7 +1550,7 @@ def supervisorlodge(request): # This is to allow the student student = 1 message = "A New Complaint has been lodged" - complaint_system_notif(request.user, caretaker_name.user,'lodge_comp_alert',obj1.id,student,message) + complaint_system_notif(request.user, caretaker_name.user,'lodge_comp_alert',obj1.id,student,message,role=dsgn) # return render(request, "complaintModule/complaint_user.html", # {'history': history, 'comp_id': comp_id }) @@ -1777,7 +1777,7 @@ def caretakerlodge(request): # This is to allow the student student = 1 message = "A New Complaint has been lodged" - complaint_system_notif(request.user, caretaker_name.user,'lodge_comp_alert',obj1.id,student,message) + complaint_system_notif(request.user, caretaker_name.user,'lodge_comp_alert',obj1.id,student,message,role=dsgn) # return render(request, "complaintModule/complaint_user.html", # {'history': history, 'comp_id': comp_id }) diff --git a/FusionIIIT/applications/examination/api/views.py b/FusionIIIT/applications/examination/api/views.py index d7e48ec80..2094b1f73 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,167 @@ 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/PG 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 UG 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. + + PG (M.Tech/M.Des) students use this same ThesisRegistration/ThesisEvaluation + pipeline for their sem 2/3 block-graded thesis (see academic_procedures.models. + ThesisEvaluation's docstring), so the eligibility check covers both buckets -- + not just PHD, despite this helper's PhD-era name.""" + if student.programme not in PROGRAMME_TYPE_BUCKETS['PHD'] + PROGRAMME_TYPE_BUCKETS['PG']: + 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 +293,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 +309,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 = ( @@ -165,9 +343,7 @@ def calculate_cpi_for_student(student, selected_semester, semester_type): if new_code and old_code and new_code != old_code: code_replacement_map[new_code] = old_code - # Best-graded attempt per distinct course code. This collapses backlog/ - # improvement retakes and duplicate grade rows of the SAME course to a single - # credit, but keeps genuinely different courses separate. + # Best-graded attempt per distinct course code (dedups backlog/improvement retakes; keeps distinct courses separate). best_by_code = {} for g in grades: code = (g.course_id.code or '').strip() @@ -178,6 +354,7 @@ def calculate_cpi_for_student(student, selected_semester, semester_type): > grade_conversion.get((prev.grade or '').strip(), -1)): best_by_code[code] = g + # A replaced elective is superseded once a replacement is graded; each distinct replacement counts on its own. superseded_codes = set() for graded_code in best_by_code: old = code_replacement_map.get(graded_code) @@ -185,20 +362,20 @@ def calculate_cpi_for_student(student, selected_semester, semester_type): superseded_codes.add(old) old = code_replacement_map.get(old) - total_points = Decimal('0') - total_credits = Decimal('0') + totals = {'points': Decimal('0'), 'credits': Decimal('0'), 'earned': Decimal('0')} for code, best_record in best_by_code.items(): if code in superseded_codes: continue - grade_factor = grade_conversion.get((best_record.grade or '').strip(), -1) course_credit = best_record.course_id.credit credit = Decimal(str(course_credit)) if course_credit is not None else Decimal('0') - 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): @@ -324,15 +501,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( @@ -425,27 +598,22 @@ 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 ) # Scope by the offering each registration is bound to (course_instructor), # falling back to the student's home section only for pre-sectioning rows. + # Sections apply only to UG (PG/PhD have no sections); electives ignore it. section = (request.data.get('section') or '').strip() or None if section and (programme_type or '').strip().upper() == 'UG': course_info_query = course_info_query.filter( @@ -457,7 +625,7 @@ def download_template(request): 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 @@ -554,25 +722,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 @@ -940,6 +1102,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 = { @@ -1588,13 +1751,9 @@ def post(self, request): 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( @@ -2029,20 +2188,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() @@ -2088,20 +2241,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") @@ -2888,8 +3035,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 = { @@ -2905,20 +3052,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, @@ -2998,20 +3156,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) # Scope by the offering each registration is bound to (course_instructor), @@ -3620,6 +3772,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) @@ -3651,8 +3810,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(), @@ -3667,10 +3826,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, @@ -3682,6 +3839,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)) @@ -3906,9 +4072,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) @@ -4003,6 +4169,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", @@ -4251,6 +4419,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/PG Thesis / Progress Seminar / Teaching Credit rows ─────── + key_to_phd_rows = {} + if student.programme in PROGRAMME_TYPE_BUCKETS['PHD'] + PROGRAMME_TYPE_BUCKETS['PG']: + 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"} # Backlog/Improvement remark only + course_first_grade = {} + + semesters_data = [] + summer_counter = 0 + cumulative_credits = Decimal('0') # deduped total (tu), count-once + + 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_conversion.get(grade.strip(), -1) >= 0: + 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 grade_conversion.get((phd_row["grade"] or "").strip(), -1) >= 0: + sem_credits_earned += Decimal(str(phd_row["credits"])) + + if courses: + try: + s_spi, _, _ = calculate_spi_for_student(student, s_no, s_type) + # 2nd return is the deduped total credits (tu) up to this semester. + s_cpi, s_cum_credits, _ = calculate_cpi_for_student(student, s_no, s_type) + cumulative_credits = Decimal(str(s_cum_credits)) + 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(cumulative_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(cumulative_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, @@ -4342,182 +4729,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"} # for Backlog/Improvement remark only - course_first_grade: dict = {} # course_id -> first grade string - - semesters_data = [] - summer_counter = 0 # increment each time we encounter a summer semester - cumulative_credits = Decimal('0') # deduped running total (tu), count-once - - 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 - - # Credit is earned for any grade that carries points, incl. F - # (which earns credit with grade point 2) and S. Only X/I/AU/CD - # (absent from grade_conversion -> factor -1) earn no credit. - credit = Decimal(str(course.credit)) if course.credit is not None else Decimal('0') - if grade_conversion.get(grade.strip(), -1) >= 0: - 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) - try: - s_spi, _, _ = calculate_spi_for_student(student, s_no, s_type) - s_cpi, s_cum_credits, _ = calculate_cpi_for_student(student, s_no, s_type) - cumulative_credits = Decimal(str(s_cum_credits)) - 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(cumulative_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(cumulative_credits), - "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}, @@ -4552,151 +4764,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", - } - FAILING_GRADES2 = {"F", "I", "X", "AU", "CD"} # for Backlog/Improvement remark only - 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 - # F earns credit (grade point 2); only X/I/AU/CD do not. - credit2 = Decimal(str(c2.credit)) if c2.credit is not None else Decimal('0') - if grade_conversion.get(grade2.strip(), -1) >= 0: - sem_creds2 += credit2 - courses2.append({ - "code": c2.code or "", - "name": c2.name or "", - "credits": float(credit2), - "grade": grade2, - "remark": rem2, - }) - - if courses2: - try: - sp2, _, _ = calculate_spi_for_student(stu, s_no2, s_type2) - # 2nd return is the deduped total credits (tu) up to - # this semester; use it so the cumulative is count-once - # and reconciles with the transcript total. - cp2, cum2, _ = calculate_cpi_for_student(stu, s_no2, s_type2) - running_creds2 = Decimal(str(cum2)) - 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/globals/api/serializers.py b/FusionIIIT/applications/globals/api/serializers.py index d90e0d921..a3fc90763 100644 --- a/FusionIIIT/applications/globals/api/serializers.py +++ b/FusionIIIT/applications/globals/api/serializers.py @@ -5,7 +5,7 @@ from notifications.models import Notification from applications.globals.models import (ExtraInfo, HoldsDesignation, DepartmentInfo, - Designation) + Designation, Announcement) from applications.placement_cell.api.serializers import (SkillSerializer, HasSerializer, EducationSerializer, CourseSerializer, ExperienceSerializer, @@ -69,3 +69,31 @@ class HoldsDesignationSerializer(serializers.ModelSerializer): class Meta: model = HoldsDesignation fields = ('user','designation','held_at') + +class AnnouncementSerializer(serializers.ModelSerializer): + + class Meta: + model = Announcement + fields = ('id', 'title', 'message', 'audience_type', 'target_role', + 'target_department', 'target_batch', 'target_users', 'created_at') + extra_kwargs = { + 'target_role': {'required': False, 'allow_null': True}, + 'target_department': {'required': False, 'allow_null': True}, + 'target_batch': {'required': False, 'allow_null': True}, + 'target_users': {'required': False}, + } + + def validate(self, attrs): + audience_type = attrs.get('audience_type') + required_field = { + 'role': 'target_role', + 'department': 'target_department', + 'batch': 'target_batch', + }.get(audience_type) + if required_field and not attrs.get(required_field): + raise serializers.ValidationError( + {required_field: f"This field is required when audience_type is '{audience_type}'."}) + if audience_type == 'individual' and not attrs.get('target_users'): + raise serializers.ValidationError( + {'target_users': "This field is required when audience_type is 'individual'."}) + return attrs diff --git a/FusionIIIT/applications/globals/api/urls.py b/FusionIIIT/applications/globals/api/urls.py index 2ef43ac2a..bb869dffe 100644 --- a/FusionIIIT/applications/globals/api/urls.py +++ b/FusionIIIT/applications/globals/api/urls.py @@ -24,11 +24,19 @@ re_path(r'^profile_delete/(?P[0-9]+)/', views.profile_delete, name='delete-profile-api'), # Notification endpoints + # unread-count must be listed before the bare 'notification/' pattern below, + # since that pattern has no end anchor and would otherwise swallow this route too. + re_path(r'^notification/unread-count/', views.unread_notification_count, name='notification-unread-count'), re_path(r'^notification/', views.notification, name='notification'), re_path(r'^notificationread', views.NotificationRead, name='notifications-read'), re_path(r'^notificationdelete', views.delete_notification, name='notifications-delete'), re_path(r'^notificationunread', views.NotificationUnread, name='notifications-unread'), + # Announcement endpoints + re_path(r'^announcements/create/', views.create_announcement, name='create-announcement'), + re_path(r'^announcements/audience-options/', views.announcement_audience_options, name='announcement-audience-options'), + re_path(r'^announcements/search-users/', views.search_users, name='announcement-search-users'), + # Course management proxy re_path(r'^admin_delete_course/(?P\d+)/', views.admin_delete_course_proxy, name='admin_delete_course_proxy') ] \ No newline at end of file diff --git a/FusionIIIT/applications/globals/api/views.py b/FusionIIIT/applications/globals/api/views.py index 7a38063cf..5198cf936 100644 --- a/FusionIIIT/applications/globals/api/views.py +++ b/FusionIIIT/applications/globals/api/views.py @@ -8,6 +8,7 @@ ) from django.shortcuts import get_object_or_404 from django.db import transaction +from django.db.models import Q import hashlib import hmac @@ -29,9 +30,12 @@ from . import serializers from applications.globals.models import (ExtraInfo, HoldsDesignation, ModuleAccess, - Designation, PasswordResetOTP) + Designation, DepartmentInfo, PasswordResetOTP, + Announcement) from .utils import get_and_authenticate_user from notifications.models import Notification +from notifications.signals import notify +from applications.globals.decorators import role_required _security_log = logging.getLogger("fusion.security") User = get_user_model() @@ -113,18 +117,60 @@ def auth_view(request): return Response(data=resp,status=status.HTTP_200_OK) +def _notification_role_matches(notification, active_role): + """ + A notification is visible unless its data payload carries a 'role' tag + that doesn't match the viewer's currently active role. Untagged + notifications (the vast majority) are always visible. This must be a + plain Python check, not a queryset filter: Notification.data is a + jsonfield.fields.JSONField (not django.db.models.JSONField), and + data__role=... style ORM lookups are unreliable on this package version. + """ + data = notification.data + if not isinstance(data, dict): + return True + tagged_role = data.get('role') + if not tagged_role: + return True + if not active_role: + return False + return tagged_role.lower() == active_role.lower() + + +def _visible_notifications(user, queryset=None): + """ + Filters `queryset` (default: all of `user`'s notifications) down to the + ones visible under the role-tag rule. Callers that only need a subset + (e.g. unread ones) should pass a pre-filtered queryset so the cheap, + DB-level boolean filters run before this Python-side role check. + """ + active_role = getattr(getattr(user, 'extrainfo', None), 'last_selected_role', None) + qs = user.notifications.all() if queryset is None else queryset + return [ + n for n in qs + if _notification_role_matches(n, active_role) + ] + @api_view(['GET']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def notification(request): - notifications=serializers.NotificationSerializer(request.user.notifications.all(),many=True).data + notifications=serializers.NotificationSerializer(_visible_notifications(request.user),many=True).data resp={ - 'notifications':notifications, + 'notifications':notifications, } return Response(data=resp,status=status.HTTP_200_OK) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def unread_notification_count(request): + unread_qs = request.user.notifications.filter(unread=True, deleted=False) + count = len(_visible_notifications(request.user, unread_qs)) + return Response(data={'count': count}, status=status.HTTP_200_OK) + @api_view(['PATCH']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) @@ -150,7 +196,22 @@ def profile(request, username=None): if profile['user_type'] == 'student': student = user.extrainfo.student - std_sem = student.curr_semester_no + std_sem = Student.objects.select_related('batch_id__curriculum__programme').get(id=student.id).curr_semester_no + + # Get programme_type from batch -> curriculum -> programme -> category + programme_type = None + try: + student_obj = Student.objects.select_related( + 'batch_id__curriculum__programme' + ).get(id=student.id) + + if (student_obj.batch_id and + student_obj.batch_id.curriculum and + student_obj.batch_id.curriculum.programme): + programme_type = student_obj.batch_id.curriculum.programme.category + except (Student.DoesNotExist, AttributeError): + programme_type = None + skills = list( Has.objects.filter(unique_id_id=student) .select_related("skill_id") @@ -171,6 +232,7 @@ def profile(request, username=None): resp = { 'profile' : profile, 'semester_no' : std_sem, + 'programme_type' : programme_type, 'skills' : formatted_skills, 'education' : education, 'course' : course, @@ -383,6 +445,88 @@ def delete_notification(request): ) +def resolve_audience_recipients(obj): + """ + Resolves recipient Users for anything carrying the shared audience_type/ + target_role/target_department/target_batch/target_users fields (currently + Announcement and Calendar). + """ + if obj.audience_type == 'all': + return User.objects.all() + if obj.audience_type == 'role': + return User.objects.filter( + current_designation__designation=obj.target_role).distinct() + if obj.audience_type == 'department': + return User.objects.filter(extrainfo__department=obj.target_department) + if obj.audience_type == 'batch': + return User.objects.filter(extrainfo__student__batch_id=obj.target_batch) + if obj.audience_type == 'individual': + return obj.target_users.all() + return User.objects.none() + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +@role_required(['acadadmin']) +def create_announcement(request): + serializer = serializers.AnnouncementSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + announcement = serializer.save(created_by=request.user.extrainfo) + + recipients = resolve_audience_recipients(announcement) + notify.send( + sender=request.user, + recipient=recipients, + verb=announcement.title, + description=announcement.message, + url='', + module='Announcement', + flag='announcement', + role=announcement.target_role.name if announcement.audience_type == 'role' else None, + ) + + return Response(serializers.AnnouncementSerializer(announcement).data, status=status.HTTP_201_CREATED) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +@role_required(['acadadmin']) +def announcement_audience_options(request): + roles = serializers.DesignationSerializer(Designation.objects.all(), many=True).data + departments = serializers.DepartmentInfoSerializer(DepartmentInfo.objects.all(), many=True).data + return Response({'roles': roles, 'departments': departments}, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +@role_required(['acadadmin']) +def search_users(request): + query = request.query_params.get('q', '').strip() + if not query: + return Response([], status=status.HTTP_200_OK) + + users = User.objects.filter( + Q(username__icontains=query) + | Q(first_name__icontains=query) + | Q(last_name__icontains=query) + | Q(extrainfo__id__icontains=query) + ).distinct()[:20] + + results = [ + { + 'id': user.id, + 'label': f"{user.first_name} {user.last_name} ({user.username})".strip(), + } + for user in users + ] + return Response(results, status=status.HTTP_200_OK) + + @api_view(['DELETE']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) diff --git a/FusionIIIT/applications/globals/decorators.py b/FusionIIIT/applications/globals/decorators.py new file mode 100644 index 000000000..53e123fae --- /dev/null +++ b/FusionIIIT/applications/globals/decorators.py @@ -0,0 +1,39 @@ +from functools import wraps + +from rest_framework import status +from rest_framework.response import Response + +from applications.globals.models import HoldsDesignation + + +def role_required(allowed_roles): + """ + Decorator factory that accepts a list of allowed role names. + Accepts multiple HoldsDesignation records per user. + """ + allowed_lower = {role.lower() for role in allowed_roles} + + def decorator(view_func): + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + # Fetch all designations for this user + user_roles = ( + HoldsDesignation.objects + .select_related('designation') + .filter(user=request.user) + .values_list('designation__name', flat=True) # or whichever field holds the string + ) + + # Normalize to lowercase for comparison + user_roles_lower = {r.lower() for r in user_roles} + + # Check intersection + if not (user_roles_lower & allowed_lower): + return Response( + {"error": "Permission denied: one of %s required" % allowed_roles}, + status=status.HTTP_403_FORBIDDEN + ) + + return view_func(request, *args, **kwargs) + return _wrapped_view + return decorator diff --git a/FusionIIIT/applications/globals/migrations/0007_moduleaccess_thesis_research.py b/FusionIIIT/applications/globals/migrations/0007_moduleaccess_thesis_research.py new file mode 100644 index 000000000..486684ec9 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0007_moduleaccess_thesis_research.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-07-07 19:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0006_auto_20260304_0836'), + ] + + operations = [ + migrations.AddField( + model_name='moduleaccess', + name='thesis_research', + field=models.BooleanField(default=False), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0008_announcement.py b/FusionIIIT/applications/globals/migrations/0008_announcement.py new file mode 100644 index 000000000..0168bda33 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0008_announcement.py @@ -0,0 +1,32 @@ +# Generated by Django 3.1.5 on 2026-07-31 12:27 + +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), + ('globals', '0007_moduleaccess_thesis_research'), + ] + + operations = [ + migrations.CreateModel( + name='Announcement', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('message', models.TextField()), + ('audience_type', models.CharField(choices=[('all', 'Everyone'), ('role', 'Specific Role'), ('batch', 'Specific Batch'), ('department', 'Specific Department'), ('individual', 'Specific Individuals')], max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='announcements_created', to='globals.extrainfo')), + ('target_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='programme_curriculum.batch')), + ('target_department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='globals.departmentinfo')), + ('target_role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='globals.designation')), + ('target_users', models.ManyToManyField(blank=True, related_name='targeted_announcements', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0009_announcement_created_by_set_null.py b/FusionIIIT/applications/globals/migrations/0009_announcement_created_by_set_null.py new file mode 100644 index 000000000..37f45d4c3 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0009_announcement_created_by_set_null.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.5 on 2026-08-04 20:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0008_announcement'), + ] + + operations = [ + migrations.AlterField( + model_name='announcement', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='announcements_created', to='globals.extrainfo'), + ), + ] diff --git a/FusionIIIT/applications/globals/models.py b/FusionIIIT/applications/globals/models.py index 618b917bc..607573170 100644 --- a/FusionIIIT/applications/globals/models.py +++ b/FusionIIIT/applications/globals/models.py @@ -319,6 +319,7 @@ class ModuleAccess(models.Model): designation = models.CharField(max_length=155) program_and_curriculum = models.BooleanField(default=False) course_registration = models.BooleanField(default=False) + thesis_research = models.BooleanField(default=False) course_management = models.BooleanField(default=False) other_academics = models.BooleanField(default=False) spacs = models.BooleanField(default=False) @@ -385,3 +386,37 @@ class Meta: def __str__(self): return f"OTP record for {self.username}" + + +class Announcement(models.Model): + """ + Current Purpose : An acadadmin-composed announcement targeted at a specific + audience. On creation, recipients are resolved and fanned out via the + existing django-notifications-hq notify.send(..., flag="announcement") + pipeline, so delivery/read-state reuses the Notification model as-is. + """ + AUDIENCE_CHOICES = ( + ('all', 'Everyone'), + ('role', 'Specific Role'), + ('batch', 'Specific Batch'), + ('department', 'Specific Department'), + ('individual', 'Specific Individuals'), + ) + + created_by = models.ForeignKey( + ExtraInfo, null=True, blank=True, on_delete=models.SET_NULL, related_name='announcements_created') + title = models.CharField(max_length=200) + message = models.TextField() + audience_type = models.CharField(max_length=20, choices=AUDIENCE_CHOICES) + target_role = models.ForeignKey( + Designation, null=True, blank=True, on_delete=models.SET_NULL) + target_department = models.ForeignKey( + DepartmentInfo, null=True, blank=True, on_delete=models.SET_NULL) + target_batch = models.ForeignKey( + 'programme_curriculum.Batch', null=True, blank=True, on_delete=models.SET_NULL) + target_users = models.ManyToManyField( + User, blank=True, related_name='targeted_announcements') + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.title diff --git a/FusionIIIT/applications/health_center/utils.py b/FusionIIIT/applications/health_center/utils.py index 220efea76..2f60fbdca 100644 --- a/FusionIIIT/applications/health_center/utils.py +++ b/FusionIIIT/applications/health_center/utils.py @@ -844,7 +844,7 @@ def compounder_view_handler(request): medical_relief_instance = medical_relief.objects.get(file_id=request.POST['file_id']) medical_relief_instance.compounder_forward_flag = True medical_relief_instance.save() - healthcare_center_notif(request.user,user.user,'rel_approve','') + healthcare_center_notif(request.user,user.user,'rel_approve','',role=acc_admin_des_id.name) data = {'status': 1} return JsonResponse(data) elif 'comp_announce' in request.POST: diff --git a/FusionIIIT/applications/leave/handlers.py b/FusionIIIT/applications/leave/handlers.py index 03488a35d..0748959f1 100644 --- a/FusionIIIT/applications/leave/handlers.py +++ b/FusionIIIT/applications/leave/handlers.py @@ -425,8 +425,9 @@ def authority_processing(request, leave_request): leave_request.status = 'forwarded' leave_request.save() leave_module_notif(request.user, leave_request.leave.applicant, 'leave_forwarded') - officer = leave.applicant.leave_admins.officer.designees.first().user - leave_module_notif(leave_request.leave.applicant, officer, 'leave_request') + officer_designation = leave.applicant.leave_admins.officer + officer = officer_designation.designees.first().user + leave_module_notif(leave_request.leave.applicant, officer, 'leave_request', role=officer_designation.name) LeaveRequest.objects.create( leave=leave, requested_from=officer, @@ -500,8 +501,9 @@ def process_staff_faculty_application(request): leave=rep_request.leave, permission='intermediary' )""" - authority = rep_request.leave.applicant.leave_admins.authority.designees.first().user - leave_module_notif(rep_request.leave.applicant, authority, 'leave_request') + authority_designation = rep_request.leave.applicant.leave_admins.authority + authority = authority_designation.designees.first().user + leave_module_notif(rep_request.leave.applicant, authority, 'leave_request', role=authority_designation.name) LeaveRequest.objects.create( leave=rep_request.leave, requested_from=authority, diff --git a/FusionIIIT/applications/notifications_extension/api/urls.py b/FusionIIIT/applications/notifications_extension/api/urls.py index 62238c007..5df7a5e76 100644 --- a/FusionIIIT/applications/notifications_extension/api/urls.py +++ b/FusionIIIT/applications/notifications_extension/api/urls.py @@ -1,4 +1,8 @@ # urls.py +# +# NOTE (as of 2026-07-31): none of these routes are called by the Fusion React +# frontend - see `applications/notifications_extension/apps.py` for details. The +# frontend's Notifications tab uses `applications/globals/api/urls.py` instead. from django.urls import path from .views import ( LeaveModuleNotificationAPIView, diff --git a/FusionIIIT/applications/notifications_extension/api/views.py b/FusionIIIT/applications/notifications_extension/api/views.py index c5017f79f..ba5404629 100644 --- a/FusionIIIT/applications/notifications_extension/api/views.py +++ b/FusionIIIT/applications/notifications_extension/api/views.py @@ -1,4 +1,15 @@ # views.py +# +# NOTE (as of 2026-07-31): this file's views are not called by the Fusion React +# frontend - see `applications/notifications_extension/apps.py` for details. +# `NotificationsList`/`Delete`/`MarkAsRead` duplicate functionality already live at +# `applications/globals/api/views.py` (`notification`/`delete_notification`/ +# `NotificationRead`/`NotificationUnread`), which is what the frontend actually uses - +# note that implementation differs in ways that matter (soft- vs hard-delete, response +# shape, and this file has no mark-as-unread endpoint). The per-module notification- +# sending views below (e.g. `LeaveModuleNotificationAPIView`) are also unreferenced by +# the frontend; those modules send notifications by calling `notification/views.py` +# helper functions directly instead of through this REST layer. from rest_framework.views import APIView from django.contrib.auth import get_user_model from rest_framework.response import Response diff --git a/FusionIIIT/applications/notifications_extension/apps.py b/FusionIIIT/applications/notifications_extension/apps.py index 118c0dabf..462696bb5 100644 --- a/FusionIIIT/applications/notifications_extension/apps.py +++ b/FusionIIIT/applications/notifications_extension/apps.py @@ -1,3 +1,14 @@ +# NOTE (as of 2026-07-31): This app's REST API (`/notifications/api/...`) is not used +# by the Fusion React frontend. The frontend's Notifications tab instead calls the +# equivalent endpoints in `applications/globals/api/` (`/api/notification/`, +# `/api/notificationread`, `/api/notificationdelete`, `/api/notificationunread`). +# This app appears to be an earlier/parallel implementation that was superseded but +# never removed. Left in place rather than deleted - verify it is still unused before +# building on or relying on it. See `applications/notifications_extension/api/views.py` +# and `api/urls.py` for the duplicated notification list/read/delete endpoints, plus a +# set of per-module notification-sending wrapper endpoints that are also unreferenced +# by the frontend (modules send notifications by calling `notification/views.py` +# helper functions directly instead). from django.apps import AppConfig diff --git a/FusionIIIT/applications/notifications_extension/urls.py b/FusionIIIT/applications/notifications_extension/urls.py index b5690994b..20e98486e 100644 --- a/FusionIIIT/applications/notifications_extension/urls.py +++ b/FusionIIIT/applications/notifications_extension/urls.py @@ -1,3 +1,7 @@ +# NOTE (as of 2026-07-31): nothing under this app's `/notifications/...` prefix is +# called by the Fusion React frontend - see `applications/notifications_extension/apps.py` +# for details. The frontend's Notifications tab uses `applications/globals/api/urls.py` +# (mounted at `/api/...`) instead. from notifications.urls import urlpatterns from applications.notifications_extension.views import mark_as_read_and_redirect from django.conf.urls import url as pattern diff --git a/FusionIIIT/applications/office_module/views.py b/FusionIIIT/applications/office_module/views.py index 27e6c9bbf..25e90f989 100644 --- a/FusionIIIT/applications/office_module/views.py +++ b/FusionIIIT/applications/office_module/views.py @@ -159,7 +159,7 @@ def officeOfDeanPnD(request): # Send notifications to all concerned users office_dean_PnD_notif(request.user, requisition.userid.user, 'request_accepted') office_dean_PnD_notif(request.user, request.user, 'assignment_created') - office_dean_PnD_notif(request.user, receive.working, 'assignment_received') + office_dean_PnD_notif(request.user, receive.working, 'assignment_received', role=receive.designation.name) # Create tracking row to send the file to Assistant Engg. Tracking.objects.create( @@ -380,7 +380,7 @@ def action(request): print(vars(track)) track.is_read = True track.save() - office_dean_PnD_notif(request.user,sent_hold_design.working, 'assignment_received') + office_dean_PnD_notif(request.user,sent_hold_design.working, 'assignment_received', role=sent_design.name) """ elif 'Revert' in request.POST: Tracking.objects.create( diff --git a/FusionIIIT/applications/office_module/views_office_students.py b/FusionIIIT/applications/office_module/views_office_students.py index e42e5f62f..df5fc3f10 100755 --- a/FusionIIIT/applications/office_module/views_office_students.py +++ b/FusionIIIT/applications/office_module/views_office_students.py @@ -162,7 +162,7 @@ def holdingMeeting(request): success_msg="Meeting created successfully. Waiting for Suprintendent for the MOM" Dean = HoldsDesignation.objects.get(designation=Designation.objects.filter(name='Dean_s')).working Superintendent = HoldsDesignation.objects.filter(designation__name='Junior Superintendent').first() - office_module_DeanS_notif(request.user, Dean, 'meeting_booked') + office_module_DeanS_notif(request.user, Dean, 'meeting_booked', role='Dean_s') office_module_DeanS_notif(request.user, Superintendent, 'meeting_booked') return render(request, 'officeModule/officeOfDeanStudents/officeOfDeanStudents.html', getUniversalContext(request, page=1, success_msg=success_msg, err_msg=err_msg, flag_dean_s=True)) @@ -190,7 +190,7 @@ def meetingMinutes(request): success_msg="MOM uploaded successfully" Dean = HoldsDesignation.objects.get(designation=Designation.objects.filter(name='Dean_s')).working Superintendent = HoldsDesignation.objects.filter(designation__name='Junior Superintendent').first() - office_module_DeanS_notif(request.user, Dean, 'MOM_submitted') + office_module_DeanS_notif(request.user, Dean, 'MOM_submitted', role='Dean_s') office_module_DeanS_notif(request.user, Superintendent, 'MOM_submitted') # office_module_DeanS_notif(request.user, 'gymkhana', 'meeting_booked') return render(request, 'officeModule/officeOfDeanStudents/officeOfDeanStudents.html', getUniversalContext(request,page=6, success_msg=success_msg, err_msg=err_msg, flag_superintendent=True)) @@ -421,7 +421,7 @@ def budgetApproval(request): office_module_DeanS_notif(request.user, request.user, 'budget_approved') Dean = HoldsDesignation.objects.get(designation=Designation.objects.filter(name='Dean_s')).working Superintendent = HoldsDesignation.objects.filter(designation__name='Junior Superintendent').first() - office_module_DeanS_notif(request.user, Dean, 'budget_approved') + office_module_DeanS_notif(request.user, Dean, 'budget_approved', role='Dean_s') office_module_DeanS_notif(request.user, Superintendent, 'budget_approved') # office_module_DeanS_notif(request.user, Co, 'budget_approved') return render(request, 'officeModule/officeOfDeanStudents/officeOfDeanStudents.html', getUniversalContext(request, page=2, success_msg=success_msg,err_msg=err_msg, flag_dean_s=True)) @@ -473,7 +473,7 @@ def clubApproval(request): success_msg = "Club Approved successfully" office_module_DeanS_notif(request.user, request.user, 'club_approved') Dean = HoldsDesignation.objects.get(designation=Designation.objects.filter(name='Dean_s')).working - office_module_DeanS_notif(request.user, Dean, 'club_approved') + office_module_DeanS_notif(request.user, Dean, 'club_approved', role='Dean_s') # office_module_DeanS_notif(request.user, 'gymkhana', 'club_approved') return render(request, 'officeModule/officeOfDeanStudents/officeOfDeanStudents.html', getUniversalContext(request, page=5,success_msg=success_msg, flag_dean_s=True)) diff --git a/FusionIIIT/applications/placement_cell/README.md b/FusionIIIT/applications/placement_cell/README.md new file mode 100644 index 000000000..28b655445 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/README.md @@ -0,0 +1,166 @@ +# Placement Cell + +The Placement Cell module manages the full campus placement lifecycle for +IIITDM Jabalpur — company drives, student applications and offers, interview +rounds, statistics and reports, debarments and restrictions, an interactive +placement calendar, an alumni network, and off-campus / published-CPI tracking. + +It is an API-only Django app (`applications/placement_cell`) consumed by the +React placement module in `Fusion-client` (`src/Modules/PlacementCell`). + +--- + +## Roles + +The module recognises four roles (the user's selected designation, exposed to +the frontend as `state.user.role`): + +| Role (designation) | Capabilities | +|----------------------|--------------| +| `placement officer` | TPO: schedules/drives, applications, interview rounds, statistics, reports, debarments, restrictions, fields, notifications, off-campus records, published-CPI export, calendar management, announcements | +| `placement chairman` | Admin oversight: officer capabilities plus placement policies | +| `student` | Browse drives, apply, manage placement profile, view offers/timeline, download CV, read announcements/calendar, alumni network | +| `alumni` | Alumni profile, post job referrals, mentorship sessions, student network | + +**How roles resolve** + +- **Backend** authorizes via `HoldsDesignation(working=user, designation__name=…)`. + `selectors.is_tpo` is true for `placement officer` / `placement chairman`; + officer/chairman-only endpoints return `403` with `{ "detail": … }`. +- **Sidebar visibility** comes from `ModuleAccess.placement_cell` for the + designation. +- **Frontend** chooses the tab set in `PlacementCellPage` from `state.user.role`. + +--- + +## Features + +### Students +- **Placement Schedule** — browse drives as a chronological **Agenda** + (Today / This Week / Upcoming / Closed) or a filterable **card** view, with + per-drive eligibility and deadline countdowns; apply, withdraw, track status. +- **My Applications / My Offers** — application timeline; accept/decline offers. +- **Placement Calendar** — read-only, colour-coded view of drives, tests, + interviews and deadlines. +- **Announcements** — read placement-cell announcements. +- **Download CV**, placement profile, notification preferences. +- **Alumni Network** — connect with alumni, browse referrals, mentorship. + +### Placement Officer / Chairman +- **Add / edit drives** with eligibility (min CPI, branches from the live + department list, passout year, gender) and custom **application fields** + (creatable inline). +- **Student CPI** — per-batch published CPI (computed from the examination + module's announced results), with off-campus companies and **Excel export**. +- **Off-Campus Placements** — record offers students received off campus + (company autocomplete from registered companies). +- **Placement Statistics & Reports** — stats, report generation and export. +- **Debarred Students** and **Restrictions** (institute-wide eligibility bars). +- **Send Notifications**, **Company Registration**, **Fields** management. +- **Placement Calendar** — Google-Calendar-style: click a date/slot to add an + event, edit/delete events; merged with drives and deadlines. +- **Announcements** — post / pin / delete. +- **Placement Appeals**, **Higher Studies**, **Alumni Verification**. + +### Chairman +- All officer capabilities plus **Placement Policies**. + +--- + +## Backend + +- **Models** (`models.py`, ~45): profiles & academic info (Education, Skill, + Experience, …), `NotifyStudent`, `PlacementSchedule`, `PlacementApplication`, + `PlacementStatus`, `PlacementRound`, `PlacementRecord`, `StudentRecord`, + `PlacementRestriction`, `PlacementPolicy`, `PlacementAppeal`, alumni models, + and the additive `PlacementAnnouncement`, `OffCampusPlacement`, + `PlacementCalendarEvent`. +- **API** (`api/urls.py`, `api/views.py`, `api/serializers.py`): DRF, Token + authentication. URLs are mounted under `placement/` (see `Fusion/urls.py`), + so routes are `…/placement/api//`. + +### Endpoint groups + +| Area | Routes | +|------|--------| +| Drives & schedule | `api/placement/`, `api/placement//`, `api/calender/`, `api/timeline//`, `api/nextround//` | +| Applications & offers | `api/apply-for-placement/`, `api/my-applications/`, `api/my-offers/`, `api/offer//`, `api/offer//respond/`, `api/student-applications//`, `api/application-detail//`, `api/download-applications//` | +| Statistics & reports | `api/statistics/`, `api/delete-statistics//`, `api/reports/`, `api/reports/export/`, `api/report-schedules/`, `api/higher-studies/` | +| Eligibility & policy | `api/restrictions/`, `api/policies/`, `api/branches/` | +| Debarment | `api/debared-students/`, `api/debared-status//` | +| Fields & profile | `api/add-field/`, `api/form-fields/`, `api/profile/`, `api/notification-preferences/`, `api/registration/`, `api/generate-cv/` | +| Notifications | `api/send-notification/` | +| Announcements | `api/announcements/`, `api/announcements//` | +| Off-campus | `api/offcampus/`, `api/offcampus//` | +| Published CPI | `api/cpi-batches/`, `api/cpi-students/` (`?batch_id=` , `?export=excel`) | +| Calendar events | `api/calendar-events/`, `api/calendar-events//` | +| Appeals | `api/placement-appeals/`, `api/placement-appeals//` | +| Alumni | `api/alumni/profile/`, `api/alumni/directory/`, `api/alumni/verification/`, `api/alumni/referrals/`, `api/alumni/connections/`, `api/alumni/sessions/` | + +**Authorization** — all endpoints require authentication; write/sensitive +operations are gated on `selectors.is_tpo` (officer/chairman). Server-controlled +fields (`created_by`, `added_by`, `posted_by`) are never client-settable. + +### Published CPI + +`selectors.get_student_published_cpi` derives a student's CPI from the +examination module's latest **announced** `ResultAnnouncement` (not the static +`Student.cpi`). The per-batch view memoises each student's computed CPI in the +cache (keyed by roll number + semester), so reloading a batch is near-instant. + +--- + +## Frontend (`Fusion-client/src/Modules/PlacementCell`) + +- React 18 + Vite, Mantine v7, Redux Toolkit, axios, `mantine-react-table`, + `react-big-calendar`. +- `pages/PlacementCellPage.jsx` renders the shared `ModuleTabs` navbar and the + role-specific tab set. **Every tab is lazy-loaded** behind a `Suspense` + boundary so opening the module only downloads the active tab's code; Vite + `manualChunks` splits the heavy vendors into cacheable chunks. +- `api.js` (+ `services/api.js` re-export) holds `placementApi` with one method + per endpoint and `buildAuthConfig()` for the token header. +- Date inputs use native `datetime-local` / `date` controls for reliability. + +--- + +## Setup — role accounts + +`manage.py setup_placement_roles` creates one idempotent login per role and +enables `ModuleAccess.placement_cell`. The password is supplied at runtime and +is **not** stored in the repo: + +```bash +cd FusionIIIT +python manage.py setup_placement_roles --password '' +# or set PLACEMENT_ROLE_PASSWORD +``` + +| Username | Role | +|----------------------|--------------------| +| `placement_officer` | placement officer | +| `placement_chairman` | placement chairman | +| `placement_student` | student | +| `placement_alumni` | alumni | + +--- + +## Tests + +See [`tests/README.md`](tests/README.md). Run with the dedicated settings +(migrations disabled so the historical chain cannot block test-DB creation): + +```bash +cd FusionIIIT +python manage.py test \ + applications.placement_cell.tests.test_placement_api \ + applications.placement_cell.tests.test_use_cases \ + applications.placement_cell.tests.test_business_rules \ + applications.placement_cell.tests.test_workflows \ + applications.placement_cell.tests.test_module \ + --settings=test_settings +``` + +`test_placement_api` covers schema regressions, API-only URL wiring, +authentication and role authorization (including announcements, off-campus, +published-CPI export and calendar-event CRUD). diff --git a/FusionIIIT/applications/placement_cell/__init__.py b/FusionIIIT/applications/placement_cell/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/__init__.py @@ -0,0 +1 @@ + diff --git a/FusionIIIT/applications/placement_cell/admin.py b/FusionIIIT/applications/placement_cell/admin.py index e333e2a68..0af314e13 100644 --- a/FusionIIIT/applications/placement_cell/admin.py +++ b/FusionIIIT/applications/placement_cell/admin.py @@ -82,7 +82,7 @@ class StudentRecordAdmin(admin.ModelAdmin): class ChairmanVisitAdmin(admin.ModelAdmin): - list_display = ('company_name', 'location', 'visiting_date', 'timestamp') + list_display = ('company_name', 'location', 'visiting_date', 'start_date', 'end_date', 'timestamp') class PlacementScheduleAdmin(admin.ModelAdmin): diff --git a/FusionIIIT/applications/placement_cell/api/__init__.py b/FusionIIIT/applications/placement_cell/api/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/api/__init__.py @@ -0,0 +1 @@ + diff --git a/FusionIIIT/applications/placement_cell/api/serializers.py b/FusionIIIT/applications/placement_cell/api/serializers.py index e6960adb0..13ccc8cd9 100644 --- a/FusionIIIT/applications/placement_cell/api/serializers.py +++ b/FusionIIIT/applications/placement_cell/api/serializers.py @@ -4,7 +4,16 @@ from applications.placement_cell.models import (Achievement, Course, Education, Experience, Has, Patent, Project, Publication, Skill, - PlacementStatus, NotifyStudent) + PlacementAppeal, PlacementStatus, + NotifyStudent, PlacementAnnouncement, + OffCampusPlacement, + PlacementCalendarEvent) + + +class PlacementAppealSerializer(serializers.ModelSerializer): + class Meta: + model = PlacementAppeal + fields = '__all__' class SkillSerializer(serializers.ModelSerializer): @@ -17,7 +26,7 @@ class HasSerializer(serializers.ModelSerializer): class Meta: model = Has - fields = ('skill_id','skill_rating') + fields = ('id', 'skill_id', 'skill_rating') def create(self, validated_data): skill = validated_data.pop('skill_id') @@ -28,6 +37,22 @@ def create(self, validated_data): raise serializers.ValidationError({'skill': 'This skill is already present'}) return has_obj + def update(self, instance, validated_data): + skill = validated_data.pop('skill_id', None) + if skill: + skill_id, _ = Skill.objects.get_or_create(**skill) + duplicate = Has.objects.filter( + unique_id=instance.unique_id, + skill_id=skill_id, + ).exclude(pk=instance.pk).exists() + if duplicate: + raise serializers.ValidationError({'skill': 'This skill is already present'}) + instance.skill_id = skill_id + if 'skill_rating' in validated_data: + instance.skill_rating = validated_data['skill_rating'] + instance.save() + return instance + class EducationSerializer(serializers.ModelSerializer): class Meta: @@ -82,3 +107,59 @@ class PlacementStatusSerializer(serializers.ModelSerializer): class Meta: model = PlacementStatus fields = ('notify_id', 'invitation', 'placed', 'timestamp', 'no_of_days') + + +class PlacementAnnouncementSerializer(serializers.ModelSerializer): + posted_by_name = serializers.SerializerMethodField() + + class Meta: + model = PlacementAnnouncement + fields = ('id', 'title', 'body', 'posted_by_name', 'posted_at', 'is_pinned') + read_only_fields = ('id', 'posted_at') + + def get_posted_by_name(self, obj): + if obj.posted_by: + full_name = '{} {}'.format( + obj.posted_by.first_name, obj.posted_by.last_name + ).strip() + return full_name or obj.posted_by.username + return None + + +class PlacementAnnouncementWriteSerializer(serializers.ModelSerializer): + + class Meta: + model = PlacementAnnouncement + fields = ('title', 'body', 'is_pinned') + + +class OffCampusPlacementSerializer(serializers.ModelSerializer): + roll_no = serializers.CharField(source='student.user.username', read_only=True) + student_name = serializers.SerializerMethodField() + + class Meta: + model = OffCampusPlacement + fields = ('id', 'roll_no', 'student_name', 'company_name', 'role', + 'offer_type', 'ctc', 'stipend', 'offer_date', 'notes', 'created_at') + read_only_fields = ('id', 'created_at') + + def get_student_name(self, obj): + user = obj.student.user + return '{} {}'.format(user.first_name, user.last_name).strip() + + +class OffCampusPlacementWriteSerializer(serializers.ModelSerializer): + + class Meta: + model = OffCampusPlacement + fields = ('student', 'company_name', 'role', 'offer_type', + 'ctc', 'stipend', 'offer_date', 'notes') + + +class PlacementCalendarEventSerializer(serializers.ModelSerializer): + + class Meta: + model = PlacementCalendarEvent + fields = ('id', 'title', 'description', 'start', 'end', 'all_day', + 'category', 'location', 'created_at') + read_only_fields = ('id', 'created_at') diff --git a/FusionIIIT/applications/placement_cell/api/urls.py b/FusionIIIT/applications/placement_cell/api/urls.py new file mode 100644 index 000000000..482463585 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/api/urls.py @@ -0,0 +1,72 @@ +from django.conf.urls import url + +from . import views + +app_name = "placement" + +urlpatterns = [ + # Back-compat alias: legacy notifications/templates reverse 'placement:placement'. + url(r"^$", views.placement_api, name="placement"), + url(r"^api/placement/$", views.placement_api, name="placement_api"), + url(r"^api/placement/(?P[0-9]+)/$", views.placement_detail_api, name="placement_detail_api"), + url(r"^api/statistics/$", views.placement_statistics_api, name="placement_statistics_api"), + url(r"^api/reports/$", views.placement_reports_api, name="placement_reports_api"), + url(r"^api/reports/export/$", views.placement_reports_export_api, name="placement_reports_export_api"), + url(r"^api/report-schedules/$", views.placement_report_schedules_api, name="placement_report_schedules_api"), + url(r"^api/report-schedules/(?P[0-9]+)/$", views.placement_report_schedule_detail_api, name="placement_report_schedule_detail_api"), + url(r"^api/delete-statistics/(?P[0-9]+)/$", views.delete_placement_statistics_api, name="delete_placement_statistics_api"), + url(r"^api/higher-studies/$", views.higher_studies_api, name="higher_studies_api"), + url(r"^api/higher-studies/(?P[0-9]+)/$", views.higher_studies_detail_api, name="higher_studies_detail_api"), + url(r"^api/registration/$", views.registration_api, name="registration_api"), + url(r"^api/add-field/$", views.placement_fields_api, name="placement_fields_api"), + url(r"^api/form-fields/$", views.form_fields_api, name="form_fields_api"), + url(r"^api/profile/$", views.placement_profile_api, name="placement_profile_api"), + url(r"^api/notification-preferences/$", views.notification_preferences_api, name="notification_preferences_api"), + url(r"^api/apply-for-placement/$", views.apply_for_placement_api, name="apply_for_placement_api"), + url(r"^api/apply-for-placement/(?P[0-9]+)/$", views.withdraw_application_api, name="withdraw_application_api"), + url(r"^api/my-applications/$", views.my_applications_api, name="my_applications_api"), + url(r"^api/my-offers/$", views.my_offers_api, name="my_offers_api"), + url(r"^api/offer/(?P[0-9]+)/$", views.offer_detail_api, name="offer_detail_api"), + url(r"^api/offer/(?P[0-9]+)/respond/$", views.offer_respond_api, name="offer_respond_api"), + url(r"^api/student-applications/(?P[0-9]+)/$", views.student_applications_api, name="student_applications_api"), + url(r"^api/application-detail/(?P[0-9]+)/$", views.application_detail_api, name="application_detail_api"), + url(r"^api/application-detail/(?P[0-9]+)/interview/$", views.application_interview_schedule_api, name="application_interview_schedule_api"), + url(r"^api/download-applications/(?P[0-9]+)/$", views.download_applications_api, name="download_applications_api"), + url(r"^api/nextround/(?P[0-9]+)/$", views.next_round_api, name="next_round_api"), + url(r"^api/timeline/(?P[0-9]+)/$", views.timeline_api, name="timeline_api"), + url(r"^api/calender/$", views.calendar_api, name="calendar_api"), + url(r"^api/generate-cv/$", views.generate_cv_api, name="generate_cv_api"), + url(r"^api/debared-students/$", views.debarred_students_api, name="debarred_students_api"), + url(r"^api/debared-status/(?P[A-Za-z0-9]+)/$", views.debarred_status_api, name="debarred_status_api"), + url(r"^api/send-notification/$", views.send_notification_api, name="send_notification_api"), + url(r"^api/restrictions/$", views.restrictions_api, name="restrictions_api"), + url(r"^api/restrictions/(?P[0-9]+)/$", views.restriction_detail_api, name="restriction_detail_api"), + url(r"^api/policies/$", views.placement_policies_api, name="placement_policies_api"), + url(r"^api/policies/(?P[0-9]+)/$", views.placement_policy_detail_api, name="placement_policy_detail_api"), + url(r"^api/alumni/profile/$", views.alumni_profile_api, name="alumni_profile_api"), + url(r"^api/alumni/directory/$", views.alumni_directory_api, name="alumni_directory_api"), + url(r"^api/alumni/verification/$", views.alumni_verification_list_api, name="alumni_verification_list_api"), + url(r"^api/alumni/verification/(?P[0-9]+)/$", views.alumni_verification_detail_api, name="alumni_verification_detail_api"), + url(r"^api/alumni/referrals/$", views.alumni_referrals_api, name="alumni_referrals_api"), + url(r"^api/alumni/connections/$", views.alumni_connections_api, name="alumni_connections_api"), + url(r"^api/alumni/connections/(?P[0-9]+)/$", views.alumni_connection_detail_api, name="alumni_connection_detail_api"), + url(r"^api/alumni/sessions/$", views.alumni_sessions_api, name="alumni_sessions_api"), + url(r"^api/alumni/sessions/(?P[0-9]+)/$", views.alumni_session_detail_api, name="alumni_session_detail_api"), + # PlacementAppeal API endpoints + url(r"^api/placement-appeals/$", views.placement_appeal_list_create_api, name="placement_appeal_list_create_api"), + url(r"^api/placement-appeals/(?P[0-9]+)/$", views.placement_appeal_detail_api, name="placement_appeal_detail_api"), + # Placement Announcements API endpoints + url(r"^api/announcements/$", views.placement_announcements_api, name="placement_announcements_api"), + url(r"^api/announcements/(?P[0-9]+)/$", views.placement_announcement_detail_api, name="placement_announcement_detail_api"), + # Off-Campus Placements API endpoints + url(r"^api/offcampus/$", views.offcampus_placements_api, name="offcampus_placements_api"), + url(r"^api/offcampus/(?P[0-9]+)/$", views.offcampus_placement_detail_api, name="offcampus_placement_detail_api"), + # Published-CPI student view + export API endpoints + url(r"^api/cpi-batches/$", views.placement_cpi_batches_api, name="placement_cpi_batches_api"), + url(r"^api/cpi-students/$", views.placement_cpi_students_api, name="placement_cpi_students_api"), + # Branch (department) reference list for placement forms + url(r"^api/branches/$", views.placement_branches_api, name="placement_branches_api"), + # Free-form placement calendar events (Google-Calendar style) + url(r"^api/calendar-events/$", views.placement_calendar_events_api, name="placement_calendar_events_api"), + url(r"^api/calendar-events/(?P[0-9]+)/$", views.placement_calendar_event_detail_api, name="placement_calendar_event_detail_api"), +] diff --git a/FusionIIIT/applications/placement_cell/api/views.py b/FusionIIIT/applications/placement_cell/api/views.py new file mode 100644 index 000000000..dd16e6c32 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/api/views.py @@ -0,0 +1,3969 @@ +from rest_framework import status +from rest_framework.authentication import TokenAuthentication +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +# --- Offer Detail and Respond APIs --- +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def offer_detail_api(request, offer_id): + """Return offer details for a student (PlacementStatus).""" + student = selectors.get_student_for_user(request.user) + offer = PlacementStatus.objects.select_related('notify_id').filter(pk=offer_id, unique_id=student).first() + if not offer: + return Response({'detail': 'Offer not found.'}, status=status.HTTP_404_NOT_FOUND) + notify = offer.notify_id + schedule = PlacementSchedule.objects.select_related('role').filter(notify_id=notify).order_by('-id').first() + response_deadline = offer.timestamp + datetime.timedelta(days=offer.no_of_days) if offer.timestamp else None + data = { + 'id': offer.id, + 'schedule_id': schedule.id if schedule else None, + 'company_name': notify.company_name, + 'role': schedule.get_role if schedule else '', + 'ctc': str(notify.ctc), + 'invitation': offer.invitation, + 'response_deadline': response_deadline.isoformat() if response_deadline else None, + 'deadline_hours': offer.no_of_days * 24, + } + return Response(data, status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def offer_respond_api(request, offer_id): + """Accept or decline an offer (PlacementStatus).""" + student = selectors.get_student_for_user(request.user) + offer = PlacementStatus.objects.select_related('notify_id').filter(pk=offer_id, unique_id=student).first() + if not offer: + return Response({'detail': 'Offer not found.'}, status=status.HTTP_404_NOT_FOUND) + action = str(request.data.get('action', '')).upper() + if offer.invitation != 'PENDING': + return Response({'detail': 'This invitation has already been responded to.'}, status=status.HTTP_409_CONFLICT) + deadline = offer.timestamp + datetime.timedelta(days=offer.no_of_days) + current_time = timezone.now() + if timezone.is_naive(deadline) and timezone.is_aware(current_time): + current_time = timezone.make_naive(current_time) + elif timezone.is_aware(deadline) and timezone.is_naive(current_time): + current_time = timezone.make_aware(current_time) + if current_time > deadline: + offer.invitation = 'IGNORE' + offer.save() + return Response({'detail': 'This placement invitation has expired.'}, status=status.HTTP_403_FORBIDDEN) + if action == 'ACCEPTED': + # Only allow if no other accepted offer + blocking_offer = PlacementStatus.objects.filter(unique_id=student, invitation='ACCEPTED').exclude(pk=offer.pk).exists() + if blocking_offer: + return Response({'detail': 'You already have an accepted offer and cannot accept another.'}, status=status.HTTP_409_CONFLICT) + offer.invitation = 'ACCEPTED' + offer.timestamp = timezone.now() + offer.save() + officer_recipients = User.objects.filter( + current_designation__designation__name__in=['placement officer', 'placement chairman'], + ).distinct() + _send_placement_notifications( + actor=request.user, + recipients=list(officer_recipients), + description='{} accepted the offer for {}.'.format(student.id.id, offer.notify_id.company_name), + ) + return Response({'message': 'Offer accepted successfully.'}, status=status.HTTP_200_OK) + elif action == 'REJECTED': + offer.invitation = 'REJECTED' + offer.timestamp = timezone.now() + offer.save() + officer_recipients = User.objects.filter( + current_designation__designation__name__in=['placement officer', 'placement chairman'], + ).distinct() + _send_placement_notifications( + actor=request.user, + recipients=list(officer_recipients), + description='{} declined the offer for {}.'.format(student.id.id, offer.notify_id.company_name), + ) + return Response({'message': 'Offer declined successfully.'}, status=status.HTTP_200_OK) + else: + return Response({'detail': 'Invalid action.'}, status=status.HTTP_400_BAD_REQUEST) +from .serializers import (PlacementAppealSerializer, + PlacementAnnouncementSerializer, + PlacementAnnouncementWriteSerializer, + OffCampusPlacementSerializer, + OffCampusPlacementWriteSerializer, + PlacementCalendarEventSerializer) +from applications.placement_cell.models import PlacementAppeal + +# PlacementAppeal API +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_appeal_list_create_api(request): + if request.method == 'GET': + appeals = PlacementAppeal.objects.select_related( + 'student__id__user', + 'placement_status__notify_id', + ).order_by('-created_at') + if not _is_tpo_user(request.user): + student = selectors.get_student_for_user(request.user) + appeals = appeals.filter(student=student) + return Response([_serialize_appeal(item) for item in appeals], status=status.HTTP_200_OK) + + student = selectors.get_student_for_user(request.user) + placement_status = get_object_or_404( + PlacementStatus.objects.select_related('notify_id'), + pk=request.data.get('placement_status'), + unique_id=student, + ) + application = PlacementApplication.objects.filter( + schedule__notify_id=placement_status.notify_id, + student=student, + ).first() + if application is None or application.status != 'reject': + return Response( + {'detail': 'Appeals can only be raised after an application is rejected.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + if PlacementAppeal.objects.filter(student=student, placement_status=placement_status).exists(): + return Response( + {'detail': 'An appeal has already been submitted for this rejection.'}, + status=status.HTTP_409_CONFLICT, + ) + reason = (request.data.get('reason') or '').strip() + if not reason: + return Response({'reason': ['This field is required.']}, status=status.HTTP_400_BAD_REQUEST) + appeal = PlacementAppeal.objects.create( + student=student, + placement_status=placement_status, + reason=reason, + ) + officer_recipients = User.objects.filter( + current_designation__designation__name__in=['placement officer', 'placement chairman'], + ).distinct() + _send_placement_notifications( + actor=request.user, + recipients=list(officer_recipients), + description='{} submitted an appeal for {}.'.format(student.id.id, placement_status.notify_id.company_name), + ) + return Response(_serialize_appeal(appeal), status=status.HTTP_201_CREATED) + +@api_view(['GET', 'PUT']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_appeal_detail_api(request, pk): + appeal = get_object_or_404( + PlacementAppeal.objects.select_related( + 'student__id__user', + 'placement_status__notify_id', + ), + pk=pk, + ) + is_tpo = _is_tpo_user(request.user) + if not is_tpo: + student = selectors.get_student_for_user(request.user) + if appeal.student_id != student.id: + return Response({'detail': 'Appeal not found.'}, status=status.HTTP_404_NOT_FOUND) + + if request.method == 'GET': + return Response(_serialize_appeal(appeal), status=status.HTTP_200_OK) + + if not is_tpo: + return Response({'detail': 'Only TPO users can update appeals.'}, status=status.HTTP_403_FORBIDDEN) + next_status = str(request.data.get('status') or '').lower() + if next_status not in ['pending', 'reviewed', 'accepted', 'rejected']: + return Response({'status': ['Invalid appeal status.']}, status=status.HTTP_400_BAD_REQUEST) + appeal.status = next_status + appeal.response = request.data.get('response', appeal.response) + appeal.reviewed_at = timezone.now() if next_status != 'pending' else None + appeal.save(update_fields=['status', 'response', 'reviewed_at']) + _send_placement_notifications( + actor=request.user, + recipients=[appeal.student.id.user], + description='Your placement appeal for {} has been updated to {}.'.format( + appeal.placement_status.notify_id.company_name, + next_status, + ), + ) + return Response(_serialize_appeal(appeal), status=status.HTTP_200_OK) +import os +import shutil +import datetime +import decimal +import zipfile +import xlwt +import logging +import json +from collections import defaultdict + +from html import escape +from datetime import date +from io import BytesIO +from wsgiref.util import FileWrapper +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User +from django.contrib import messages +from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.core.files.storage import FileSystemStorage +from django.core.mail import send_mail +from django.core.paginator import Paginator +from django.core.validators import validate_email +from django.db import transaction +from django.db.models import Count, Max, Prefetch, Q +from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.template.loader import get_template, render_to_string +from django.utils.html import strip_tags +from django.utils import timezone +from django.utils.encoding import smart_str +from xhtml2pdf import pisa +from django.core import serializers +from notifications.signals import notify +from rest_framework import status +from rest_framework.authentication import TokenAuthentication +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from applications.academic_information.models import Student +from notification.views import placement_cell_notif +from applications.globals.models import (DepartmentInfo, ExtraInfo, + HoldsDesignation, Designation) +from .. import selectors, services + +from ..models import (Achievement, ChairmanVisit, Course, Education, Experience, Conference, + Has, NotifyStudent, Patent, PlacementRecord, Extracurricular, Reference, + PlacementSchedule, PlacementStatus, Project, Publication, + Skill, StudentPlacement, StudentRecord, Role, CompanyDetails, + PlacementField, PlacementApplication, PlacementApplicationResponse, + PlacementRound, PlacementRestriction, PlacementPolicy, PlacementProfileDocument, + PlacementProfileAuditLog, PlacementNotificationPreference, + PlacementReportSchedule, AlumniConnection, AlumniMentorshipSession, AlumniProfile, + AlumniReferral, PlacementApplicationTimeline, PlacementInterviewSchedule, + PlacementAnnouncement, OffCampusPlacement, PlacementCalendarEvent) +''' + @variables: + user - logged in user + profile - variable for extrainfo + studentrecord - storing all fetched student record from database + years - yearwise record of student placement + records - all the record of placement record table + tcse - all record of cse + tece - all record of ece + tme - all record of me + tadd - all record of student + form respective form object + stuname - student name obtained from the form + ctc - salary offered obtained from the form + cname - company name obtained from the form + rollno - roll no of student obtained from the form + year - year of placement obtained from the form + s - extra info data of the student obtained from the form + p - placement data of the student obtained from the form + placementrecord - placement record of the student obtained from the form + pbirecord - pbi data of the student obtained from the form + test_type - type of higher study test obtained from the form + uname - name of universty obtained from the form + test_score - score in the test obtained from the form + higherrecord - higher study record of the student obtained from the form + current - current user on a particular designation + status - status of the sent invitation by placement cell regarding placement/pbi + institute - institute for previous education obtained from the form + degree - degree for previous education obtained from the form + grade - grade for previous education obtained from the form + stream - stream for previous education obtained from the form + sdate - start date for previous education obtained from the form + edate - end date for previous education obtained from the form + education_obj - object variable of Education table + about_me - about me data obtained from the form + age - age data obtained from the form + address - address obtained from the form + contact - contact obtained from the form + pic - picture obtained from the form + skill - skill of the user obtained from the form + skill_rating - rating of respective skill obtained from the form + has_obj - object variable of Has table + achievement - achievement of user obtained from the form + achievement_type - type of achievement obtained from the form + description - description of respective achievement obtained from the form + issuer - certifier of respective achievement obtained from the form + date_earned - date of the respective achievement obtained from the form + achievement_obj - object variable of Achievement table + publication_title - title of the publication obtained from the form + description - description of respective publication obtained from the form + publisher - publisher of respective publication obtained from the form + publication_date - date of respective publication obtained from the form + publication_obj - object variable of Publication table + patent_name - name of patent obtained from the form + description - description of respective patent obtained from the form + patent_office - office of respective patent obtained from the form + patent_date - date of respective patent obtained from the form + patent_obj - object variable of Patent table + course_name - name of the course obtained from the form + description description of respective course obtained from the form + license_no - license_no of respective course obtained from the form + sdate - start date of respective course obtained from the form + edate - end date of respective course obtained from the form + course_obj - object variable of Course table + project_name - name of project obtained from the form + project_status - status of respective project obtained from the form + summary - summery of the respective project obtained from the form + project_link - link of the respective project obtained from the form + sdate - start date of respective project obtained from the form + edate - end date of respective project obtained from the form + project_obj - object variable of Project table + title - title of any kind of experience obtained from the form + status - status of the respective experience obtained from the form + company - company from which respective experience is gained as obtained from the form + location - location of the respective experience obtained from the form + description - description of respective experience obtained from the form + sdate - start date of respective experience obtained from the form + edate - end date of respective experience obtained from the form + experience_obj - object variable of Experience table + context - to sent the relevant context for html rendering + company_name - name of visiting comapany obtained from the form + location -location of visiting company obtained from the form + description - description of respective company obtained from the form + visiting_date - visiting date of respective company obtained from the form + visit_obj -object variable of ChairmanVisit table + notify - object of NotifyStudent table + schedule - object variable of PlacementSchedule table + q1 - all data of Has table + q3 - all data of Student table + st - all data of Student table + spid - id of student to be debar + sr - record from StudentPlacement of student having id=spid + achievementcheck - checking for achievent to be shown in cv + educationcheck - checking for education to be shown in cv + publicationcheck - checking for publication to be shown in cv + patentcheck - checking for patent to be shown in cv + internshipcheck - checking for internship to be shown in cv + projectcheck - checking for project to be shown in cv + coursecheck - checking for course to be shown in cv + skillcheck - checking for skill to be shown in cv +''' + +logger = logging.getLogger('django.server') + + +def _today(): + return timezone.now().date() + + +# Ajax for the company name dropdown for CompanyName when filling AddSchedule + + +# Ajax for all the roles in the dropdown + + +def render_to_pdf(template_src, context_dict): + """ + The function is used to generate the cv in the pdf format. + Embeds the data into the predefined template. + @param: + template_src - template of cv to be rendered + context_dict - data fetched from the dtatabase to be filled in the cv template + @variables: + template - stores the template + html - html rendered pdf + result - variable to store data in BytesIO + pdf - storing encoded html of pdf version + """ + template = get_template(template_src) + html = template.render(context_dict) + result = BytesIO() + pdf = pisa.pisaDocument( + BytesIO(html.encode("UTF-8")), + result, + link_callback=_pdf_link_callback, + ) + if not pdf.err: + return HttpResponse(result.getvalue(), content_type='application/pdf') + return HttpResponse('We had some errors
%s
' % escape(html)) + + +def _pdf_link_callback(uri, rel): + if uri.startswith(settings.MEDIA_URL): + path = os.path.join(settings.MEDIA_ROOT, uri.replace(settings.MEDIA_URL, '', 1)) + elif uri.startswith(settings.STATIC_URL) and getattr(settings, 'STATIC_ROOT', None): + path = os.path.join(settings.STATIC_ROOT, uri.replace(settings.STATIC_URL, '', 1)) + else: + path = uri + + if not os.path.isfile(path): + return uri + return path + + +def export_to_xls_std_records(qs): + """ + The function is used to generate the file in the xls format. + Embeds the data into the file. + """ + response = HttpResponse(content_type='application/ms-excel') + response['Content-Disposition'] = 'attachment; filename="report.xls"' + + wb = xlwt.Workbook(encoding='utf-8') + ws = wb.add_sheet('Report') + + # Sheet header, first row + row_num = 0 + + font_style = xlwt.XFStyle() + font_style.font.bold = True + + columns = ['Roll No.', 'Name', 'CPI', 'Department', 'Discipline', 'Placed', 'Debarred' ] + + for col_num in range(len(columns)): + ws.write(row_num, col_num, columns[col_num], font_style) + + # Sheet body, remaining rows + font_style = xlwt.XFStyle() + + for student in qs: + row_num += 1 + + row = [] + row.append(student.id.id) + row.append(student.id.user.first_name+' '+student.id.user.last_name) + row.append(student.cpi) + row.append(student.programme) + row.append(student.id.department.name) + if student.studentplacement.placed_type == "PLACED": + row.append('Yes') + else: + row.append('No') + if student.studentplacement.placed_type == "DEBAR": + row.append('Yes') + else: + row.append('No') + + for col_num in range(len(row)): + ws.write(row_num, col_num, row[col_num], font_style) + + wb.save(response) + return response + + +def export_to_xls_invitation_status(qs): + response = HttpResponse(content_type='application/ms-excel') + response['Content-Disposition'] = 'attachment; filename="report.xls"' + + wb = xlwt.Workbook(encoding='utf-8') + ws = wb.add_sheet('Report') + + # Sheet header, first row + row_num = 0 + + font_style = xlwt.XFStyle() + font_style.font.bold = True + + columns = ['Roll No.', 'Name', 'Company', 'CTC', 'Invitation Status'] + + for col_num in range(len(columns)): + ws.write(row_num, col_num, columns[col_num], font_style) + + # Sheet body, remaining rows + font_style = xlwt.XFStyle() + + for student in qs: + row_num += 1 + + row = [] + row.append(student.unique_id.id.id) + row.append(student.unique_id.id.user.first_name+' '+student.unique_id.id.user.last_name) + row.append(student.notify_id.company_name) + row.append(student.notify_id.ctc) + row.append(student.invitation) + + for col_num in range(len(row)): + ws.write(row_num, col_num, row[col_num], font_style) + + wb.save(response) + return response + + +def check_invitation_date(placementstatus): + """ + The function is used to run before render of student placement view for ensuring that + last date for RESPONSE is not passed + @param: + placementstatus - queryset containing placement status of particular student + @variables: + ps - individual PlacementStatus object + """ + try: + for ps in placementstatus: + if ps.invitation=='PENDING': + dt = ps.timestamp+datetime.timedelta(days=ps.no_of_days) + if dt 5 * 1024 * 1024: + raise ValidationError('Document size must be 5MB or less.') + + +def _profile_validation_errors(student): + return _flatten_profile_errors(_profile_completion_errors(student)) + + +def _serialize_profile_eligibility_summary(student): + schedules = PlacementSchedule.objects.select_related( + 'notify_id', + 'role', + ).filter( + placement_date__gte=_today(), + ).order_by('placement_date', 'id') + summary = [] + for schedule in schedules: + eligibility = _schedule_eligibility(schedule, student) + summary.append({ + 'schedule_id': schedule.id, + 'company_name': schedule.notify_id.company_name, + 'role': schedule.get_role or '', + 'placement_date': schedule.placement_date.isoformat() if schedule.placement_date else None, + 'eligible': eligibility['eligible'], + 'reasons': eligibility['reasons'], + }) + return { + 'eligible_count': sum(1 for item in summary if item['eligible']), + 'ineligible_count': sum(1 for item in summary if not item['eligible']), + 'jobs': summary[:10], + } + + +def _is_report_admin(user): + return bool( + _is_tpo_user(user) or selectors.get_designation_queryset(user, "placement chairman") + ) + + +def _add_working_days(start, days): + current = start + remaining = days + while remaining > 0: + current += datetime.timedelta(days=1) + if current.weekday() < 5: + remaining -= 1 + return current + + +def _serialize_appeal(appeal): + due_by = _add_working_days(appeal.created_at, 5) if appeal.created_at else None + return { + 'id': appeal.id, + 'student': { + 'roll_no': appeal.student.id.id, + 'name': appeal.student.id.user.get_full_name().strip() or appeal.student.id.user.username, + 'email': appeal.student.id.user.email, + }, + 'placement_status': appeal.placement_status.id, + 'company_name': appeal.placement_status.notify_id.company_name, + 'reason': appeal.reason, + 'status': appeal.status, + 'response': appeal.response, + 'created_at': appeal.created_at.isoformat() if appeal.created_at else None, + 'reviewed_at': appeal.reviewed_at.isoformat() if appeal.reviewed_at else None, + 'due_by': due_by.isoformat() if due_by else None, + 'overdue': bool(due_by and appeal.status == 'pending' and timezone.now() > due_by), + } + + +def _get_report_records(request): + records = StudentRecord.objects.select_related( + 'record_id', + 'unique_id__id__user', + 'unique_id__id__department', + ).filter( + record_id__placement_type__in=['PLACEMENT', 'PBI'], + ) + company = request.GET.get('company') + if company: + records = records.filter(record_id__name__icontains=company) + ctc_min = request.GET.get('ctc_min') + if ctc_min not in [None, '']: + records = records.filter(record_id__ctc__gte=_parse_decimal(ctc_min)) + ctc_max = request.GET.get('ctc_max') + if ctc_max not in [None, '']: + records = records.filter(record_id__ctc__lte=_parse_decimal(ctc_max)) + year = request.GET.get('year') + if year not in [None, '']: + records = records.filter(record_id__year=year) + department = request.GET.get('department') or request.GET.get('branch') + if department: + records = records.filter(unique_id__id__department__name__iexact=department) + placement_type = request.GET.get('placement_type') + if placement_type: + records = records.filter(record_id__placement_type=placement_type) + return records + + +def _serialize_student_record(student_record): + return { + 'id': student_record.record_id.id, + 'first_name': '{} {}'.format( + student_record.unique_id.id.user.first_name, + student_record.unique_id.id.user.last_name, + ).strip() or student_record.unique_id.id.user.username, + 'roll_no': student_record.unique_id.id.id, + 'placement_name': student_record.record_id.name, + 'batch': student_record.record_id.year, + 'branch': student_record.unique_id.id.department.name if student_record.unique_id.id.department else '', + 'ctc': str(student_record.record_id.ctc), + 'placement_type': student_record.record_id.placement_type, + } + + +def _build_report_payload(request): + records = _get_report_records(request) + report_type = (request.GET.get('report_type') or 'custom').strip().lower() + if report_type == 'batch': + summary = records.values('record_id__year').annotate( + count=Count('id'), + ).order_by('-record_id__year') + columns = ['batch', 'count'] + rows = [ + {'batch': item['record_id__year'], 'count': item['count']} + for item in summary + ] + elif report_type == 'company': + summary = records.values('record_id__name').annotate( + count=Count('id'), + ).order_by('record_id__name') + columns = ['company', 'count'] + rows = [ + {'company': item['record_id__name'], 'count': item['count']} + for item in summary + ] + elif report_type == 'branch': + summary = records.values('unique_id__id__department__name').annotate( + count=Count('id'), + ).order_by('unique_id__id__department__name') + columns = ['branch', 'count'] + rows = [ + { + 'branch': item['unique_id__id__department__name'] or 'Unassigned', + 'count': item['count'], + } + for item in summary + ] + else: + columns = ['student_name', 'roll_no', 'company', 'batch', 'branch', 'ctc', 'placement_type'] + rows = [ + { + 'student_name': item['first_name'], + 'roll_no': item['roll_no'], + 'company': item['placement_name'], + 'batch': item['batch'], + 'branch': item['branch'], + 'ctc': item['ctc'], + 'placement_type': item['placement_type'], + } + for item in [_serialize_student_record(record) for record in records.order_by('-record_id__year', '-record_id__id')] + ] + return { + 'report_type': report_type, + 'columns': columns, + 'rows': rows, + 'filters': { + key: request.GET.get(key) + for key in ['company', 'ctc_min', 'ctc_max', 'year', 'department', 'branch', 'placement_type'] + if request.GET.get(key) not in [None, ''] + }, + } + + +def _build_report_pdf_response(payload): + title_map = { + 'batch': 'Batch Placement Report', + 'company': 'Company Placement Report', + 'branch': 'Branch Placement Report', + 'custom': 'Custom Placement Report', + } + header_html = ''.join( + f'{escape(str(column).replace("_", " ").title())}' + for column in payload['columns'] + ) + row_html = ''.join( + '{}'.format(''.join( + f'{escape(str(row.get(column, "")))}' + for column in payload['columns'] + )) + for row in payload['rows'] + ) or 'No records found.'.format( + len(payload['columns']) or 1, + ) + filter_html = ''.join( + f'
  • {escape(key.replace("_", " ").title())}: {escape(str(value))}
  • ' + for key, value in payload['filters'].items() + ) or '
  • No filters applied.
  • ' + html = f""" + + +

    {escape(title_map.get(payload['report_type'], 'Placement Report'))}

    +

    Generated on {escape(timezone.now().strftime('%Y-%m-%d %H:%M'))}

    +

    Filters

    +
      {filter_html}
    + + {header_html} + {row_html} +
    + + + """ + result = BytesIO() + pdf = pisa.pisaDocument(BytesIO(html.encode('utf-8')), result) + if pdf.err: + return Response({'detail': 'Could not generate PDF report.'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + response = HttpResponse(result.getvalue(), content_type='application/pdf') + response['Content-Disposition'] = 'attachment; filename="placement_report.pdf"' + return response + + +def _build_report_excel_response(payload): + response = HttpResponse(content_type='application/ms-excel') + response['Content-Disposition'] = 'attachment; filename="placement_report.xls"' + workbook = xlwt.Workbook(encoding='utf-8') + sheet = workbook.add_sheet('Report') + header_style = xlwt.XFStyle() + header_style.font.bold = True + for idx, column in enumerate(payload['columns']): + sheet.write(0, idx, str(column).replace('_', ' ').title(), header_style) + for row_index, row in enumerate(payload['rows'], start=1): + for col_index, column in enumerate(payload['columns']): + sheet.write(row_index, col_index, str(row.get(column, ''))) + workbook.save(response) + return response + + +def _serialize_report_schedule(item): + return { + 'id': item.id, + 'name': item.name, + 'report_type': item.report_type, + 'frequency': item.frequency, + 'export_format': item.export_format, + 'filters': item.filters or {}, + 'recipients': [entry.strip() for entry in (item.recipients or '').split(',') if entry.strip()], + 'is_active': item.is_active, + 'last_run_at': item.last_run_at.isoformat() if item.last_run_at else None, + 'created_at': item.created_at.isoformat() if item.created_at else None, + 'updated_at': item.updated_at.isoformat() if item.updated_at else None, + } + + +def _coerce_decimal(value): + try: + return decimal.Decimal(str(value)) + except Exception: + return None + + +def _matches_condition(actual, expected, condition): + if actual is None: + return False + actual_value = str(actual).strip() + expected_value = str(expected).strip() + if condition == 'equals': + return actual_value.lower() == expected_value.lower() + if condition == 'not_equals': + return actual_value.lower() != expected_value.lower() + actual_decimal = _coerce_decimal(actual) + expected_decimal = _coerce_decimal(expected) + if actual_decimal is None or expected_decimal is None: + return False + if condition == 'gte': + return actual_decimal >= expected_decimal + if condition == 'gt': + return actual_decimal > expected_decimal + if condition == 'lte': + return actual_decimal <= expected_decimal + if condition == 'lt': + return actual_decimal < expected_decimal + return False + + +def _student_attribute_map(student): + return { + 'cpi': student.cpi, + 'batch': student.batch, + 'passoutyr': student.batch, + 'department': student.id.department.name if student.id.department else '', + 'branch': student.id.department.name if student.id.department else '', + 'programme': student.programme, + 'gender': student.id.sex, + } + + +def _schedule_eligibility(schedule, student, *, student_placement=None, restrictions=None): + student_placement = student_placement or _ensure_studentplacement(student) + reasons = [] + if student_placement.debar == 'DEBAR': + reasons.append('Student is debarred.') + + student_attributes = _student_attribute_map(student) + direct_checks = [ + ('cpi', schedule.cpi, 'gte', 'CPI requirement not met.'), + ('passoutyr', schedule.passoutyr, 'equals', 'Passout year requirement not met.'), + ('gender', schedule.gender, 'equals', 'Gender requirement not met.'), + ] + for attribute, expected, condition, message in direct_checks: + if expected not in [None, ''] and not _matches_condition(student_attributes.get(attribute), expected, condition): + reasons.append(message) + + if schedule.branch: + allowed_branches = [item.strip().lower() for item in schedule.branch.split(',') if item.strip()] + student_branch = str(student_attributes.get('branch') or '').strip().lower() + if allowed_branches and student_branch not in allowed_branches: + reasons.append('Branch requirement not met.') + + for restriction in (restrictions if restrictions is not None else PlacementRestriction.objects.all()): + actual = student_attributes.get(restriction.criteria.lower()) + if actual is None: + continue + if not _matches_condition(actual, restriction.value, restriction.condition): + reasons.append(restriction.description or '{} restriction not met.'.format(restriction.criteria)) + + return { + 'eligible': len(reasons) == 0, + 'reasons': reasons, + } + + +def _send_placement_notifications(*, actor, recipients, description): + recipients = list(recipients) + recipient_ids = [recipient.id for recipient in recipients if getattr(recipient, 'id', None)] + student_map = { + student.id.user_id: student + for student in Student.objects.filter(id__user_id__in=recipient_ids).select_related('id__user') + } + preference_map = { + preference.student_id: preference + for preference in PlacementNotificationPreference.objects.filter( + student_id__in=[student.pk for student in student_map.values()], + ) + } + + for recipient in recipients: + preference = None + student = student_map.get(recipient.id) + if student: + preference = preference_map.get(student.pk) + if preference is None: + preference = _ensure_notification_preferences(student) + preference_map[student.pk] = preference + portal_enabled = True if preference is None else preference.enable_portal + email_enabled = False if preference is None else preference.enable_email + sms_enabled = False if preference is None else preference.enable_sms + + if portal_enabled: + notify.send( + sender=actor, + recipient=recipient, + verb=description, + url='placement:placement', + module='Placement Cell', + ) + + if email_enabled and recipient.email: + send_mail( + subject='Placement Cell Notification', + message=description, + from_email=getattr(settings, 'EMAIL_HOST_USER', None) or 'noreply@fusion.local', + recipient_list=[recipient.email], + fail_silently=True, + ) + + if sms_enabled: + logger = logging.getLogger(__name__) + logger.info('SMS notification queued for %s: %s', recipient.username, description) + + +def _application_stage_label(status_value): + stage_map = { + 'pending': 'Under Review', + 'shortlisted': 'Shortlisted', + 'interview_scheduled': 'Interview Scheduled', + 'interview_completed': 'Interview Completed', + 'offer_released': 'Offer Released', + 'accept': 'Selected', + 'reject': 'Rejected', + 'withdrawn': 'Withdrawn', + } + return stage_map.get(status_value, 'Under Review') + + +def _create_application_timeline_entry(application, *, stage=None, remarks='', actor=None): + return PlacementApplicationTimeline.objects.create( + application=application, + stage=stage or _application_stage_label(application.status), + remarks=remarks or '', + actor=actor, + ) + + +def _serialize_application_timeline(application): + entries = [{ + 'id': 'applied-{}'.format(application.id), + 'stage': 'Applied', + 'remarks': 'Application submitted', + 'actor': application.student.id.user.get_full_name().strip() or application.student.id.user.username, + 'created_at': application.created_at.isoformat() if application.created_at else None, + }] + entries.extend([{ + 'id': item.id, + 'stage': item.stage, + 'remarks': item.remarks, + 'actor': item.actor.get_full_name().strip() if item.actor else '', + 'created_at': item.created_at.isoformat() if item.created_at else None, + } for item in application.timeline_entries.select_related('actor').all()]) + return entries + + +def _serialize_application_interview(item): + return { + 'id': item.id, + 'round_no': item.round_no, + 'title': item.title or 'Round {}'.format(item.round_no), + 'scheduled_at': item.scheduled_at.isoformat() if item.scheduled_at else None, + 'end_datetime': item.end_datetime.isoformat() if item.end_datetime else None, + 'mode': item.mode, + 'location': item.location, + 'meeting_link': item.meeting_link, + 'remarks': item.remarks, + 'feedback': item.remarks, + 'outcome': item.outcome, + 'is_active': item.is_active, + } + + +def _ensure_placement_record_for_selection(application): + student_record = StudentRecord.objects.filter( + unique_id=application.student, + record_id__name=application.schedule.notify_id.company_name, + record_id__year=timezone.now().year, + record_id__placement_type=application.schedule.notify_id.placement_type, + ).select_related('record_id').first() + if student_record: + return student_record.record_id + + record = PlacementRecord.objects.create( + placement_type=application.schedule.notify_id.placement_type, + name=application.schedule.notify_id.company_name, + ctc=application.schedule.notify_id.ctc, + year=timezone.now().year, + test_type=application.schedule.get_role or '', + test_score=0, + ) + StudentRecord.objects.get_or_create(record_id=record, unique_id=application.student) + return record + + +def _serialize_tpo_application_detail(application, request=None): + student_user = application.student.id.user + offer = PlacementStatus.objects.filter( + notify_id=application.schedule.notify_id, + unique_id=application.student, + ).first() + responses = PlacementApplicationResponse.objects.select_related('field').filter(application=application) + profile = application.student.id + documents = PlacementProfileDocument.objects.filter(student=application.student).order_by('-uploaded_at', '-id') + return { + 'id': application.id, + 'schedule_id': application.schedule_id, + 'status': application.status, + 'status_label': _application_stage_label(application.status), + 'remarks': application.remarks or '', + 'updated_at': application.updated_at.isoformat() if application.updated_at else None, + 'applied_at': application.created_at.isoformat() if application.created_at else None, + 'student': { + 'name': student_user.get_full_name().strip() or student_user.username, + 'roll_no': application.student.id.id, + 'email': student_user.email, + 'phone_no': str(profile.phone_no or ''), + 'address': profile.address or '', + 'about_me': profile.about_me if profile.about_me != 'NA' else '', + 'programme': application.student.programme or '', + 'branch': application.student.id.department.name if application.student.id.department else '', + 'cpi': application.student.cpi, + 'passout_year': application.student.batch, + }, + 'company': { + 'name': application.schedule.notify_id.company_name, + 'role': application.schedule.get_role or '', + 'ctc': str(application.schedule.notify_id.ctc), + 'placement_type': application.schedule.notify_id.placement_type, + }, + 'documents': [_serialize_profile_document(item, request=request) for item in documents], + 'resume': _serialize_profile_document(documents[0], request=request) if documents else None, + 'timeline': _serialize_application_timeline(application), + 'interviews': [ + _serialize_application_interview(item) + for item in application.interview_schedules.all() + ], + 'offer': { + 'id': offer.id, + 'invitation': offer.invitation, + 'timestamp': offer.timestamp.isoformat() if offer and offer.timestamp else None, + 'deadline_days': offer.no_of_days if offer else None, + } if offer else None, + 'responses': [ + { + 'field': item.field.name if item.field else 'Response', + 'value': item.value, + } + for item in responses + ], + } + + +def _serialize_schedule(schedule, user, *, has_applied=False, eligibility=None): + eligibility = eligibility or {'eligible': True, 'reasons': []} + company = schedule.company + application_fields = [{ + 'field_id': field.id, + 'name': field.name, + 'type': field.type, + 'required': field.required, + } for field in schedule.fields.all().order_by('name')] + + return { + 'id': str(schedule.id), + 'jobID': str(schedule.id), + 'company_name': schedule.notify_id.company_name, + 'location': schedule.location, + 'role_st': schedule.get_role or '', + 'placement_type': schedule.notify_id.placement_type, + 'schedule_at': schedule.schedule_at.isoformat() if schedule.schedule_at else '', + 'placement_date': schedule.placement_date.isoformat() if schedule.placement_date else '', + 'description': schedule.description or '', + 'ctc': str(schedule.notify_id.ctc), + 'check': has_applied, + 'time': schedule.time.isoformat() if schedule.time else '', + 'end_datetime': schedule.end_datetime.isoformat() if schedule.end_datetime else '', + 'attached_file_url': schedule.attached_file.url if schedule.attached_file else None, + 'eligible': eligibility['eligible'], + 'eligibility_reasons': eligibility['reasons'], + 'eligibility_criteria': [item.strip() for item in (schedule.eligibility or '').split(',') if item.strip()], + 'passout_year': schedule.passoutyr or '', + 'gender_requirement': schedule.gender or '', + 'cpi_requirement': schedule.cpi or '', + 'branch_requirement': schedule.branch or '', + 'company_details': { + 'description': company.description if company else '', + 'address': company.address if company else '', + 'website': company.website if company else '', + 'logo_url': company.logo.url if company and company.logo else None, + }, + 'application_fields': application_fields, + } + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_api(request): + if request.method == 'GET': + selected_role = cache.get('last_selected_role_{}'.format(request.user.id)) + has_student_designation = selectors.get_designation_queryset( + request.user, "student" + ).exists() + has_tpo_designation = _is_tpo_user(request.user) + is_student_view = selected_role == "student" and has_student_designation + is_tpo_user = has_tpo_designation and not is_student_view + schedules = PlacementSchedule.objects.select_related( + 'notify_id', + 'role', + 'company', + ).prefetch_related( + 'fields', + ).order_by('-placement_date', '-id') + + # Advanced search/filter + company = request.GET.get('company') + role = request.GET.get('role') + location = request.GET.get('location') + min_package = request.GET.get('min_package') + max_package = request.GET.get('max_package') + + if company: + schedules = schedules.filter(notify_id__company_name__icontains=company) + if role: + schedules = schedules.filter(role__role__icontains=role) + if location: + schedules = schedules.filter(location__icontains=location) + if min_package: + schedules = schedules.filter(notify_id__ctc__gte=min_package) + if max_package: + schedules = schedules.filter(notify_id__ctc__lte=max_package) + + if has_student_designation and not is_tpo_user: + student = selectors.get_student_for_user(request.user) + future_aspect = _ensure_studentplacement(student).future_aspect + # Students should be able to see both placement and internship drives + # on the placement schedule page. Higher studies remains separate. + # The frontend exposes All / Active / Upcoming tabs, so students + # need the full schedule list and the client can segment by date. + if future_aspect == "HIGHER STUDIES": + schedules = schedules.filter(notify_id__placement_type=future_aspect) + else: + schedules = schedules.filter( + notify_id__placement_type__in=["PLACEMENT", "PBI"] + ) + schedules = list(schedules) + data = [] + has_applied_schedule_ids = set() + eligibility_by_schedule_id = {} + + if has_student_designation: + try: + student = selectors.get_student_for_user(request.user) + student_placement = _ensure_studentplacement(student) + restrictions = list(PlacementRestriction.objects.all()) + has_applied_schedule_ids = set( + PlacementApplication.objects.filter( + student=student, + schedule_id__in=[schedule.id for schedule in schedules], + ).exclude(status='withdrawn').values_list('schedule_id', flat=True), + ) + for schedule in schedules: + eligibility_by_schedule_id[schedule.id] = _schedule_eligibility( + schedule, + student, + student_placement=student_placement, + restrictions=restrictions, + ) + except Exception: + has_applied_schedule_ids = set() + eligibility_by_schedule_id = {} + + for schedule in schedules: + try: + data.append( + _serialize_schedule( + schedule, + request.user, + has_applied=schedule.id in has_applied_schedule_ids, + eligibility=eligibility_by_schedule_id.get( + schedule.id, + {'eligible': True, 'reasons': []}, + ), + ), + ) + except Exception: + continue + return Response(data, status=status.HTTP_200_OK) + + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can create placement schedules.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + company = None + company_id = request.data.get('company_id') + if company_id: + company = CompanyDetails.objects.filter(id=company_id).first() + company_name = request.data.get('company_name') or request.data.get('title') + if company is None and company_name: + company, _ = CompanyDetails.objects.get_or_create(company_name=company_name) + + role_name = request.data.get('role') or '' + role = selectors.get_or_create_role(role_name) if role_name else None + placement_date = _parse_date(request.data.get('placement_date')) or timezone.now().date() + if placement_date < _today(): + return Response( + {'placement_date': ['Placement date cannot be in the past.']}, + status=status.HTTP_400_BAD_REQUEST, + ) + schedule_time = _parse_time(request.data.get('schedule_at')) or timezone.now().time() + notify = NotifyStudent.objects.create( + placement_type=_normalize_placement_type(request.data.get('placement_type')), + company_name=company_name or '', + ctc=_parse_decimal(request.data.get('ctc')), + description=request.data.get('description') or '', + ) + schedule = PlacementSchedule.objects.create( + notify_id=notify, + title=request.data.get('title') or notify.company_name, + placement_date=placement_date, + end_date=_parse_date(request.data.get('end_date')), + location=request.data.get('location') or '', + description=request.data.get('description') or '', + eligibility=request.data.get('eligibility') or '', + passoutyr=request.data.get('passoutyr') or '', + gender=request.data.get('gender') or '', + cpi=str(request.data.get('cpi') or ''), + branch=request.data.get('branch') or '', + time=schedule_time, + role=role, + attached_file=request.FILES.get('resume') or request.FILES.get('attached_file'), + schedule_at=_parse_datetime(request.data.get('schedule_at')) or timezone.now(), + end_datetime=_parse_datetime(request.data.get('end_datetime')), + company=company, + ) + field_ids = _get_field_ids_from_request(request) + if field_ids: + schedule.fields.set(PlacementField.objects.filter(id__in=field_ids)) + return Response(_serialize_schedule(schedule, request.user), status=status.HTTP_201_CREATED) + + +@api_view(['GET', 'PUT', 'DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_detail_api(request, schedule_id): + schedule = get_object_or_404( + PlacementSchedule.objects.select_related('notify_id', 'role', 'company').prefetch_related('fields'), + pk=schedule_id, + ) + + if request.method == 'GET': + return Response(_serialize_schedule(schedule, request.user), status=status.HTTP_200_OK) + + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can modify placement schedules.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + if request.method == 'DELETE': + schedule.notify_id.delete() + return Response({'message': 'Placement schedule deleted successfully.'}, status=status.HTTP_200_OK) + + notify = schedule.notify_id + notify.placement_type = _normalize_placement_type(request.data.get('placement_type') or notify.placement_type) + notify.company_name = request.data.get('company_name') or notify.company_name + notify.ctc = _parse_decimal(request.data.get('ctc'), notify.ctc) + notify.description = request.data.get('description') or notify.description + notify.save() + + placement_date = _parse_date(request.data.get('placement_date')) + if placement_date and placement_date < _today(): + return Response( + {'placement_date': ['Placement date cannot be in the past.']}, + status=status.HTTP_400_BAD_REQUEST, + ) + schedule.placement_date = placement_date or schedule.placement_date + schedule.location = request.data.get('location') or schedule.location + schedule.description = request.data.get('description') or schedule.description + schedule.schedule_at = _parse_datetime(request.data.get('schedule_at')) or schedule.schedule_at + schedule.end_datetime = _parse_datetime(request.data.get('end_date_time')) or schedule.end_datetime + role_name = request.data.get('role') + if role_name: + schedule.role = selectors.get_or_create_role(role_name) + time_value = _parse_time(request.data.get('schedule_at')) + if time_value: + schedule.time = time_value + schedule.save() + return Response({'message': 'Placement schedule updated successfully.'}, status=status.HTTP_200_OK) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_statistics_api(request): + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can access placement statistics.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + if request.method == 'GET': + records = StudentRecord.objects.select_related( + 'record_id', + 'unique_id__id__user', + 'unique_id__id__department', + ) + company = request.GET.get('company') + if company: + records = records.filter(record_id__name__icontains=company) + ctc_min = request.GET.get('ctc_min') + if ctc_min not in [None, '']: + records = records.filter(record_id__ctc__gte=_parse_decimal(ctc_min)) + ctc_max = request.GET.get('ctc_max') + if ctc_max not in [None, '']: + records = records.filter(record_id__ctc__lte=_parse_decimal(ctc_max)) + year = request.GET.get('year') + if year not in [None, '']: + records = records.filter(record_id__year=year) + department = request.GET.get('department') + if department: + records = records.filter(unique_id__id__department__name__iexact=department) + + if request.GET.get('aggregate_by') == 'department': + summary = records.values( + 'unique_id__id__department__name', + ).annotate( + count=Count('id'), + ).order_by('unique_id__id__department__name') + return Response([ + { + 'department': item['unique_id__id__department__name'] or 'Unassigned', + 'count': item['count'], + } + for item in summary + ], status=status.HTTP_200_OK) + + rows = [] + for item in records.order_by('-record_id__year', '-record_id__id'): + rows.append({ + 'id': item.record_id.id, + 'first_name': '{} {}'.format( + item.unique_id.id.user.first_name, + item.unique_id.id.user.last_name, + ).strip() or item.unique_id.id.user.username, + 'placement_name': item.record_id.name, + 'batch': item.record_id.year, + 'branch': item.unique_id.id.department.name if item.unique_id.id.department else '', + 'ctc': str(item.record_id.ctc), + }) + return Response(rows, status=status.HTTP_200_OK) + + roll_no = request.data.get('roll_no') + student = get_object_or_404(Student.objects.select_related('id__user', 'id__department'), pk=roll_no) + record = PlacementRecord.objects.create( + placement_type=_normalize_placement_type(request.data.get('placement_type')), + name=request.data.get('company_name') or '', + ctc=_parse_decimal(request.data.get('ctc')), + year=int(request.data.get('year') or timezone.now().year), + test_type=request.data.get('test_type') or '', + test_score=int(request.data.get('test_score') or 0), + ) + StudentRecord.objects.get_or_create(record_id=record, unique_id=student) + return Response({'id': record.id}, status=status.HTTP_201_CREATED) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_reports_api(request): + if not _is_report_admin(request.user): + return Response({'detail': 'Only TPO and chairman users can access reports.'}, status=status.HTTP_403_FORBIDDEN) + payload = _build_report_payload(request) + payload['templates'] = [ + {'value': 'batch', 'label': 'Batch Summary'}, + {'value': 'company', 'label': 'Company Summary'}, + {'value': 'branch', 'label': 'Branch Summary'}, + {'value': 'custom', 'label': 'Custom Report'}, + ] + return Response(payload, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_reports_export_api(request): + if not _is_report_admin(request.user): + return Response({'detail': 'Only TPO and chairman users can export reports.'}, status=status.HTTP_403_FORBIDDEN) + payload = _build_report_payload(request) + export_format = ( + request.GET.get('export_format') + or request.GET.get('download_format') + or 'excel' + ).lower() + if export_format == 'pdf': + return _build_report_pdf_response(payload) + return _build_report_excel_response(payload) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_report_schedules_api(request): + if not _is_report_admin(request.user): + return Response({'detail': 'Only TPO and chairman users can manage report schedules.'}, status=status.HTTP_403_FORBIDDEN) + if request.method == 'GET': + schedules = PlacementReportSchedule.objects.all() + return Response([_serialize_report_schedule(item) for item in schedules], status=status.HTTP_200_OK) + + recipients = request.data.get('recipients') or [] + if isinstance(recipients, list): + recipients = ', '.join([str(item).strip() for item in recipients if str(item).strip()]) + schedule = PlacementReportSchedule.objects.create( + name=request.data.get('name') or 'Placement Report', + report_type=request.data.get('report_type') or 'custom', + frequency=request.data.get('frequency') or 'weekly', + export_format=request.data.get('export_format') or 'excel', + filters=request.data.get('filters') or {}, + recipients=recipients, + is_active=bool(request.data.get('is_active', True)), + created_by=request.user, + ) + return Response(_serialize_report_schedule(schedule), status=status.HTTP_201_CREATED) + + +@api_view(['PUT', 'DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_report_schedule_detail_api(request, schedule_id): + if not _is_report_admin(request.user): + return Response({'detail': 'Only TPO and chairman users can manage report schedules.'}, status=status.HTTP_403_FORBIDDEN) + schedule = get_object_or_404(PlacementReportSchedule, pk=schedule_id) + if request.method == 'DELETE': + schedule.delete() + return Response({'message': 'Report schedule deleted successfully.'}, status=status.HTTP_200_OK) + + recipients = request.data.get('recipients', schedule.recipients) + if isinstance(recipients, list): + recipients = ', '.join([str(item).strip() for item in recipients if str(item).strip()]) + schedule.name = request.data.get('name', schedule.name) + schedule.report_type = request.data.get('report_type', schedule.report_type) + schedule.frequency = request.data.get('frequency', schedule.frequency) + schedule.export_format = request.data.get('export_format', schedule.export_format) + schedule.filters = request.data.get('filters', schedule.filters) + schedule.recipients = recipients + if 'is_active' in request.data: + schedule.is_active = bool(request.data.get('is_active')) + schedule.save() + return Response(_serialize_report_schedule(schedule), status=status.HTTP_200_OK) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def delete_placement_statistics_api(request, record_id): + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can delete placement statistics.'}, + status=status.HTTP_403_FORBIDDEN, + ) + PlacementRecord.objects.filter(pk=record_id).delete() + return Response({'message': 'Record deleted successfully.'}, status=status.HTTP_200_OK) + + +def _serialize_higher_studies_record(student_record): + student = student_record.unique_id + user = student.id.user + record = student_record.record_id + return { + 'id': record.id, + 'student_record_id': student_record.id, + 'roll_no': student.id.id, + 'student_name': '{} {}'.format( + user.first_name, + user.last_name, + ).strip() or user.username, + 'university': record.name, + 'test_type': record.test_type, + 'test_score': record.test_score, + 'year': record.year, + 'department': student.id.department.name if student.id.department else '', + } + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def higher_studies_api(request): + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can access higher studies records.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + if request.method == 'GET': + records = StudentRecord.objects.select_related( + 'record_id', + 'unique_id__id__user', + 'unique_id__id__department', + ).filter(record_id__placement_type='HIGHER STUDIES') + + roll_no = request.GET.get('roll_no') + if roll_no: + records = records.filter(unique_id__id__id__iexact=roll_no) + university = request.GET.get('university') + if university: + records = records.filter(record_id__name__icontains=university) + test_type = request.GET.get('test_type') + if test_type: + records = records.filter(record_id__test_type__icontains=test_type) + year = request.GET.get('year') + if year not in [None, '']: + records = records.filter(record_id__year=year) + + data = [ + _serialize_higher_studies_record(item) + for item in records.order_by('-record_id__year', '-record_id__id') + ] + return Response(data, status=status.HTTP_200_OK) + + roll_no = request.data.get('roll_no') or request.data.get('roll') + student = get_object_or_404(Student.objects.select_related('id__user', 'id__department'), pk=roll_no) + record = PlacementRecord.objects.create( + placement_type='HIGHER STUDIES', + name=request.data.get('university') or request.data.get('company_name') or request.data.get('name') or '', + ctc=0, + year=int(request.data.get('year') or timezone.now().year), + test_type=request.data.get('test_type') or '', + test_score=int(request.data.get('test_score') or 0), + ) + student_record, _ = StudentRecord.objects.get_or_create(record_id=record, unique_id=student) + return Response(_serialize_higher_studies_record(student_record), status=status.HTTP_201_CREATED) + + +@api_view(['PUT', 'DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def higher_studies_detail_api(request, record_id): + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can modify higher studies records.'}, + status=status.HTTP_403_FORBIDDEN, + ) + record = get_object_or_404(PlacementRecord, pk=record_id, placement_type='HIGHER STUDIES') + + if request.method == 'DELETE': + record.delete() + return Response({'message': 'Higher studies record deleted successfully.'}, status=status.HTTP_200_OK) + + record.name = request.data.get('university') or request.data.get('company_name') or record.name + record.test_type = request.data.get('test_type') or record.test_type + if request.data.get('test_score') not in [None, '']: + record.test_score = int(request.data.get('test_score')) + if request.data.get('year') not in [None, '']: + record.year = int(request.data.get('year')) + record.save() + student_record = get_object_or_404( + StudentRecord.objects.select_related('record_id', 'unique_id__id__user', 'unique_id__id__department'), + record_id=record, + ) + return Response(_serialize_higher_studies_record(student_record), status=status.HTTP_200_OK) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def registration_api(request): + if request.method == 'POST' and not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can create company registrations.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + if request.method == 'GET': + companies = CompanyDetails.objects.all().order_by('company_name') + data = [{ + 'id': company.id, + 'companyName': company.company_name, + 'description': company.description, + 'address': company.address, + 'website': company.website, + 'logo': company.logo.url if company.logo else None, + } for company in companies] + return Response(data, status=status.HTTP_200_OK) + + company = CompanyDetails.objects.create( + company_name=request.data.get('companyName') or request.data.get('company_name') or '', + description=request.data.get('description') or '', + address=request.data.get('address') or '', + website=request.data.get('website') or '', + logo=request.FILES.get('logo'), + ) + return Response({ + 'id': company.id, + 'companyName': company.company_name, + 'description': company.description, + 'address': company.address, + 'website': company.website, + 'logo': company.logo.url if company.logo else None, + }, status=status.HTTP_200_OK) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_fields_api(request): + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can manage placement fields.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + if request.method == 'GET': + fields = PlacementField.objects.all().order_by('name') + data = [{ + 'id': field.id, + 'name': field.name, + 'type': field.type, + 'required': field.required, + } for field in fields] + return Response(data, status=status.HTTP_200_OK) + + field = PlacementField.objects.create( + name=request.data.get('name') or '', + type=request.data.get('type') or 'text', + required=bool(request.data.get('required')), + ) + return Response({ + 'id': field.id, + 'name': field.name, + 'type': field.type, + 'required': field.required, + }, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def form_fields_api(request): + job_id = request.GET.get('jobId') + schedule = PlacementSchedule.objects.filter(pk=job_id).first() if job_id else None + queryset = schedule.fields.all() if schedule and schedule.fields.exists() else PlacementField.objects.all() + data = [{ + 'field_id': field.id, + 'id': field.id, + 'name': field.name, + 'type': field.type, + 'required': field.required, + } for field in queryset.order_by('name')] + return Response(data, status=status.HTTP_200_OK) + + +@api_view(['GET', 'POST', 'PUT']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_profile_api(request): + student = selectors.get_student_for_user(request.user) + + if request.method == 'POST': + file_obj = request.FILES.get('document') + if not file_obj: + return Response( + {'document': ['Please choose a document to upload.']}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + _validate_profile_document(file_obj) + except ValidationError as exc: + return Response({'document': exc.messages}, status=status.HTTP_400_BAD_REQUEST) + document = PlacementProfileDocument.objects.create( + student=student, + name=request.data.get('name') or file_obj.name, + document=file_obj, + ) + _log_profile_action( + student, + request.user, + 'document_uploaded', + {'name': document.name}, + ) + return Response(_serialize_profile_document(document, request=request), status=status.HTTP_201_CREATED) + + if request.method == 'PUT': + current_data = _serialize_profile(student) + incoming_data = _sanitize_profile_payload(request.data) + updated_data = dict(current_data) + updated_data.update(incoming_data) + field_errors = _profile_form_errors(updated_data) + document_file = request.FILES.get('document') + if document_file: + try: + _validate_profile_document(document_file) + except ValidationError as exc: + field_errors['document'] = exc.messages + if field_errors: + return Response( + { + 'detail': 'Placement profile could not be saved.', + 'field_errors': field_errors, + 'errors': _flatten_profile_errors(field_errors), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + profile = student.id + changed_fields = {} + for key in ['first_name', 'last_name', 'email']: + previous_value = getattr(profile.user, key) or '' + new_value = updated_data[key] + if previous_value != new_value: + changed_fields[key] = {'from': previous_value, 'to': new_value} + setattr(profile.user, key, new_value) + profile.user.save() + + profile_mapping = { + 'phone_no': int(updated_data['phone_no']), + 'address': updated_data['address'], + 'about_me': updated_data['about_me'], + } + for key, new_value in profile_mapping.items(): + previous_value = getattr(profile, key) + previous_text = '' if previous_value is None else str(previous_value) + current_text = str(new_value) + if previous_text != current_text: + changed_fields[key] = {'from': previous_text, 'to': current_text} + setattr(profile, key, new_value) + profile.save() + + if changed_fields: + _log_profile_action( + student, + request.user, + 'profile_updated', + {'changed_fields': changed_fields}, + ) + + if document_file: + document = PlacementProfileDocument.objects.create( + student=student, + name=request.data.get('name') or document_file.name, + document=document_file, + ) + _log_profile_action( + student, + request.user, + 'document_uploaded', + {'name': document.name}, + ) + + if changed_fields or document_file: + _send_placement_notifications( + actor=request.user, + recipients=[request.user], + description='Your placement profile has been updated.', + ) + + preferences = _ensure_notification_preferences(student) + documents = PlacementProfileDocument.objects.filter(student=student) + logs = PlacementProfileAuditLog.objects.filter(student=student)[:25] + field_errors = _profile_completion_errors(student) + validation_errors = _flatten_profile_errors(field_errors) + return Response({ + 'is_complete': len(validation_errors) == 0, + 'profile': _serialize_profile(student), + 'eligibility_summary': _serialize_profile_eligibility_summary(student), + 'field_errors': field_errors, + 'validation_errors': validation_errors, + 'documents': [_serialize_profile_document(item, request=request) for item in documents], + 'audit_logs': [_serialize_audit_log(item) for item in logs], + 'preferences': { + 'portal': preferences.enable_portal, + 'email': preferences.enable_email, + 'sms': preferences.enable_sms, + }, + }, status=status.HTTP_200_OK) + + +@api_view(['GET', 'PUT']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def notification_preferences_api(request): + student = selectors.get_student_for_user(request.user) + preferences = _ensure_notification_preferences(student) + + if request.method == 'PUT': + preferences.enable_portal = bool(request.data.get('portal', preferences.enable_portal)) + preferences.enable_email = bool(request.data.get('email', preferences.enable_email)) + preferences.enable_sms = bool(request.data.get('sms', preferences.enable_sms)) + preferences.save() + _log_profile_action( + student, + request.user, + 'notification_preferences_updated', + { + 'portal': preferences.enable_portal, + 'email': preferences.enable_email, + 'sms': preferences.enable_sms, + }, + ) + + return Response({ + 'portal': preferences.enable_portal, + 'email': preferences.enable_email, + 'sms': preferences.enable_sms, + }, status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def apply_for_placement_api(request): + student = selectors.get_student_for_user(request.user) + schedule = get_object_or_404(PlacementSchedule, pk=request.data.get('jobId')) + requested_action = ( + request.data.get('invitation') or + request.data.get('status') or + 'ACCEPTED' + ) + requested_action = str(requested_action).upper() + is_decline = requested_action in ['REJECT', 'REJECTED', 'DECLINE', 'DECLINED'] + student_placement = _ensure_studentplacement(student) + if student_placement.debar == 'DEBAR': + return Response( + {'detail': 'Debarred students are not eligible to apply for placement activities.'}, + status=status.HTTP_403_FORBIDDEN, + ) + profile_errors = _profile_validation_errors(student) + if profile_errors: + return Response( + {'detail': 'Placement profile is incomplete.', 'errors': profile_errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + eligibility = _schedule_eligibility(schedule, student) + if not eligibility['eligible']: + return Response( + {'detail': 'You are not eligible for this job posting.', 'errors': eligibility['reasons']}, + status=status.HTTP_403_FORBIDDEN, + ) + + if is_decline: + placement_status, _ = PlacementStatus.objects.get_or_create( + notify_id=schedule.notify_id, + unique_id=student, + ) + placement_status.invitation = 'REJECTED' + placement_status.timestamp = timezone.now() + placement_status.no_of_days = 2 + placement_status.save() + officer_recipients = User.objects.filter( + current_designation__designation__name__in=['placement officer', 'placement chairman'], + ).distinct() + _send_placement_notifications( + actor=request.user, + recipients=officer_recipients, + description='{} declined the offer for {}.'.format(student.id.id, schedule.notify_id.company_name), + ) + return Response({'message': 'Invitation declined successfully.'}, status=status.HTTP_200_OK) + + application_limit = _max_active_application_limit() + warning_threshold = max(application_limit - 2, 1) + warning_message = None + with transaction.atomic(): + # Lock this student's placement row so concurrent applies can't both pass the cap. + StudentPlacement.objects.select_for_update().filter(pk=student_placement.pk).first() + active_application_count = PlacementApplication.objects.filter( + student=student, + ).exclude(status='withdrawn').count() + if active_application_count >= application_limit: + return Response( + { + 'detail': 'You can only have {} active applications at a time.'.format( + application_limit, + ), + }, + status=status.HTTP_403_FORBIDDEN, + ) + elif active_application_count >= warning_threshold: + warning_message = 'You have {} active applications. The limit is {}.'.format( + active_application_count, + application_limit, + ) + + application, created = PlacementApplication.objects.get_or_create( + schedule=schedule, + student=student, + defaults={'status': 'pending'}, + ) + if not created: + if application.status == 'withdrawn': + return Response( + {'detail': 'This application was withdrawn and cannot be submitted again.'}, + status=status.HTTP_409_CONFLICT, + ) + return Response( + {'detail': 'You have already applied for this job.'}, + status=status.HTTP_409_CONFLICT, + ) + + responses = request.data.get('responses') or [] + if responses: + PlacementApplicationResponse.objects.filter(application=application).delete() + for item in responses: + field = PlacementField.objects.filter(id=item.get('field_id')).first() + PlacementApplicationResponse.objects.create( + application=application, + field=field, + value=str(item.get('value') or ''), + ) + _log_profile_action( + student, + request.user, + 'application_submitted', + {'job_id': schedule.id, 'company_name': schedule.notify_id.company_name}, + ) + response_payload = {'message': 'Application submitted successfully.'} + if warning_message: + response_payload['warning'] = warning_message + return Response(response_payload, status=status.HTTP_200_OK) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def withdraw_application_api(request, schedule_id): + student = selectors.get_student_for_user(request.user) + application = get_object_or_404( + PlacementApplication.objects.select_related('schedule__notify_id'), + schedule_id=schedule_id, + student=student, + ) + if application.status == 'withdrawn': + return Response({'detail': 'Application already withdrawn.'}, status=status.HTTP_409_CONFLICT) + + application.status = 'withdrawn' + application.withdrawn_at = timezone.now() + application.save(update_fields=['status', 'withdrawn_at']) + + placement_status = PlacementStatus.objects.filter( + notify_id=application.schedule.notify_id, + unique_id=student, + invitation__in=['PENDING', 'ACCEPTED'], + ).first() + if placement_status: + placement_status.invitation = 'REJECTED' + placement_status.timestamp = timezone.now() + placement_status.no_of_days = 2 + placement_status.save(update_fields=['invitation', 'timestamp', 'no_of_days']) + + officer_recipients = User.objects.filter( + current_designation__designation__name__in=['placement officer', 'placement chairman'], + ).distinct() + # Notify company if company user exists + company_users = User.objects.filter(email=application.schedule.company.company_name + '@example.com') if application.schedule.company and application.schedule.company.company_name else [] + notification_recipients = list(officer_recipients) + list(company_users) + notification_recipients.append(request.user) + _send_placement_notifications( + actor=request.user, + recipients=notification_recipients, + description='{} withdrew the application for {}.'.format( + student.id.id, + application.schedule.notify_id.company_name, + ), + ) + _log_profile_action( + student, + request.user, + 'application_withdrawn', + {'job_id': application.schedule_id, 'company_name': application.schedule.notify_id.company_name}, + ) + return Response({'message': 'Application withdrawn successfully.'}, status=status.HTTP_200_OK) + + +@api_view(['GET', 'PUT', 'DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def student_applications_api(request, identifier): + if request.method == 'PUT': + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can update application status.'}, + status=status.HTTP_403_FORBIDDEN, + ) + application = get_object_or_404(PlacementApplication, pk=identifier) + if application.status in ['accept', 'reject', 'withdrawn']: + return Response( + {'detail': 'Final application status cannot be changed.'}, + status=status.HTTP_409_CONFLICT, + ) + status_value = request.data.get('status') or 'pending' + allowed_statuses = [ + 'pending', + 'shortlisted', + 'interview_scheduled', + 'interview_completed', + 'offer_released', + 'accept', + 'reject', + ] + next_status = status_value if status_value in allowed_statuses else 'pending' + remarks = request.data.get('remarks') or '' + application.status = next_status + application.remarks = remarks + application.save(update_fields=['status', 'remarks', 'updated_at']) + student_user = application.student.id.user + if application.status == 'accept': + placement_status, _ = PlacementStatus.objects.get_or_create( + notify_id=application.schedule.notify_id, + unique_id=application.student, + ) + placement_status.invitation = 'PENDING' + placement_status.placed = 'PLACED' + placement_status.timestamp = timezone.now() + placement_status.no_of_days = 2 + placement_status.save() + _ensure_placement_record_for_selection(application) + _create_application_timeline_entry( + application, + stage='Selected', + remarks=remarks or 'Congratulations! You have been selected.', + actor=request.user, + ) + _send_placement_notifications( + actor=request.user, + recipients=[student_user], + description='You received an offer for {}. Please respond within 48 hours.'.format( + application.schedule.notify_id.company_name, + ), + ) + elif application.status == 'reject': + placement_status, _ = PlacementStatus.objects.get_or_create( + notify_id=application.schedule.notify_id, + unique_id=application.student, + ) + placement_status.invitation = 'REJECTED' + placement_status.timestamp = timezone.now() + placement_status.no_of_days = 2 + placement_status.save() + _create_application_timeline_entry( + application, + stage='Rejected', + remarks=remarks or 'Your application has been rejected.', + actor=request.user, + ) + _send_placement_notifications( + actor=request.user, + recipients=[student_user], + description='Your application for {} has been rejected.'.format( + application.schedule.notify_id.company_name, + ), + ) + elif application.status == 'offer_released': + placement_status, _ = PlacementStatus.objects.get_or_create( + notify_id=application.schedule.notify_id, + unique_id=application.student, + ) + placement_status.invitation = 'PENDING' + placement_status.timestamp = timezone.now() + placement_status.no_of_days = 2 + placement_status.save() + _create_application_timeline_entry( + application, + stage='Offer Released', + remarks=remarks or 'Offer released. Please respond from your dashboard.', + actor=request.user, + ) + _send_placement_notifications( + actor=request.user, + recipients=[student_user], + description='Offer released for {}. Please check your placement dashboard.'.format( + application.schedule.notify_id.company_name, + ), + ) + else: + _create_application_timeline_entry( + application, + stage=_application_stage_label(application.status), + remarks=remarks or 'Application status updated to {}.'.format(_application_stage_label(application.status)), + actor=request.user, + ) + _send_placement_notifications( + actor=request.user, + recipients=[student_user], + description='Your application status for {} was updated to {}.'.format( + application.schedule.notify_id.company_name, + application.status, + ), + ) + return Response({'message': 'Application status updated successfully.'}, status=status.HTTP_200_OK) + + if request.method == 'DELETE': + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can delete applications.'}, + status=status.HTTP_403_FORBIDDEN, + ) + application = get_object_or_404(PlacementApplication, pk=identifier) + student_user = application.student.id.user + company_name = application.schedule.notify_id.company_name + application.delete() + _send_placement_notifications( + actor=request.user, + recipients=[student_user], + description='Your application for {} was removed by the placement office.'.format( + company_name, + ), + ) + return Response({'message': 'Application deleted successfully.'}, status=status.HTTP_200_OK) + + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can view applicant lists.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + applications = PlacementApplication.objects.select_related( + 'student__id__user', + ).filter(schedule_id=identifier).order_by('-created_at') + students = [] + for app in applications: + students.append({ + 'id': app.id, + 'username': app.student.id.user.username, + 'name': '{} {}'.format( + app.student.id.user.first_name, + app.student.id.user.last_name, + ).strip() or app.student.id.user.username, + 'roll_no': app.student.id.id, + 'email': app.student.id.user.email, + 'cpi': app.student.cpi, + 'status': app.status, + 'applied_at': app.created_at.isoformat() if app.created_at else None, + }) + return Response({'students': students}, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def download_applications_api(request, schedule_id): + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can export applicant data.'}, + status=status.HTTP_403_FORBIDDEN, + ) + applications = PlacementApplication.objects.select_related( + 'student__id__user', + ).filter(schedule_id=schedule_id).order_by('-created_at') + response = HttpResponse(content_type='application/ms-excel') + response['Content-Disposition'] = 'attachment; filename="applications_{}.xls"'.format(schedule_id) + workbook = xlwt.Workbook(encoding='utf-8') + worksheet = workbook.add_sheet('Applications') + headers = ['Name', 'Roll No', 'Email', 'CPI', 'Status'] + header_style = xlwt.XFStyle() + header_style.font.bold = True + for index, header in enumerate(headers): + worksheet.write(0, index, header, header_style) + row_style = xlwt.XFStyle() + for row_index, application in enumerate(applications, start=1): + worksheet.write(row_index, 0, '{} {}'.format(application.student.id.user.first_name, application.student.id.user.last_name).strip() or application.student.id.user.username, row_style) + worksheet.write(row_index, 1, application.student.id.id, row_style) + worksheet.write(row_index, 2, application.student.id.user.email, row_style) + worksheet.write(row_index, 3, application.student.cpi, row_style) + worksheet.write(row_index, 4, application.status, row_style) + workbook.save(response) + return response + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def next_round_api(request, schedule_id): + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can schedule next rounds.'}, + status=status.HTTP_403_FORBIDDEN, + ) + schedule = get_object_or_404(PlacementSchedule, pk=schedule_id) + start_datetime = _parse_datetime(request.data.get('start_datetime')) + end_datetime = _parse_datetime(request.data.get('end_datetime')) + + if not start_datetime: + return Response( + {'detail': 'Interview start date and time are required.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not end_datetime: + end_datetime = start_datetime + datetime.timedelta(hours=1) + + if end_datetime <= start_datetime: + return Response( + {'detail': 'Interview end time must be after start time.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + application_ids = request.data.get('application_ids') or [] + applications = PlacementApplication.objects.select_related('student__id__user').filter( + schedule=schedule, + ).exclude(status__in=['reject', 'withdrawn', 'accept']) + if application_ids: + applications = applications.filter(id__in=application_ids) + applications = list(applications) + if not applications: + return Response( + {'detail': 'Select at least one valid candidate to schedule the next round.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + conflicts = _collect_student_schedule_conflicts( + applications=applications, + start_dt=start_datetime, + end_dt=end_datetime, + current_schedule_id=schedule.id, + ) + if conflicts: + return Response( + { + 'detail': 'Selected interview time conflicts with student placement calendar.', + 'conflicts': conflicts, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + requested_round_no = request.data.get('round_no') + if requested_round_no in [None, '']: + existing_round_no = ( + PlacementRound.objects.filter(schedule=schedule) + .aggregate(max_round=Max('round_no')) + .get('max_round') + or 0 + ) + round_no = existing_round_no + 1 + else: + round_no = int(requested_round_no) + + feedback = request.data.get('feedback') + description = ( + feedback + if feedback not in [None, ''] + else request.data.get('description') or '' + ) + + with transaction.atomic(): + round_obj = PlacementRound.objects.create( + schedule=schedule, + round_no=round_no, + test_date=start_datetime.date(), + start_datetime=start_datetime, + end_datetime=end_datetime, + mode=request.data.get('mode') or '', + location_link=request.data.get('location_link') or '', + description=description, + test_type=request.data.get('test_type') or '', + ) + applications_to_update = [] + for application in applications: + application.status = 'interview_scheduled' + application.remarks = description or application.remarks + applications_to_update.append(application) + PlacementInterviewSchedule.objects.create( + application=application, + round_no=round_no, + title=request.data.get('test_type') or 'Round {}'.format(round_no), + scheduled_at=start_datetime, + end_datetime=end_datetime, + mode=request.data.get('mode') or '', + location=request.data.get('location_link') or '', + meeting_link=request.data.get('location_link') or '', + remarks=description, + created_by=request.user, + ) + _create_application_timeline_entry( + application, + stage='Interview Scheduled', + remarks=description or 'Interview round scheduled.', + actor=request.user, + ) + PlacementApplication.objects.bulk_update(applications_to_update, ['status', 'remarks']) + + recipients = [application.student.id.user for application in applications] + if recipients: + _send_placement_notifications( + actor=request.user, + recipients=recipients, + description='Interview schedule updated for {}: {} on {}.'.format( + schedule.notify_id.company_name, + round_obj.test_type or 'Round {}'.format(round_obj.round_no), + round_obj.start_datetime.isoformat() if round_obj.start_datetime else 'TBA', + ), + ) + return Response( + { + 'id': round_obj.id, + 'scheduled_candidates': len(applications), + 'start_datetime': round_obj.start_datetime.isoformat() if round_obj.start_datetime else None, + 'end_datetime': round_obj.end_datetime.isoformat() if round_obj.end_datetime else None, + }, + status=status.HTTP_201_CREATED, + ) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def timeline_api(request, schedule_id): + rounds = list(PlacementRound.objects.filter(schedule_id=schedule_id).order_by('round_no', 'created_at')) + student_data = selectors.get_designation_queryset(request.user, "student") + application = None + if student_data: + student = selectors.get_student_for_user(request.user) + application = PlacementApplication.objects.filter(schedule_id=schedule_id, student=student).first() + + data = [] + if application: + timeline_entries = _serialize_application_timeline(application) + for index, item in enumerate(timeline_entries): + data.append({ + 'round_no': index, + 'test_name': item['stage'], + 'test_date': item['created_at'], + 'description': item['remarks'], + }) + if application.status == 'reject': + return Response({'next_data': data}, status=status.HTTP_200_OK) + if application.status == 'withdrawn': + return Response({'next_data': data}, status=status.HTTP_200_OK) + + interviews = PlacementInterviewSchedule.objects.filter(application=application).order_by('scheduled_at', 'id') + if interviews.exists(): + data.extend([{ + 'round_no': max(item.round_no, len(data)), + 'test_name': item.title or 'Round {}'.format(item.round_no), + 'test_date': item.scheduled_at.isoformat() if item.scheduled_at else None, + 'start_datetime': item.scheduled_at.isoformat() if item.scheduled_at else None, + 'end_datetime': item.end_datetime.isoformat() if item.end_datetime else None, + 'mode': item.mode, + 'location_link': item.meeting_link or item.location, + 'description': item.remarks, + 'feedback': item.remarks, + 'outcome': item.outcome, + } for item in interviews]) + return Response({'next_data': data}, status=status.HTTP_200_OK) + + if not rounds: + if not data: + data.append({ + 'round_no': 0, + 'test_name': 'Application', + 'test_date': None, + 'description': 'To be updated', + }) + return Response({'next_data': data}, status=status.HTTP_200_OK) + + data.extend([{ + 'round_no': max(item.round_no, len(data)), + 'test_name': item.test_type or 'Round {}'.format(item.round_no), + 'test_date': item.start_datetime.isoformat() if item.start_datetime else (item.test_date.isoformat() if item.test_date else None), + 'start_datetime': item.start_datetime.isoformat() if item.start_datetime else None, + 'end_datetime': item.end_datetime.isoformat() if item.end_datetime else None, + 'mode': item.mode, + 'location_link': item.location_link, + 'description': item.description, + 'feedback': item.description, + } for item in rounds]) + return Response({'next_data': data}, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def my_applications_api(request): + student = selectors.get_student_for_user(request.user) + applications = PlacementApplication.objects.select_related( + 'schedule__notify_id', + 'schedule__role', + ).prefetch_related( + Prefetch( + 'interview_schedules', + queryset=PlacementInterviewSchedule.objects.order_by('scheduled_at', 'id'), + to_attr='prefetched_interviews', + ), + ).filter(student=student).order_by('-created_at') + offer_map = { + item.notify_id_id: item + for item in PlacementStatus.objects.filter( + unique_id=student, + notify_id_id__in=[application.schedule.notify_id_id for application in applications], + ) + } + rows = [] + for application in applications: + interviews = list(getattr(application, 'prefetched_interviews', [])) + next_round = interviews[-1] if interviews else None + offer = offer_map.get(application.schedule.notify_id_id) + rows.append({ + 'application_id': application.id, + 'schedule_id': application.schedule_id, + 'company_name': application.schedule.notify_id.company_name, + 'role': application.schedule.get_role, + 'placement_type': application.schedule.notify_id.placement_type, + 'status': application.status, + 'status_label': application.status.replace('_', ' ').title(), + 'applied_at': application.created_at.isoformat() if application.created_at else None, + 'offer_id': offer.id if offer else None, + 'offer_status': offer.invitation if offer else None, + 'rounds': [ + { + 'id': item.id, + 'round_no': item.round_no, + 'title': item.title or 'Round {}'.format(item.round_no), + 'date': item.scheduled_at.isoformat() if item.scheduled_at else None, + 'description': item.remarks, + 'feedback': item.remarks, + 'outcome': item.outcome, + 'mode': item.mode, + 'location': item.meeting_link or item.location, + } + for item in interviews + ], + 'next_interview': { + 'round_no': next_round.round_no, + 'title': next_round.title or 'Round {}'.format(next_round.round_no), + 'date': next_round.scheduled_at.isoformat() if next_round.scheduled_at else None, + 'description': next_round.remarks, + 'feedback': next_round.remarks, + 'outcome': next_round.outcome, + } if next_round else None, + 'can_withdraw': application.status not in ['withdrawn', 'reject', 'accept'], + 'can_raise_appeal': application.status == 'reject', + }) + return Response({'applications': rows}, status=status.HTTP_200_OK) + + +@api_view(['GET', 'PUT']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def application_detail_api(request, application_id): + application = get_object_or_404( + PlacementApplication.objects.select_related( + 'student__id__user', + 'student__id__department', + 'schedule__notify_id', + 'schedule__role', + ).prefetch_related( + 'timeline_entries__actor', + 'interview_schedules', + ), + pk=application_id, + ) + + if request.method == 'GET': + if _is_tpo_user(request.user): + return Response(_serialize_tpo_application_detail(application, request=request), status=status.HTTP_200_OK) + + student = selectors.get_student_for_user(request.user) + if application.student_id != student.id: + return Response({'detail': 'Application not found.'}, status=status.HTTP_404_NOT_FOUND) + return Response(_serialize_tpo_application_detail(application, request=request), status=status.HTTP_200_OK) + + if not _is_tpo_user(request.user): + return Response({'detail': 'Only TPO users can update applicants.'}, status=status.HTTP_403_FORBIDDEN) + + if application.status in ['accept', 'reject', 'withdrawn']: + return Response({'detail': 'This application is already finalized and cannot be changed.'}, status=status.HTTP_409_CONFLICT) + + next_status = request.data.get('status') or application.status + remarks = request.data.get('remarks') or '' + allowed_statuses = [ + 'pending', + 'shortlisted', + 'interview_scheduled', + 'interview_completed', + 'offer_released', + 'accept', + 'reject', + ] + if next_status not in allowed_statuses: + return Response({'status': ['Invalid application status.']}, status=status.HTTP_400_BAD_REQUEST) + + application.status = next_status + application.remarks = remarks + application.save(update_fields=['status', 'remarks', 'updated_at']) + + student_user = application.student.id.user + stage_label = _application_stage_label(next_status) + _create_application_timeline_entry( + application, + stage=stage_label, + remarks=remarks or stage_label, + actor=request.user, + ) + + if next_status == 'offer_released': + placement_status, _ = PlacementStatus.objects.get_or_create( + notify_id=application.schedule.notify_id, + unique_id=application.student, + ) + placement_status.invitation = 'PENDING' + placement_status.timestamp = timezone.now() + placement_status.no_of_days = 2 + placement_status.save() + _send_placement_notifications( + actor=request.user, + recipients=[student_user], + description='You received an offer for {}. Please respond within 48 hours.'.format( + application.schedule.notify_id.company_name, + ), + ) + elif next_status == 'accept': + placement_status, _ = PlacementStatus.objects.get_or_create( + notify_id=application.schedule.notify_id, + unique_id=application.student, + ) + placement_status.invitation = 'PENDING' + placement_status.placed = 'PLACED' + placement_status.timestamp = timezone.now() + placement_status.no_of_days = 2 + placement_status.save() + _ensure_placement_record_for_selection(application) + _send_placement_notifications( + actor=request.user, + recipients=[student_user], + description='Congratulations! You have been selected for {}. Please review the offer in your dashboard.'.format( + application.schedule.notify_id.company_name, + ), + ) + elif next_status == 'reject': + _send_placement_notifications( + actor=request.user, + recipients=[student_user], + description='Your application for {} has been rejected.'.format( + application.schedule.notify_id.company_name, + ), + ) + else: + _send_placement_notifications( + actor=request.user, + recipients=[student_user], + description='Your application status for {} is now {}.'.format( + application.schedule.notify_id.company_name, + stage_label, + ), + ) + + application.refresh_from_db() + return Response(_serialize_tpo_application_detail(application, request=request), status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def application_interview_schedule_api(request, application_id): + if not _is_tpo_user(request.user): + return Response({'detail': 'Only TPO users can manage interviews.'}, status=status.HTTP_403_FORBIDDEN) + + application = get_object_or_404( + PlacementApplication.objects.select_related('student__id__user', 'schedule__notify_id'), + pk=application_id, + ) + scheduled_at = _parse_datetime(request.data.get('scheduled_at')) + if not scheduled_at: + return Response({'scheduled_at': ['Interview date and time are required.']}, status=status.HTTP_400_BAD_REQUEST) + + end_datetime = _parse_datetime(request.data.get('end_datetime')) + if not end_datetime: + end_datetime = scheduled_at + datetime.timedelta(hours=1) + + requested_round_no = request.data.get('round_no') + if requested_round_no in [None, '']: + existing_round_no = ( + PlacementInterviewSchedule.objects.filter(application=application) + .aggregate(max_round=Max('round_no')) + .get('max_round') + or 0 + ) + round_no = existing_round_no + 1 + else: + round_no = int(requested_round_no) + + feedback = request.data.get('feedback') + remarks = ( + feedback + if feedback not in [None, ''] + else request.data.get('remarks') or '' + ) + + interview = PlacementInterviewSchedule.objects.create( + application=application, + round_no=round_no, + title=request.data.get('title') or '', + scheduled_at=scheduled_at, + end_datetime=end_datetime, + mode=request.data.get('mode') or '', + location=request.data.get('location') or '', + meeting_link=request.data.get('meeting_link') or '', + remarks=remarks, + outcome=request.data.get('outcome') or 'pending', + is_active=bool(request.data.get('is_active', True)), + created_by=request.user, + ) + application.status = 'interview_scheduled' + application.remarks = remarks or application.remarks + application.save(update_fields=['status', 'remarks', 'updated_at']) + _create_application_timeline_entry( + application, + stage='Interview Scheduled', + remarks=remarks or 'Interview round scheduled.', + actor=request.user, + ) + _send_placement_notifications( + actor=request.user, + recipients=[application.student.id.user], + description='Interview scheduled for {} on {}.'.format( + application.schedule.notify_id.company_name, + interview.scheduled_at.isoformat(), + ), + ) + return Response(_serialize_application_interview(interview), status=status.HTTP_201_CREATED) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def my_offers_api(request): + student = selectors.get_student_for_user(request.user) + visible_offer_statuses = {'offer_released', 'accept'} + offers = PlacementStatus.objects.select_related('notify_id').filter( + unique_id=student, + ).order_by('-timestamp', '-id') + notify_ids = [offer.notify_id_id for offer in offers] + schedules = PlacementSchedule.objects.select_related('role', 'notify_id').filter( + notify_id_id__in=notify_ids, + ).order_by('notify_id_id', '-id') + schedule_map = {} + for schedule in schedules: + if schedule.notify_id_id not in schedule_map: + schedule_map[schedule.notify_id_id] = schedule + + applications = PlacementApplication.objects.filter( + student=student, + schedule__notify_id_id__in=notify_ids, + ).select_related('schedule__notify_id').order_by('schedule__notify_id_id', '-created_at') + application_map = {} + for application in applications: + notify_id = application.schedule.notify_id_id + if notify_id not in application_map: + application_map[notify_id] = application + + rows = [] + for offer in offers: + schedule = schedule_map.get(offer.notify_id_id) + application = application_map.get(offer.notify_id_id) + if application is None or application.status not in visible_offer_statuses: + continue + deadline = offer.response_date if offer.timestamp else None + rows.append({ + 'id': offer.id, + 'schedule_id': schedule.id if schedule else None, + 'company_name': offer.notify_id.company_name, + 'role': schedule.get_role if schedule else '', + 'ctc': str(offer.notify_id.ctc), + 'status': offer.invitation, + 'response_deadline': deadline.isoformat() if deadline else None, + 'expired': bool(deadline and timezone.now() > deadline), + }) + return Response({'offers': rows}, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def calendar_api(request): + if not request.user.is_authenticated: + return Response( + {'detail': 'Authentication credentials were not provided.'}, + status=status.HTTP_403_FORBIDDEN, + ) + rounds = PlacementRound.objects.select_related('schedule__notify_id').all().order_by('test_date') + schedule_data = [{ + 'id': item.schedule.id, + 'company_name': item.schedule.notify_id.company_name, + 'round': item.round_no, + 'date': item.start_datetime.isoformat() if item.start_datetime else (item.test_date.isoformat() if item.test_date else item.schedule.placement_date.isoformat()), + 'end_datetime': item.end_datetime.isoformat() if item.end_datetime else None, + 'description': item.description, + 'type': item.test_type, + 'mode': item.mode, + 'location_link': item.location_link, + } for item in rounds] + if not schedule_data: + schedules = PlacementSchedule.objects.select_related('notify_id').all() + schedule_data = [{ + 'id': item.id, + 'company_name': item.notify_id.company_name, + 'round': 0, + 'date': item.placement_date.isoformat(), + 'description': item.description, + 'type': item.notify_id.placement_type, + } for item in schedules] + return Response({'schedule_data': schedule_data}, status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def generate_cv_api(request): + username = request.user.username + profile = get_object_or_404(ExtraInfo, Q(user=request.user)) + student = get_object_or_404(Student, Q(id=profile.id)) + now = datetime.datetime.now() + if int(str(profile.id)[:2]) == 20: + roll = (1 + now.year - int(str(profile.id)[:4])) if now.month > 4 else (now.year - int(str(profile.id)[:4])) + else: + roll = (1 + now.year - int("20" + str(profile.id)[0:2])) if now.month > 4 else (now.year - int("20" + str(profile.id)[0:2])) + + def _flag(name): + return '1' if request.data.get(name, True) else '0' + + reference = Reference.objects.filter(unique_id=student) + profile_picture_url = profile.profile_picture.url if profile.profile_picture else '' + profile_picture_path = profile.profile_picture.path if profile.profile_picture else '' + context = { + 'pagesize': 'A4', + 'user': request.user, + 'references': reference, + 'profile': profile, + 'profile_picture': profile_picture_url, + 'profile_picture_path': profile_picture_path, + 'projects': Project.objects.filter(unique_id=student), + 'skills': Has.objects.select_related('skill_id').filter(unique_id=student), + 'educations': Education.objects.filter(unique_id=student), + 'courses': Course.objects.filter(unique_id=student), + 'experiences': Experience.objects.filter(unique_id=student), + 'referencecheck': '1' if reference.exists() and request.data.get('references', True) else '0', + 'achievements': Achievement.objects.filter(unique_id=student), + 'extracurriculars': Extracurricular.objects.filter(unique_id=student), + 'publications': Publication.objects.filter(unique_id=student), + 'patents': Patent.objects.filter(unique_id=student), + 'roll': roll, + 'achievementcheck': _flag('achievements'), + 'extracurricularcheck': _flag('extracurriculars'), + 'educationcheck': _flag('education'), + 'publicationcheck': _flag('publications'), + 'patentcheck': _flag('patents'), + 'conferencecheck': _flag('conferences'), + 'conferences': Conference.objects.filter(unique_id=student), + 'internshipcheck': _flag('experience'), + 'projectcheck': _flag('projects'), + 'coursecheck': _flag('courses'), + 'skillcheck': _flag('skills'), + 'today': datetime.date.today(), + } + pdf_response = render_to_pdf('placementModule/cv.html', context) + if isinstance(pdf_response, HttpResponse): + selected_sections = sorted( + key for key, enabled in request.data.items() if str(enabled).lower() in ['true', '1', 'yes', 'on'] + ) if hasattr(request.data, 'items') else [] + PlacementProfileAuditLog.objects.create( + student=student, + actor=request.user, + action='resume_downloaded', + details={ + 'filename': 'student_cv.pdf', + 'selected_sections': selected_sections, + }, + ) + pdf_response['Content-Disposition'] = 'attachment; filename="student_cv.pdf"' + return pdf_response + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def debarred_students_api(request): + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can access debarred students.'}, + status=status.HTTP_403_FORBIDDEN, + ) + rows = [] + records = StudentPlacement.objects.select_related('unique_id__id__user').filter(debar='DEBAR') + for item in records: + rows.append({ + 'id': item.unique_id.id.id, + 'roll_no': item.unique_id.id.id, + 'name': '{} {}'.format(item.unique_id.id.user.first_name, item.unique_id.id.user.last_name).strip() or item.unique_id.id.user.username, + 'description': item.debar_reason or '', + }) + return Response(rows, status=status.HTTP_200_OK) + + +@api_view(['GET', 'POST', 'DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def debarred_status_api(request, roll_no): + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can manage debarred status.'}, + status=status.HTTP_403_FORBIDDEN, + ) + student = get_object_or_404(Student.objects.select_related('id__user', 'id__department'), pk=roll_no) + student_placement = _ensure_studentplacement(student) + + if request.method == 'GET': + return Response({ + 'name': '{} {}'.format(student.id.user.first_name, student.id.user.last_name).strip() or student.id.user.username, + 'programme': student.programme, + 'year': student.batch, + 'department': student.id.department.name if student.id.department else '', + 'email': student.id.user.email, + 'description': student_placement.debar_reason or '', + }, status=status.HTTP_200_OK) + + if request.method == 'DELETE': + student_placement.debar = 'NOT DEBAR' + student_placement.debar_reason = '' + student_placement.save() + _send_placement_notifications( + actor=request.user, + recipients=[student.id.user], + description='Your placement debarment has been removed.', + ) + return Response({'message': 'Student un-debarred successfully.'}, status=status.HTTP_200_OK) + + student_placement.debar = 'DEBAR' + student_placement.debar_reason = request.data.get('reason') or '' + student_placement.save() + _send_placement_notifications( + actor=request.user, + recipients=[student.id.user], + description='You have been debarred from placement activities. {}'.format(student_placement.debar_reason).strip(), + ) + return Response({'message': 'Student debarred successfully.'}, status=status.HTTP_200_OK) + + +def _serialize_restriction(restriction): + return { + 'id': restriction.id, + 'criteria': restriction.criteria, + 'condition': restriction.condition, + 'value': restriction.value, + 'description': restriction.description, + } + + +def _serialize_policy(policy): + return { + 'id': policy.id, + 'title': policy.title, + 'description': policy.description, + 'created_by': policy.created_by.get_full_name().strip() or policy.created_by.username if policy.created_by else '', + 'created_at': policy.created_at.isoformat() if policy.created_at else None, + 'updated_at': policy.updated_at.isoformat() if policy.updated_at else None, + } + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def restrictions_api(request): + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can manage placement restrictions.'}, + status=status.HTTP_403_FORBIDDEN, + ) + if request.method == 'GET': + data = [_serialize_restriction(item) for item in PlacementRestriction.objects.all().order_by('-id')] + return Response(data, status=status.HTTP_200_OK) + + restriction = PlacementRestriction.objects.create( + criteria=request.data.get('criteria') or '', + condition=request.data.get('condition') or '', + value=request.data.get('value') or '', + description=request.data.get('description') or '', + ) + return Response(_serialize_restriction(restriction), status=status.HTTP_201_CREATED) + + +@api_view(['PUT', 'DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def restriction_detail_api(request, restriction_id): + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can manage placement restrictions.'}, + status=status.HTTP_403_FORBIDDEN, + ) + restriction = get_object_or_404(PlacementRestriction, pk=restriction_id) + + if request.method == 'DELETE': + restriction.delete() + return Response({'message': 'Restriction deleted successfully.'}, status=status.HTTP_200_OK) + + restriction.criteria = request.data.get('criteria') or restriction.criteria + restriction.condition = request.data.get('condition') or restriction.condition + restriction.value = request.data.get('value') or restriction.value + restriction.description = request.data.get('description') or '' + restriction.save() + return Response(_serialize_restriction(restriction), status=status.HTTP_200_OK) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_policies_api(request): + if not selectors.get_designation_queryset(request.user, "placement chairman").exists(): + return Response( + {'detail': 'Only placement chairman users can manage placement policies.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + if request.method == 'GET': + data = [_serialize_policy(item) for item in PlacementPolicy.objects.all()] + return Response(data, status=status.HTTP_200_OK) + + title = (request.data.get('title') or '').strip() + description = (request.data.get('description') or '').strip() + + errors = {} + if not title: + errors['title'] = ['This field is required.'] + if not description: + errors['description'] = ['This field is required.'] + if errors: + return Response(errors, status=status.HTTP_400_BAD_REQUEST) + + policy = PlacementPolicy.objects.create( + title=title, + description=description, + created_by=request.user, + ) + return Response(_serialize_policy(policy), status=status.HTTP_201_CREATED) + + +@api_view(['PUT']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_policy_detail_api(request, policy_id): + if not selectors.get_designation_queryset(request.user, "placement chairman").exists(): + return Response( + {'detail': 'Only placement chairman users can manage placement policies.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + policy = get_object_or_404(PlacementPolicy, pk=policy_id) + title = (request.data.get('title') or '').strip() + description = (request.data.get('description') or '').strip() + + errors = {} + if not title: + errors['title'] = ['This field is required.'] + if not description: + errors['description'] = ['This field is required.'] + if errors: + return Response(errors, status=status.HTTP_400_BAD_REQUEST) + + policy.title = title + policy.description = description + policy.save(update_fields=['title', 'description', 'updated_at']) + return Response(_serialize_policy(policy), status=status.HTTP_200_OK) + + +def _ensure_alumni_designation(user): + designation, _ = Designation.objects.get_or_create( + name='alumni', + defaults={'full_name': 'Alumni', 'type': 'administrative'}, + ) + HoldsDesignation.objects.get_or_create( + user=user, + working=user, + designation=designation, + ) + + +def _is_tpo_user(user): + return selectors.is_tpo(user) + + +def _max_active_application_limit(): + try: + return max(int(getattr(settings, 'PLACEMENT_MAX_ACTIVE_APPLICATIONS', 10)), 1) + except (TypeError, ValueError): + return 10 + + +def _serialize_alumni_profile(profile): + extra = ExtraInfo.objects.filter(user=profile.user).select_related('department').first() + return { + 'id': profile.id, + 'username': profile.user.username, + 'full_name': profile.user.get_full_name().strip() or profile.user.username, + 'email': profile.user.email, + 'graduation_year': profile.graduation_year, + 'degree': profile.degree, + 'current_company': profile.current_company, + 'current_designation': profile.current_designation, + 'linkedin_url': profile.linkedin_url, + 'verification_document': profile.verification_document.url if profile.verification_document else None, + 'verification_notes': profile.verification_notes, + 'status': profile.status, + 'topics': [item.strip() for item in (profile.topics or '').split(',') if item.strip()], + 'availability': profile.availability, + 'bio': profile.bio, + 'mentorship_enabled': profile.mentorship_enabled, + 'department': extra.department.name if extra and extra.department else '', + 'approved_at': profile.approved_at.isoformat() if profile.approved_at else None, + } + + +def _serialize_referral(referral): + return { + 'id': referral.id, + 'title': referral.title, + 'company': referral.company, + 'location': referral.location, + 'application_url': referral.application_url, + 'description': referral.description, + 'expires_at': referral.expires_at.isoformat() if referral.expires_at else None, + 'created_at': referral.created_at.isoformat() if referral.created_at else None, + 'alumni': _serialize_alumni_profile(referral.alumni), + } + + +def _serialize_connection(connection): + return { + 'id': connection.id, + 'status': connection.status, + 'message': connection.message, + 'created_at': connection.created_at.isoformat() if connection.created_at else None, + 'responded_at': connection.responded_at.isoformat() if connection.responded_at else None, + 'student': { + 'roll_no': connection.student.id.id, + 'name': connection.student.id.user.get_full_name().strip() or connection.student.id.user.username, + 'email': connection.student.id.user.email, + }, + 'alumni': _serialize_alumni_profile(connection.alumni), + } + + +def _serialize_session(session): + return { + 'id': session.id, + 'topic': session.topic, + 'agenda': session.agenda, + 'scheduled_at': session.scheduled_at.isoformat() if session.scheduled_at else None, + 'mode': session.mode, + 'meeting_link': session.meeting_link, + 'student_message': session.student_message, + 'alumni_message': session.alumni_message, + 'status': session.status, + 'student': { + 'roll_no': session.student.id.id, + 'name': session.student.id.user.get_full_name().strip() or session.student.id.user.username, + 'email': session.student.id.user.email, + }, + 'alumni': _serialize_alumni_profile(session.alumni), + } + + +@api_view(['GET', 'POST', 'PUT']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def alumni_profile_api(request): + profile = AlumniProfile.objects.filter(user=request.user).first() + + if request.method == 'GET': + return Response({ + 'profile': _serialize_alumni_profile(profile) if profile else None, + 'can_access': bool(profile and profile.status == 'approved'), + 'is_tpo': _is_tpo_user(request.user), + }, status=status.HTTP_200_OK) + + data = request.data + if profile is None: + graduation_year = data.get('graduation_year') + if not graduation_year: + return Response({'graduation_year': ['This field is required.']}, status=status.HTTP_400_BAD_REQUEST) + profile = AlumniProfile.objects.create( + user=request.user, + graduation_year=int(graduation_year), + degree=data.get('degree') or '', + current_company=data.get('current_company') or '', + current_designation=data.get('current_designation') or '', + linkedin_url=data.get('linkedin_url') or '', + verification_document=request.FILES.get('verification_document'), + bio=data.get('bio') or '', + topics=data.get('topics') or '', + availability=data.get('availability') or '', + mentorship_enabled=str(data.get('mentorship_enabled', '')).lower() in ['true', '1', 'yes', 'on'], + verification_notes=data.get('verification_notes') or '', + status='pending', + ) + officer_recipients = User.objects.filter( + current_designation__designation__name__in=['placement officer', 'placement chairman'], + ).distinct() + _send_placement_notifications( + actor=request.user, + recipients=officer_recipients, + description='New alumni verification request submitted by {}.'.format(request.user.username), + ) + return Response(_serialize_alumni_profile(profile), status=status.HTTP_201_CREATED) + + if profile.status == 'approved' or _is_tpo_user(request.user): + profile.degree = data.get('degree', profile.degree) + profile.current_company = data.get('current_company', profile.current_company) + profile.current_designation = data.get('current_designation', profile.current_designation) + profile.linkedin_url = data.get('linkedin_url', profile.linkedin_url) + profile.bio = data.get('bio', profile.bio) + profile.topics = data.get('topics', profile.topics) + profile.availability = data.get('availability', profile.availability) + if 'mentorship_enabled' in data: + profile.mentorship_enabled = str(data.get('mentorship_enabled')).lower() in ['true', '1', 'yes', 'on'] + if request.FILES.get('verification_document'): + profile.verification_document = request.FILES.get('verification_document') + profile.status = 'pending' + profile.save() + return Response(_serialize_alumni_profile(profile), status=status.HTTP_200_OK) + + return Response( + {'detail': 'Your alumni registration is awaiting approval.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def alumni_directory_api(request): + profiles = AlumniProfile.objects.filter(status='approved').order_by('-approved_at', '-id') + if request.GET.get('mentors_only') in ['true', '1']: + profiles = profiles.filter(mentorship_enabled=True) + query = (request.GET.get('query') or '').strip() + if query: + profiles = profiles.filter( + Q(user__username__icontains=query) | + Q(user__first_name__icontains=query) | + Q(user__last_name__icontains=query) | + Q(current_company__icontains=query) | + Q(topics__icontains=query) + ) + return Response([_serialize_alumni_profile(item) for item in profiles], status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def alumni_verification_list_api(request): + if not _is_tpo_user(request.user): + return Response({'detail': 'Only TPO users can access this queue.'}, status=status.HTTP_403_FORBIDDEN) + profiles = AlumniProfile.objects.select_related('user').all().order_by('status', '-created_at') + return Response([_serialize_alumni_profile(item) for item in profiles], status=status.HTTP_200_OK) + + +@api_view(['PUT']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def alumni_verification_detail_api(request, profile_id): + if not _is_tpo_user(request.user): + return Response({'detail': 'Only TPO users can verify alumni.'}, status=status.HTTP_403_FORBIDDEN) + profile = get_object_or_404(AlumniProfile, pk=profile_id) + decision = str(request.data.get('status') or '').lower() + if decision not in ['approved', 'rejected', 'pending']: + return Response({'status': ['Invalid verification status.']}, status=status.HTTP_400_BAD_REQUEST) + profile.status = decision + profile.verification_notes = request.data.get('verification_notes', profile.verification_notes) + profile.approved_by = request.user if decision == 'approved' else None + profile.approved_at = timezone.now() if decision == 'approved' else None + profile.save() + if decision == 'approved': + _ensure_alumni_designation(profile.user) + _send_placement_notifications( + actor=request.user, + recipients=[profile.user], + description='Your alumni verification request has been {}.'.format(decision), + ) + return Response(_serialize_alumni_profile(profile), status=status.HTTP_200_OK) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def alumni_referrals_api(request): + if request.method == 'GET': + queryset = AlumniReferral.objects.select_related('alumni__user').all() + queryset = queryset.filter(Q(expires_at__isnull=True) | Q(expires_at__gte=_today())) + return Response([_serialize_referral(item) for item in queryset], status=status.HTTP_200_OK) + + profile = get_object_or_404(AlumniProfile, user=request.user) + if profile.status != 'approved': + return Response({'detail': 'Approved alumni access is required.'}, status=status.HTTP_403_FORBIDDEN) + referral = AlumniReferral.objects.create( + alumni=profile, + title=request.data.get('title') or '', + company=request.data.get('company') or '', + location=request.data.get('location') or '', + application_url=request.data.get('application_url') or '', + description=request.data.get('description') or '', + expires_at=_parse_date(request.data.get('expires_at')), + ) + recipients = User.objects.filter(current_designation__designation__name='student').distinct() + _send_placement_notifications( + actor=request.user, + recipients=recipients, + description='New alumni job referral posted: {} at {}.'.format(referral.title, referral.company), + ) + return Response(_serialize_referral(referral), status=status.HTTP_201_CREATED) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def alumni_connections_api(request): + if request.method == 'GET': + if selectors.is_student(request.user): + student = selectors.get_student_for_user(request.user) + queryset = AlumniConnection.objects.select_related('alumni__user', 'student__id__user').filter(student=student) + else: + profile = get_object_or_404(AlumniProfile, user=request.user) + queryset = AlumniConnection.objects.select_related('alumni__user', 'student__id__user').filter(alumni=profile) + return Response([_serialize_connection(item) for item in queryset], status=status.HTTP_200_OK) + + if not selectors.is_student(request.user): + return Response({'detail': 'Only students can initiate alumni connections.'}, status=status.HTTP_403_FORBIDDEN) + student = selectors.get_student_for_user(request.user) + alumni = get_object_or_404(AlumniProfile, pk=request.data.get('alumni_id'), status='approved') + connection, created = AlumniConnection.objects.get_or_create( + alumni=alumni, + student=student, + defaults={'message': request.data.get('message') or ''}, + ) + if not created: + return Response({'detail': 'A connection request already exists.'}, status=status.HTTP_409_CONFLICT) + _send_placement_notifications( + actor=request.user, + recipients=[alumni.user], + description='{} requested to connect with you.'.format(student.id.id), + ) + return Response(_serialize_connection(connection), status=status.HTTP_201_CREATED) + + +@api_view(['PUT']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def alumni_connection_detail_api(request, connection_id): + connection = get_object_or_404(AlumniConnection.objects.select_related('alumni__user', 'student__id__user'), pk=connection_id) + if connection.alumni.user != request.user and not _is_tpo_user(request.user): + return Response({'detail': 'You cannot update this connection.'}, status=status.HTTP_403_FORBIDDEN) + next_status = str(request.data.get('status') or '').lower() + if next_status not in ['connected', 'rejected', 'pending']: + return Response({'status': ['Invalid connection status.']}, status=status.HTTP_400_BAD_REQUEST) + connection.status = next_status + connection.responded_by = request.user + connection.responded_at = timezone.now() + connection.save() + _send_placement_notifications( + actor=request.user, + recipients=[connection.student.id.user], + description='Your alumni connection request has been {}.'.format(next_status), + ) + return Response(_serialize_connection(connection), status=status.HTTP_200_OK) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def alumni_sessions_api(request): + if request.method == 'GET': + if selectors.is_student(request.user): + student = selectors.get_student_for_user(request.user) + queryset = AlumniMentorshipSession.objects.select_related('alumni__user', 'student__id__user').filter(student=student) + else: + profile = get_object_or_404(AlumniProfile, user=request.user) + queryset = AlumniMentorshipSession.objects.select_related('alumni__user', 'student__id__user').filter(alumni=profile) + return Response([_serialize_session(item) for item in queryset], status=status.HTTP_200_OK) + + if not selectors.is_student(request.user): + return Response({'detail': 'Only students can request mentorship sessions.'}, status=status.HTTP_403_FORBIDDEN) + student = selectors.get_student_for_user(request.user) + alumni = get_object_or_404(AlumniProfile, pk=request.data.get('alumni_id'), status='approved', mentorship_enabled=True) + session = AlumniMentorshipSession.objects.create( + alumni=alumni, + student=student, + topic=request.data.get('topic') or '', + agenda=request.data.get('agenda') or '', + scheduled_at=_parse_datetime(request.data.get('scheduled_at')) or timezone.now(), + mode=request.data.get('mode') or 'online', + student_message=request.data.get('student_message') or '', + ) + _send_placement_notifications( + actor=request.user, + recipients=[alumni.user], + description='New mentorship session request from {} on {}.'.format(student.id.id, session.topic), + ) + return Response(_serialize_session(session), status=status.HTTP_201_CREATED) + + +@api_view(['PUT']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def alumni_session_detail_api(request, session_id): + session = get_object_or_404(AlumniMentorshipSession.objects.select_related('alumni__user', 'student__id__user'), pk=session_id) + if session.alumni.user != request.user and session.student.id.user != request.user and not _is_tpo_user(request.user): + return Response({'detail': 'You cannot update this session.'}, status=status.HTTP_403_FORBIDDEN) + if session.alumni.user == request.user or _is_tpo_user(request.user): + session.status = request.data.get('status', session.status) + session.alumni_message = request.data.get('alumni_message', session.alumni_message) + session.meeting_link = request.data.get('meeting_link', session.meeting_link) + session.mode = request.data.get('mode', session.mode) + parsed_dt = _parse_datetime(request.data.get('scheduled_at')) + if parsed_dt: + session.scheduled_at = parsed_dt + if session.student.id.user == request.user: + session.student_message = request.data.get('student_message', session.student_message) + session.save() + recipients = [session.alumni.user, session.student.id.user] + _send_placement_notifications( + actor=request.user, + recipients=recipients, + description='Mentorship session "{}" has been updated.'.format(session.topic), + ) + return Response(_serialize_session(session), status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def send_notification_api(request): + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can send placement notifications.'}, + status=status.HTTP_403_FORBIDDEN, + ) + send_to = request.data.get('sendTo') + recipient = request.data.get('recipient') + description = request.data.get('description') or request.data.get('type') or 'Placement Cell notification' + + if send_to == 'All': + recipients = User.objects.filter(extrainfo__user_type='student') + else: + target_user = User.objects.filter(username=recipient).first() + if target_user is None: + target_user = User.objects.filter(extrainfo__id=recipient).first() + if target_user is None: + return Response( + {'recipient': ['No user found for the supplied recipient.']}, + status=status.HTTP_404_NOT_FOUND, + ) + recipients = [target_user] + + _send_placement_notifications( + actor=request.user, + recipients=recipients, + description=description, + ) + + return Response({'message': 'Notification sent successfully.'}, status=status.HTTP_200_OK) + + +# --- Placement Announcements API --- +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_announcements_api(request): + """List placement announcements (any authenticated user) or post one (TPO only).""" + if request.method == 'GET': + announcements = PlacementAnnouncement.objects.all() + return Response(PlacementAnnouncementSerializer(announcements, many=True).data) + + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can post announcements.'}, + status=status.HTTP_403_FORBIDDEN, + ) + serializer = PlacementAnnouncementWriteSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + announcement = PlacementAnnouncement.objects.create( + posted_by=request.user, **serializer.validated_data + ) + return Response( + PlacementAnnouncementSerializer(announcement).data, + status=status.HTTP_201_CREATED, + ) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_announcement_detail_api(request, announcement_id): + """Delete a placement announcement (TPO only).""" + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can delete announcements.'}, + status=status.HTTP_403_FORBIDDEN, + ) + PlacementAnnouncement.objects.filter(pk=announcement_id).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +# --- Off-Campus Placements API --- +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def offcampus_placements_api(request): + """List off-campus placement records or record a new one against a roll number (TPO only).""" + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can manage off-campus placements.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + if request.method == 'GET': + placements = OffCampusPlacement.objects.select_related('student__user').all() + return Response(OffCampusPlacementSerializer(placements, many=True).data) + + roll_no = str(request.data.get('roll_no', '')).strip() + if not roll_no: + return Response({'detail': 'roll_no is required.'}, status=status.HTTP_400_BAD_REQUEST) + student = ExtraInfo.objects.filter(user__username=roll_no).select_related('user').first() + if not student: + return Response( + {'detail': 'No student found with roll number {}.'.format(roll_no)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + payload = {key: value for key, value in request.data.items() if key != 'roll_no'} + payload['student'] = student.pk + serializer = OffCampusPlacementWriteSerializer(data=payload) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + placement = serializer.save(added_by=request.user) + return Response( + OffCampusPlacementSerializer(placement).data, + status=status.HTTP_201_CREATED, + ) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def offcampus_placement_detail_api(request, placement_id): + """Delete an off-campus placement record (TPO only).""" + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can manage off-campus placements.'}, + status=status.HTTP_403_FORBIDDEN, + ) + OffCampusPlacement.objects.filter(pk=placement_id).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +# --- Published-CPI student view + export API --- +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_cpi_batches_api(request): + """List batches that have an announced result (for the CPI batch filter).""" + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO and chairman users can view published CPI data.'}, + status=status.HTTP_403_FORBIDDEN, + ) + batches = selectors.batches_with_published_results() + return Response( + [ + {'id': batch.id, 'label': str(batch), 'year': batch.year} + for batch in batches + ] + ) + + +def _published_cpi_rows(batch_id): + """Build per-student published-CPI rows for a batch (empty if not published). + """ + from django.core.cache import cache + from applications.examination.models import ResultAnnouncement + from applications.examination.api.views import calculate_cpi_for_student + + latest = ( + ResultAnnouncement.objects + .filter(batch_id=batch_id, announced=True) + .order_by('-semester') + .first() + ) + if latest is None: + return [] + + students = list( + Student.objects.filter(batch_id=batch_id).select_related('id__user') + ) + if not students: + return [] + + extra_pks = [student.id_id for student in students] + offcampus_map = {} + for ocp in OffCampusPlacement.objects.filter(student_id__in=extra_pks): + offcampus_map.setdefault(ocp.student_id, []).append(ocp.company_name) + + semester = latest.semester + semester_type = latest.semester_type + # Keep cache keys free of spaces/colons so they stay valid on memcached too. + semester_slug = (semester_type or 'na').replace(' ', '-') + key_by_pk = { + student.pk: 'pc-cpi-v1-{}-{}-{}'.format( + student.id.user.username, semester, semester_slug + ) + for student in students + } + cached = cache.get_many(list(key_by_pk.values())) + + to_cache = {} + rows = [] + for student in students: + extra = student.id # ExtraInfo + key = key_by_pk[student.pk] + if key in cached: + cpi = cached[key] + else: + try: + cpi_value, _, _ = calculate_cpi_for_student( + student, semester, semester_type + ) + except Exception: + cpi_value = None + cpi = str(cpi_value) if cpi_value is not None else None + if cpi is not None: + to_cache[key] = cpi + if cpi is None: + continue + rows.append( + { + 'roll_no': extra.user.username, + 'student_name': '{} {}'.format( + extra.user.first_name, extra.user.last_name + ).strip(), + 'email': extra.user.email, + 'cpi': cpi, + 'off_campus': offcampus_map.get(extra.pk, []), + } + ) + if to_cache: + cache.set_many(to_cache, 60 * 60) + return rows + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_cpi_students_api(request): + """Students of a batch with their published CPI and off-campus companies. + + Requires ``?batch_id=``; without it an empty list is returned. Pass + ``?export=excel`` to download the same rows as an ``.xls`` workbook. + Restricted to TPO and chairman users. + """ + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO and chairman users can view published CPI data.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + raw_batch_id = request.query_params.get('batch_id') + if not raw_batch_id: + return Response([]) + # Validate as an integer so it cannot be reflected into the response + # (filename header) or reach the ORM filter as arbitrary input. + try: + batch_id = int(raw_batch_id) + except (TypeError, ValueError): + return Response( + {'detail': 'batch_id must be an integer.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + rows = _published_cpi_rows(batch_id) + + if request.query_params.get('export') == 'excel': + response = HttpResponse(content_type='application/ms-excel') + response['Content-Disposition'] = ( + 'attachment; filename="published_cpi_batch_{}.xls"'.format(batch_id) + ) + workbook = xlwt.Workbook(encoding='utf-8') + worksheet = workbook.add_sheet('Published CPI') + headers = ['Roll No', 'Name', 'Email', 'CPI', 'Off-Campus'] + header_style = xlwt.XFStyle() + header_style.font.bold = True + for index, header in enumerate(headers): + worksheet.write(0, index, header, header_style) + for row_index, row in enumerate(rows, start=1): + worksheet.write(row_index, 0, row['roll_no']) + worksheet.write(row_index, 1, row['student_name']) + worksheet.write(row_index, 2, row['email']) + worksheet.write(row_index, 3, row['cpi']) + worksheet.write(row_index, 4, ', '.join(row['off_campus'])) + workbook.save(response) + return response + + return Response(rows) + + +# --- Branch (department) reference list for placement forms --- +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_branches_api(request): + """Distinct academic department names that students actually belong to. + + Branch eligibility compares a schedule's branch against the student's + department name, so the placement-event form populates its branch options + from this list instead of a hard-coded one (which had drifted from the real + department names and silently broke branch eligibility). + """ + names = ( + Student.objects + .exclude(id__department__isnull=True) + .values_list('id__department__name', flat=True) + .distinct() + ) + branches = sorted({name for name in names if name}) + return Response(branches) + + +# --- Placement calendar events (free-form, Google-Calendar style) --- +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_calendar_events_api(request): + """List placement calendar events (any authenticated user) or add one (TPO).""" + if request.method == 'GET': + events = PlacementCalendarEvent.objects.all() + return Response(PlacementCalendarEventSerializer(events, many=True).data) + + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can add calendar events.'}, + status=status.HTTP_403_FORBIDDEN, + ) + serializer = PlacementCalendarEventSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + event = serializer.save(created_by=request.user) + return Response( + PlacementCalendarEventSerializer(event).data, + status=status.HTTP_201_CREATED, + ) + + +@api_view(['PUT', 'PATCH', 'DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def placement_calendar_event_detail_api(request, event_id): + """Update or delete a placement calendar event (TPO only).""" + if not _is_tpo_user(request.user): + return Response( + {'detail': 'Only TPO users can manage calendar events.'}, + status=status.HTTP_403_FORBIDDEN, + ) + event = get_object_or_404(PlacementCalendarEvent, pk=event_id) + + if request.method == 'DELETE': + event.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + serializer = PlacementCalendarEventSerializer( + event, data=request.data, partial=request.method == 'PATCH' + ) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + serializer.save() + return Response(serializer.data) + diff --git a/FusionIIIT/applications/placement_cell/forms.py b/FusionIIIT/applications/placement_cell/forms.py index aea81b795..54fb122ba 100644 --- a/FusionIIIT/applications/placement_cell/forms.py +++ b/FusionIIIT/applications/placement_cell/forms.py @@ -410,7 +410,7 @@ class SendInvite(forms.Form): company - name of company """ company = forms.ModelChoiceField(required=True, queryset=NotifyStudent.objects.all(), label="company") - rollno = forms.CharField(label="rollno", widget=forms.TextInput(attrs={'min': 0}), required=False) + rollno = forms.IntegerField(label="rollno", widget=forms.NumberInput(attrs={'min': 0}), required=False) programme = forms.ChoiceField(choices = Con.PROGRAMME, required=False, label="programme", widget=forms.Select(attrs={'style': "height:45px", 'onchange': "changeDeptForSend()", @@ -483,7 +483,6 @@ def clean_company_name(self): return company_name - def current_year(): return date.today().year @@ -546,87 +545,6 @@ class SearchPbiRecord(forms.Form): label="cname", required=False) - -class SendInvitation(forms.Form): - """ - The form is used to send invite to students about upcoming placement or pbi events. - @variables: - company - name of company - """ - company = forms.ModelChoiceField(required=True, queryset=NotifyStudent.objects.all(), label="company") - rollno = forms.IntegerField(label="rollno", widget=forms.NumberInput(attrs={'min': 0}), required=False) - programme = forms.ChoiceField(choices = Con.PROGRAMME, required=False, - label="programme", widget=forms.Select(attrs={'style': "height:45px", - 'onchange': "changeDeptForSend()", - 'id': "id_programme_send"})) - - dep_btech = forms.MultipleChoiceField(choices = Constants.BTECH_DEP, required=False, label="department", - widget=forms.CheckboxSelectMultiple) - dep_bdes = forms.MultipleChoiceField(choices = Constants.BDES_DEP, required=False, label="department", - widget=forms.CheckboxSelectMultiple) - dep_mtech = forms.MultipleChoiceField(choices = Constants.MTECH_DEP, required=False, label="department", - widget=forms.CheckboxSelectMultiple) - dep_mdes = forms.MultipleChoiceField(choices = Constants.MDES_DEP, required=False, label="department", - widget=forms.CheckboxSelectMultiple) - dep_phd = forms.MultipleChoiceField(choices = Constants.PHD_DEP, required=False, label="department", - widget=forms.CheckboxSelectMultiple) - cpi = forms.DecimalField(label="cpi", required=False) - no_of_days = forms.CharField(required=True, widget=forms.NumberInput(attrs={ 'min':0, - 'max':30, - 'max_length': 10, - 'class': 'form-control'})) - - -class AddPlacementSchedule(forms.Form): - """ - The form is used to placement or pbi schedule. - @variables: - time - time of placement activity - ctc - salary - company_name - name of company - placement_type - placement type (placement/pbi) - location - location of company - description - description of company - placement_date - date of placement activity - """ - time = forms.TimeField(label='time', widget=forms.widgets.TimeInput(attrs={'type': "time", - 'value':"00:00", - 'min':"0:00", - 'max':"24:00"})) - ctc = forms.DecimalField(label="ctc", widget=forms.NumberInput(attrs={ 'min':0, 'step': 0.25}) ) - company_name = forms.CharField(widget=forms.TextInput(attrs={'max_length': 100, - 'class': 'field', - 'list': 'company_dropdown1', - 'id': 'company_input'}), - label="company_name") - placement_type = forms.ChoiceField(widget=forms.Select(attrs={'style': "height:45px"}), label="placement_type", - choices=Constants.PLACEMENT_TYPE) - location = forms.CharField(widget=forms.TextInput(attrs={'max_length': 100, - 'class': 'field'}), - label="location") - description = forms.CharField(widget=forms.Textarea(attrs={'max_length': 1000, - 'class': 'form-control'}), - label="description", required=False) - attached_file = forms.FileField(required=False) - placement_date = forms.DateField(label='placement_date', widget=forms.DateInput(attrs={'class':'datepicker'})) - - def clean_ctc(self): - ctc = self.cleaned_data['ctc'] - # print('form validation \n\n\n\n', ctc) - if ctc <= 0: - raise forms.ValidationError("CTC must be positive value") - - return ctc - - def clean_company_name(self): - company_name = self.cleaned_data['company_name'] - # print('form validation \n\n\n\n', ctc) - if NotifyStudent.objects.filter(company_name=company_name): - raise forms.ValidationError("company_name must be unique") - - return company_name - - class SearchHigherRecord(forms.Form): """ The form is used to search from higher study record based on various parameters . @@ -671,7 +589,7 @@ class ManagePlacementRecord(forms.Form): stuname = forms.CharField(widget=forms.TextInput(attrs={'max_length': 100, 'class': 'field'}), label="stuname", required=False) - roll = forms.CharField(widget=forms.TextInput(attrs={ 'min':0, + roll = forms.IntegerField(widget=forms.NumberInput(attrs={ 'min':0, 'max_length': 10, 'class': 'form-control'}), label="roll", required=False) diff --git a/FusionIIIT/applications/placement_cell/management/__init__.py b/FusionIIIT/applications/placement_cell/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/placement_cell/management/commands/__init__.py b/FusionIIIT/applications/placement_cell/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/placement_cell/management/commands/setup_placement_roles.py b/FusionIIIT/applications/placement_cell/management/commands/setup_placement_roles.py new file mode 100644 index 000000000..0b196b798 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/management/commands/setup_placement_roles.py @@ -0,0 +1,111 @@ +"""Create/refresh the placement-cell role accounts. + +Idempotent: re-running updates the password and keeps a single account per role. + +The password is NOT stored in the repository -- pass it explicitly:: + + python manage.py setup_placement_roles --password '' + +or via the PLACEMENT_ROLE_PASSWORD environment variable. +""" + +import os + +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from applications.academic_information.models import Student +from applications.globals.models import ( + Designation, + DepartmentInfo, + ExtraInfo, + HoldsDesignation, + ModuleAccess, +) + +# (username, designation name, full name, ExtraInfo.user_type, needs Student record) +ROLES = [ + ("placement_officer", "placement officer", "Placement Officer", "staff", False), + ("placement_chairman", "placement chairman", "Placement Chairman", "staff", False), + ("placement_student", "student", "Student", "student", True), + ("placement_alumni", "alumni", "Alumni", "student", False), +] + + +class Command(BaseCommand): + help = "Create the placement-cell role accounts (officer, chairman, student, alumni)." + + def add_arguments(self, parser): + parser.add_argument( + "--password", + default=os.environ.get("PLACEMENT_ROLE_PASSWORD"), + help="Password for the role accounts (or set PLACEMENT_ROLE_PASSWORD).", + ) + parser.add_argument( + "--department", + default="CSE", + help="Department to attach the accounts to (default: CSE).", + ) + + @transaction.atomic + def handle(self, *args, **options): + password = options["password"] + if not password: + raise CommandError( + "Provide --password '' or set PLACEMENT_ROLE_PASSWORD." + ) + + department, _ = DepartmentInfo.objects.get_or_create( + name=options["department"] + ) + + for username, role_name, full_name, user_type, needs_student in ROLES: + designation, _ = Designation.objects.get_or_create( + name=role_name, defaults={"full_name": full_name} + ) + + # Make the placement module visible in the sidebar for this role. + access, _ = ModuleAccess.objects.get_or_create(designation=role_name) + if not access.placement_cell: + access.placement_cell = True + access.save(update_fields=["placement_cell"]) + + user, _ = User.objects.get_or_create( + username=username, + defaults={"email": "{}@iiitdmj.ac.in".format(username)}, + ) + user.set_password(password) + user.save() + + extra, _ = ExtraInfo.objects.get_or_create( + user=user, + defaults={"id": username, "user_type": user_type, "department": department}, + ) + extra.user_type = user_type + extra.department = department + extra.last_selected_role = role_name + extra.save(update_fields=["user_type", "department", "last_selected_role"]) + + if needs_student: + Student.objects.get_or_create( + id=extra, + defaults={ + "programme": "B.Tech", + "batch": 2026, + "cpi": 8.5, + "category": "GEN", + }, + ) + + HoldsDesignation.objects.get_or_create( + user=user, working=user, designation=designation + ) + + self.stdout.write( + self.style.SUCCESS( + "{:<18} -> role '{}'".format(username, role_name) + ) + ) + + self.stdout.write(self.style.SUCCESS("Placement role accounts ready.")) diff --git a/FusionIIIT/applications/placement_cell/migrations/0001_initial.py b/FusionIIIT/applications/placement_cell/migrations/0001_initial.py index 712c60a56..257c98609 100644 --- a/FusionIIIT/applications/placement_cell/migrations/0001_initial.py +++ b/FusionIIIT/applications/placement_cell/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1.5 on 2024-07-16 15:44 +# Generated by Django 3.1.5 on 2023-03-15 18:53 import datetime from django.db import migrations, models diff --git a/FusionIIIT/applications/placement_cell/migrations/0002_frontend_api_models.py b/FusionIIIT/applications/placement_cell/migrations/0002_frontend_api_models.py new file mode 100644 index 000000000..a3aae8c4a --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0002_frontend_api_models.py @@ -0,0 +1,139 @@ +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('placement_cell', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='companydetails', + name='address', + field=models.TextField(blank=True, default='', max_length=1000), + ), + migrations.AddField( + model_name='companydetails', + name='description', + field=models.TextField(blank=True, default='', max_length=1000), + ), + migrations.AddField( + model_name='companydetails', + name='logo', + field=models.ImageField(blank=True, null=True, upload_to='documents/placement/company_logos'), + ), + migrations.AddField( + model_name='companydetails', + name='website', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.CreateModel( + name='PlacementField', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('type', models.CharField(choices=[('text', 'Text'), ('number', 'Number'), ('decimal', 'Decimal'), ('date', 'Date'), ('time', 'Time')], default='text', max_length=20)), + ('required', models.BooleanField(default=False)), + ], + ), + migrations.AddField( + model_name='placementschedule', + name='branch', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='placementschedule', + name='company', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='placement_cell.CompanyDetails'), + ), + migrations.AddField( + model_name='placementschedule', + name='cpi', + field=models.CharField(blank=True, default='', max_length=20), + ), + migrations.AddField( + model_name='placementschedule', + name='eligibility', + field=models.TextField(blank=True, default='', max_length=1000), + ), + migrations.AddField( + model_name='placementschedule', + name='end_date', + field=models.DateField(blank=True, null=True, verbose_name='Date'), + ), + migrations.AddField( + model_name='placementschedule', + name='end_datetime', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='placementschedule', + name='gender', + field=models.CharField(blank=True, default='', max_length=20), + ), + migrations.AddField( + model_name='placementschedule', + name='passoutyr', + field=models.CharField(blank=True, default='', max_length=20), + ), + migrations.AddField( + model_name='placementschedule', + name='fields', + field=models.ManyToManyField(blank=True, to='placement_cell.PlacementField'), + ), + migrations.AddField( + model_name='studentplacement', + name='debar_reason', + field=models.TextField(blank=True, default='', max_length=1000), + ), + migrations.CreateModel( + name='PlacementRestriction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('criteria', models.CharField(max_length=50)), + ('condition', models.CharField(max_length=50)), + ('value', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, default='', max_length=1000)), + ], + ), + migrations.CreateModel( + name='PlacementRound', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('round_no', models.IntegerField(default=0)), + ('test_date', models.DateField(blank=True, null=True)), + ('description', models.TextField(blank=True, default='', max_length=1000)), + ('test_type', models.CharField(blank=True, default='', max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('schedule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='placement_cell.PlacementSchedule')), + ], + options={ + 'ordering': ('round_no', 'created_at'), + }, + ), + migrations.CreateModel( + name='PlacementApplication', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('accept', 'Accept'), ('reject', 'Reject')], default='pending', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('schedule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='placement_cell.PlacementSchedule')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='academic_information.Student')), + ], + options={ + 'unique_together': {('schedule', 'student')}, + }, + ), + migrations.CreateModel( + name='PlacementApplicationResponse', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.TextField(blank=True, default='', max_length=5000)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='placement_cell.PlacementApplication')), + ('field', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='placement_cell.PlacementField')), + ], + ), + ] diff --git a/FusionIIIT/applications/placement_cell/migrations/0003_assignment7_requirement_fixes.py b/FusionIIIT/applications/placement_cell/migrations/0003_assignment7_requirement_fixes.py new file mode 100644 index 000000000..08bb2c146 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0003_assignment7_requirement_fixes.py @@ -0,0 +1,28 @@ +from django.db import migrations, models +import django.core.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('placement_cell', '0002_frontend_api_models'), + ] + + operations = [ + migrations.AlterField( + model_name='education', + name='grade', + field=models.CharField(default='', max_length=10), + ), + migrations.AlterField( + model_name='has', + name='skill_rating', + field=models.IntegerField( + default=80, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(100), + ], + ), + ), + ] diff --git a/FusionIIIT/applications/placement_cell/migrations/0004_higher_studies_chairman_visit_dates.py b/FusionIIIT/applications/placement_cell/migrations/0004_higher_studies_chairman_visit_dates.py new file mode 100644 index 000000000..0d3d6f000 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0004_higher_studies_chairman_visit_dates.py @@ -0,0 +1,23 @@ +import datetime + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('placement_cell', '0003_assignment7_requirement_fixes'), + ] + + operations = [ + migrations.AddField( + model_name='chairmanvisit', + name='start_date', + field=models.DateField(default=datetime.date.today, verbose_name='Start Date'), + ), + migrations.AddField( + model_name='chairmanvisit', + name='end_date', + field=models.DateField(blank=True, null=True, verbose_name='End Date'), + ), + ] diff --git a/FusionIIIT/applications/placement_cell/migrations/0005_auto_20260418_0906.py b/FusionIIIT/applications/placement_cell/migrations/0005_auto_20260418_0906.py new file mode 100644 index 000000000..6fe05cd3b --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0005_auto_20260418_0906.py @@ -0,0 +1,65 @@ +# Generated by Django 3.1.5 on 2026-04-18 09:06 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('academic_information', '0001_initial'), + ('placement_cell', '0004_higher_studies_chairman_visit_dates'), + ] + + operations = [ + migrations.AddField( + model_name='placementapplication', + name='withdrawn_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='placementapplication', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('accept', 'Accept'), ('reject', 'Reject'), ('withdrawn', 'Withdrawn')], default='pending', max_length=20), + ), + migrations.CreateModel( + name='PlacementProfileDocument', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='Supporting Document', max_length=100)), + ('document', models.FileField(upload_to='documents/placement/profile_documents')), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='academic_information.student')), + ], + options={ + 'ordering': ('-uploaded_at', '-id'), + }, + ), + migrations.CreateModel( + name='PlacementProfileAuditLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(max_length=100)), + ('details', models.JSONField(blank=True, default=dict)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='academic_information.student')), + ], + options={ + 'ordering': ('-created_at', '-id'), + }, + ), + migrations.CreateModel( + name='PlacementNotificationPreference', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('enable_portal', models.BooleanField(default=True)), + ('enable_email', models.BooleanField(default=True)), + ('enable_sms', models.BooleanField(default=False)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('student', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='academic_information.student')), + ], + ), + ] diff --git a/FusionIIIT/applications/placement_cell/migrations/0006_alumni_features.py b/FusionIIIT/applications/placement_cell/migrations/0006_alumni_features.py new file mode 100644 index 000000000..67034b272 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0006_alumni_features.py @@ -0,0 +1,97 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0001_initial'), + ('placement_cell', '0005_auto_20260418_0906'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AlumniProfile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('graduation_year', models.IntegerField()), + ('degree', models.CharField(blank=True, default='', max_length=100)), + ('current_company', models.CharField(blank=True, default='', max_length=150)), + ('current_designation', models.CharField(blank=True, default='', max_length=150)), + ('linkedin_url', models.URLField(blank=True, default='')), + ('verification_document', models.FileField(blank=True, null=True, upload_to='documents/placement/alumni_verification')), + ('verification_notes', models.TextField(blank=True, default='', max_length=1000)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='pending', max_length=20)), + ('topics', models.TextField(blank=True, default='', max_length=1000)), + ('availability', models.CharField(blank=True, default='', max_length=200)), + ('bio', models.TextField(blank=True, default='', max_length=1500)), + ('mentorship_enabled', models.BooleanField(default=False)), + ('approved_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_alumni_profiles', to=settings.AUTH_USER_MODEL)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-updated_at', '-id'), + }, + ), + migrations.CreateModel( + name='AlumniReferral', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=150)), + ('company', models.CharField(max_length=150)), + ('location', models.CharField(blank=True, default='', max_length=150)), + ('application_url', models.URLField(blank=True, default='')), + ('description', models.TextField(max_length=2000)), + ('expires_at', models.DateField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('alumni', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='referrals', to='placement_cell.alumniprofile')), + ], + options={ + 'ordering': ('-created_at', '-id'), + }, + ), + migrations.CreateModel( + name='AlumniMentorshipSession', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('topic', models.CharField(max_length=150)), + ('agenda', models.TextField(blank=True, default='', max_length=1500)), + ('scheduled_at', models.DateTimeField()), + ('mode', models.CharField(blank=True, default='online', max_length=50)), + ('meeting_link', models.CharField(blank=True, default='', max_length=300)), + ('student_message', models.TextField(blank=True, default='', max_length=1500)), + ('alumni_message', models.TextField(blank=True, default='', max_length=1500)), + ('status', models.CharField(choices=[('requested', 'Requested'), ('scheduled', 'Scheduled'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='requested', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('alumni', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='placement_cell.alumniprofile')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alumni_sessions', to='academic_information.student')), + ], + options={ + 'ordering': ('scheduled_at', '-id'), + }, + ), + migrations.CreateModel( + name='AlumniConnection', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('connected', 'Connected'), ('rejected', 'Rejected')], default='pending', max_length=20)), + ('message', models.TextField(blank=True, default='', max_length=1000)), + ('responded_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('alumni', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='connections', to='placement_cell.alumniprofile')), + ('responded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='alumni_connection_responses', to=settings.AUTH_USER_MODEL)), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alumni_connections', to='academic_information.student')), + ], + options={ + 'ordering': ('-created_at', '-id'), + 'unique_together': {('alumni', 'student')}, + }, + ), + ] diff --git a/FusionIIIT/applications/placement_cell/migrations/0007_reporting_features.py b/FusionIIIT/applications/placement_cell/migrations/0007_reporting_features.py new file mode 100644 index 000000000..f4f292e97 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0007_reporting_features.py @@ -0,0 +1,33 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("placement_cell", "0006_alumni_features"), + ] + + operations = [ + migrations.CreateModel( + name="PlacementReportSchedule", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=120)), + ("report_type", models.CharField(choices=[("batch", "Batch Summary"), ("company", "Company Summary"), ("branch", "Branch Summary"), ("custom", "Custom Report")], default="custom", max_length=20)), + ("frequency", models.CharField(choices=[("daily", "Daily"), ("weekly", "Weekly"), ("monthly", "Monthly")], default="weekly", max_length=20)), + ("export_format", models.CharField(choices=[("excel", "Excel"), ("pdf", "PDF")], default="excel", max_length=20)), + ("filters", models.JSONField(blank=True, default=dict)), + ("recipients", models.TextField(blank=True, default="", max_length=500)), + ("is_active", models.BooleanField(default=True)), + ("last_run_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("created_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + "ordering": ("-updated_at", "-id"), + }, + ), + ] diff --git a/FusionIIIT/applications/placement_cell/migrations/0008_round_datetime_conflicts.py b/FusionIIIT/applications/placement_cell/migrations/0008_round_datetime_conflicts.py new file mode 100644 index 000000000..d2ba78473 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0008_round_datetime_conflicts.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("placement_cell", "0007_reporting_features"), + ] + + operations = [ + migrations.AddField( + model_name="placementround", + name="end_datetime", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="placementround", + name="location_link", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AddField( + model_name="placementround", + name="mode", + field=models.CharField(blank=True, default="", max_length=30), + ), + migrations.AddField( + model_name="placementround", + name="start_datetime", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/FusionIIIT/applications/placement_cell/migrations/0009_application_detail_features.py b/FusionIIIT/applications/placement_cell/migrations/0009_application_detail_features.py new file mode 100644 index 000000000..182ac6289 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0009_application_detail_features.py @@ -0,0 +1,75 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("placement_cell", "0008_round_datetime_conflicts"), + ] + + operations = [ + migrations.AddField( + model_name="placementapplication", + name="remarks", + field=models.TextField(blank=True, default="", max_length=1000), + ), + migrations.AddField( + model_name="placementapplication", + name="updated_at", + field=models.DateTimeField(auto_now=True, null=True), + preserve_default=False, + ), + migrations.AlterField( + model_name="placementapplication", + name="status", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("shortlisted", "Shortlisted"), + ("interview_scheduled", "Interview Scheduled"), + ("interview_completed", "Interview Completed"), + ("offer_released", "Offer Released"), + ("accept", "Accept"), + ("reject", "Reject"), + ("withdrawn", "Withdrawn"), + ], + default="pending", + max_length=20, + ), + ), + migrations.CreateModel( + name="PlacementApplicationTimeline", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("stage", models.CharField(default="", max_length=100)), + ("remarks", models.TextField(blank=True, default="", max_length=1000)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("actor", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ("application", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="timeline_entries", to="placement_cell.PlacementApplication")), + ], + options={"ordering": ("created_at", "id")}, + ), + migrations.CreateModel( + name="PlacementInterviewSchedule", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("round_no", models.IntegerField(default=1)), + ("title", models.CharField(blank=True, default="", max_length=100)), + ("scheduled_at", models.DateTimeField()), + ("end_datetime", models.DateTimeField(blank=True, null=True)), + ("mode", models.CharField(blank=True, default="", max_length=30)), + ("location", models.CharField(blank=True, default="", max_length=255)), + ("meeting_link", models.CharField(blank=True, default="", max_length=255)), + ("remarks", models.TextField(blank=True, default="", max_length=1000)), + ("outcome", models.CharField(choices=[("pending", "Pending"), ("passed", "Passed"), ("failed", "Failed"), ("selected", "Selected")], default="pending", max_length=20)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("application", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="interview_schedules", to="placement_cell.PlacementApplication")), + ("created_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={"ordering": ("-scheduled_at", "-id")}, + ), + ] diff --git a/FusionIIIT/applications/placement_cell/migrations/0010_placement_appeal.py b/FusionIIIT/applications/placement_cell/migrations/0010_placement_appeal.py new file mode 100644 index 000000000..136d334fb --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0010_placement_appeal.py @@ -0,0 +1,28 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("placement_cell", "0009_application_detail_features"), + ] + + operations = [ + migrations.CreateModel( + name="PlacementAppeal", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("reason", models.TextField(max_length=2000)), + ("status", models.CharField(choices=[("pending", "Pending"), ("reviewed", "Reviewed"), ("accepted", "Accepted"), ("rejected", "Rejected")], default="pending", max_length=20)), + ("response", models.TextField(blank=True, default="", max_length=2000)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("reviewed_at", models.DateTimeField(blank=True, null=True)), + ("placement_status", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="placement_cell.PlacementStatus")), + ("student", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="academic_information.Student")), + ], + options={ + "unique_together": {("student", "placement_status")}, + }, + ), + ] diff --git a/FusionIIIT/applications/placement_cell/migrations/0011_performance_indexes.py b/FusionIIIT/applications/placement_cell/migrations/0011_performance_indexes.py new file mode 100644 index 000000000..cc2c2d8ee --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0011_performance_indexes.py @@ -0,0 +1,83 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("placement_cell", "0010_placement_appeal"), + ] + + operations = [ + migrations.AddIndex( + model_name="notifystudent", + index=models.Index(fields=["placement_type"], name="placement_c_placeme_1c6ff8_idx"), + ), + migrations.AddIndex( + model_name="notifystudent", + index=models.Index(fields=["company_name"], name="placement_c_company_0204a2_idx"), + ), + migrations.AddIndex( + model_name="placementstatus", + index=models.Index(fields=["unique_id", "invitation"], name="placement_c_unique__068844_idx"), + ), + migrations.AddIndex( + model_name="placementstatus", + index=models.Index(fields=["unique_id", "timestamp"], name="placement_c_unique__5767b0_idx"), + ), + migrations.AddIndex( + model_name="studentrecord", + index=models.Index(fields=["unique_id", "record_id"], name="placement_c_unique__964ebc_idx"), + ), + migrations.AddIndex( + model_name="placementschedule", + index=models.Index(fields=["placement_date"], name="placement_c_placeme_190366_idx"), + ), + migrations.AddIndex( + model_name="placementschedule", + index=models.Index(fields=["schedule_at"], name="placement_c_schedul_f76a20_idx"), + ), + migrations.AddIndex( + model_name="placementschedule", + index=models.Index(fields=["notify_id", "placement_date"], name="placement_c_notify__957faa_idx"), + ), + migrations.AddIndex( + model_name="placementapplication", + index=models.Index(fields=["student", "created_at"], name="placement_c_student_5ef23d_idx"), + ), + migrations.AddIndex( + model_name="placementapplication", + index=models.Index(fields=["schedule", "created_at"], name="placement_c_schedul_6db3a0_idx"), + ), + migrations.AddIndex( + model_name="placementapplication", + index=models.Index(fields=["student", "status"], name="placement_c_student_10ef00_idx"), + ), + migrations.AddIndex( + model_name="placementapplication", + index=models.Index(fields=["schedule", "status"], name="placement_c_schedul_93bec9_idx"), + ), + migrations.AddIndex( + model_name="placementround", + index=models.Index(fields=["schedule", "round_no"], name="placement_c_schedul_6c9b4d_idx"), + ), + migrations.AddIndex( + model_name="placementround", + index=models.Index(fields=["schedule", "start_datetime"], name="placement_c_schedul_3be9db_idx"), + ), + migrations.AddIndex( + model_name="placementapplicationtimeline", + index=models.Index(fields=["application", "created_at"], name="placement_c_applica_6646ff_idx"), + ), + migrations.AddIndex( + model_name="placementinterviewschedule", + index=models.Index(fields=["application", "scheduled_at"], name="placement_c_applica_45d617_idx"), + ), + migrations.AddIndex( + model_name="placementinterviewschedule", + index=models.Index(fields=["application", "round_no"], name="placement_c_applica_209fbb_idx"), + ), + migrations.AddIndex( + model_name="placementprofiledocument", + index=models.Index(fields=["student", "uploaded_at"], name="placement_c_student_27f93a_idx"), + ), + ] diff --git a/FusionIIIT/applications/placement_cell/migrations/0012_placementpolicy.py b/FusionIIIT/applications/placement_cell/migrations/0012_placementpolicy.py new file mode 100644 index 000000000..50028ecba --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0012_placementpolicy.py @@ -0,0 +1,27 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('placement_cell', '0011_performance_indexes'), + ] + + operations = [ + migrations.CreateModel( + name='PlacementPolicy', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=150)), + ('description', models.TextField(max_length=3000)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-updated_at', '-id'), + }, + ), + ] diff --git a/FusionIIIT/applications/placement_cell/migrations/0013_sync_index_names_and_updated_at.py b/FusionIIIT/applications/placement_cell/migrations/0013_sync_index_names_and_updated_at.py new file mode 100644 index 000000000..beab1adf4 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0013_sync_index_names_and_updated_at.py @@ -0,0 +1,162 @@ +# Generated by Django 3.1.5 on 2026-06-20 22:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('placement_cell', '0012_placementpolicy'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='notifystudent', + name='placement_c_placeme_1c6ff8_idx', + ), + migrations.RemoveIndex( + model_name='notifystudent', + name='placement_c_company_0204a2_idx', + ), + migrations.RemoveIndex( + model_name='placementapplication', + name='placement_c_student_5ef23d_idx', + ), + migrations.RemoveIndex( + model_name='placementapplication', + name='placement_c_schedul_6db3a0_idx', + ), + migrations.RemoveIndex( + model_name='placementapplication', + name='placement_c_student_10ef00_idx', + ), + migrations.RemoveIndex( + model_name='placementapplication', + name='placement_c_schedul_93bec9_idx', + ), + migrations.RemoveIndex( + model_name='placementapplicationtimeline', + name='placement_c_applica_6646ff_idx', + ), + migrations.RemoveIndex( + model_name='placementinterviewschedule', + name='placement_c_applica_45d617_idx', + ), + migrations.RemoveIndex( + model_name='placementinterviewschedule', + name='placement_c_applica_209fbb_idx', + ), + migrations.RemoveIndex( + model_name='placementprofiledocument', + name='placement_c_student_27f93a_idx', + ), + migrations.RemoveIndex( + model_name='placementround', + name='placement_c_schedul_6c9b4d_idx', + ), + migrations.RemoveIndex( + model_name='placementround', + name='placement_c_schedul_3be9db_idx', + ), + migrations.RemoveIndex( + model_name='placementschedule', + name='placement_c_placeme_190366_idx', + ), + migrations.RemoveIndex( + model_name='placementschedule', + name='placement_c_schedul_f76a20_idx', + ), + migrations.RemoveIndex( + model_name='placementschedule', + name='placement_c_notify__957faa_idx', + ), + migrations.RemoveIndex( + model_name='placementstatus', + name='placement_c_unique__068844_idx', + ), + migrations.RemoveIndex( + model_name='placementstatus', + name='placement_c_unique__5767b0_idx', + ), + migrations.RemoveIndex( + model_name='studentrecord', + name='placement_c_unique__964ebc_idx', + ), + migrations.AlterField( + model_name='placementapplication', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddIndex( + model_name='notifystudent', + index=models.Index(fields=['placement_type'], name='placement_c_placeme_f6d79d_idx'), + ), + migrations.AddIndex( + model_name='notifystudent', + index=models.Index(fields=['company_name'], name='placement_c_company_1cda10_idx'), + ), + migrations.AddIndex( + model_name='placementapplication', + index=models.Index(fields=['student', 'created_at'], name='placement_c_student_ee2e19_idx'), + ), + migrations.AddIndex( + model_name='placementapplication', + index=models.Index(fields=['schedule', 'created_at'], name='placement_c_schedul_5ba89e_idx'), + ), + migrations.AddIndex( + model_name='placementapplication', + index=models.Index(fields=['student', 'status'], name='placement_c_student_3320d9_idx'), + ), + migrations.AddIndex( + model_name='placementapplication', + index=models.Index(fields=['schedule', 'status'], name='placement_c_schedul_f69f77_idx'), + ), + migrations.AddIndex( + model_name='placementapplicationtimeline', + index=models.Index(fields=['application', 'created_at'], name='placement_c_applica_b9c487_idx'), + ), + migrations.AddIndex( + model_name='placementinterviewschedule', + index=models.Index(fields=['application', 'scheduled_at'], name='placement_c_applica_15105d_idx'), + ), + migrations.AddIndex( + model_name='placementinterviewschedule', + index=models.Index(fields=['application', 'round_no'], name='placement_c_applica_bacc2d_idx'), + ), + migrations.AddIndex( + model_name='placementprofiledocument', + index=models.Index(fields=['student', 'uploaded_at'], name='placement_c_student_706a33_idx'), + ), + migrations.AddIndex( + model_name='placementround', + index=models.Index(fields=['schedule', 'round_no'], name='placement_c_schedul_f88491_idx'), + ), + migrations.AddIndex( + model_name='placementround', + index=models.Index(fields=['schedule', 'start_datetime'], name='placement_c_schedul_8335e1_idx'), + ), + migrations.AddIndex( + model_name='placementschedule', + index=models.Index(fields=['placement_date'], name='placement_c_placeme_9b17cf_idx'), + ), + migrations.AddIndex( + model_name='placementschedule', + index=models.Index(fields=['schedule_at'], name='placement_c_schedul_501807_idx'), + ), + migrations.AddIndex( + model_name='placementschedule', + index=models.Index(fields=['notify_id', 'placement_date'], name='placement_c_notify__27a97c_idx'), + ), + migrations.AddIndex( + model_name='placementstatus', + index=models.Index(fields=['unique_id', 'invitation'], name='placement_c_unique__1e8d5c_idx'), + ), + migrations.AddIndex( + model_name='placementstatus', + index=models.Index(fields=['unique_id', 'timestamp'], name='placement_c_unique__fafa80_idx'), + ), + migrations.AddIndex( + model_name='studentrecord', + index=models.Index(fields=['unique_id', 'record_id'], name='placement_c_unique__18ed5e_idx'), + ), + ] diff --git a/FusionIIIT/applications/placement_cell/migrations/0014_offcampusplacement_placementannouncement.py b/FusionIIIT/applications/placement_cell/migrations/0014_offcampusplacement_placementannouncement.py new file mode 100644 index 000000000..625a88b21 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0014_offcampusplacement_placementannouncement.py @@ -0,0 +1,50 @@ +# Generated by Django 3.1.5 on 2026-06-21 11:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0006_auto_20260304_0836'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('placement_cell', '0013_sync_index_names_and_updated_at'), + ] + + operations = [ + migrations.CreateModel( + name='PlacementAnnouncement', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=300)), + ('body', models.TextField()), + ('posted_at', models.DateTimeField(auto_now_add=True)), + ('is_pinned', models.BooleanField(default=False)), + ('posted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='placement_announcements', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-is_pinned', '-posted_at'), + }, + ), + migrations.CreateModel( + name='OffCampusPlacement', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('company_name', models.CharField(max_length=255)), + ('role', models.CharField(max_length=200)), + ('offer_type', models.CharField(choices=[('placement', 'Placement'), ('internship', 'Internship')], default='placement', max_length=20)), + ('ctc', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('stipend', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('offer_date', models.DateField()), + ('notes', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='added_offcampus_placements', to=settings.AUTH_USER_MODEL)), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='offcampus_placements', to='globals.extrainfo')), + ], + options={ + 'ordering': ('-offer_date', '-id'), + }, + ), + ] diff --git a/FusionIIIT/applications/placement_cell/migrations/0015_placementcalendarevent.py b/FusionIIIT/applications/placement_cell/migrations/0015_placementcalendarevent.py new file mode 100644 index 000000000..196e1f527 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/migrations/0015_placementcalendarevent.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1.5 on 2026-06-21 19:47 + +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), + ('placement_cell', '0014_offcampusplacement_placementannouncement'), + ] + + operations = [ + migrations.CreateModel( + name='PlacementCalendarEvent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('start', models.DateTimeField()), + ('end', models.DateTimeField(blank=True, null=True)), + ('all_day', models.BooleanField(default=False)), + ('category', models.CharField(choices=[('event', 'Event'), ('drive', 'Drive'), ('test', 'Online Test'), ('interview', 'Interview'), ('deadline', 'Deadline'), ('other', 'Other')], default='event', max_length=20)), + ('location', models.CharField(blank=True, max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='placement_calendar_events', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('start', 'id'), + }, + ), + ] diff --git a/FusionIIIT/applications/placement_cell/models.py b/FusionIIIT/applications/placement_cell/models.py index 53f8d8ea4..793fb555c 100644 --- a/FusionIIIT/applications/placement_cell/models.py +++ b/FusionIIIT/applications/placement_cell/models.py @@ -1,13 +1,30 @@ +# imports import datetime + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.contrib.auth import get_user_model from django.db import models from django.utils import timezone from django.utils.translation import gettext as _ from applications.academic_information.models import Student +from applications.globals.models import ExtraInfo + +User = get_user_model() # Class definations: +def validate_start_before_end(*, start_date, end_date, start_field='sdate', end_field='edate'): + if start_date and end_date and start_date >= end_date: + raise ValidationError({ + start_field: _('Start date must be earlier than end date.'), + end_field: _('End date must be later than start date.'), + }) + + class Constants: RESUME_TYPE = ( ('ONGOING', 'Ongoing'), @@ -54,7 +71,6 @@ class Constants: ('CSE', 'CSE'), ('ME','ME'), ('ECE','ECE'), - ('SM','SM'), ) BDES_DEP = ( @@ -92,6 +108,10 @@ class Project(models.Model): sdate = models.DateField(_("Date"), default=datetime.date.today) edate = models.DateField(null=True, blank=True) + def clean(self): + super().clean() + validate_start_before_end(start_date=self.sdate, end_date=self.edate) + def __str__(self): return '{} - {}'.format(self.unique_id.id, self.project_name) @@ -106,7 +126,10 @@ def __str__(self): class Has(models.Model): skill_id = models.ForeignKey(Skill, on_delete=models.CASCADE) unique_id = models.ForeignKey(Student, on_delete=models.CASCADE) - skill_rating = models.IntegerField(default=80) + skill_rating = models.IntegerField( + default=80, + validators=[MinValueValidator(0), MaxValueValidator(100)], + ) class Meta: unique_together = (('skill_id', 'unique_id'),) @@ -125,29 +148,8 @@ class Education(models.Model): edate = models.DateField(null=True, blank=True) def clean(self): - - sdate = self.cleaned_data.get("startdate") - stime = self.cleaned_data.get("starttime") - print(sdate, "sdate") - today = datetime.datetime.now() - datetime.timedelta(1) - print(today, "today") - k1 = stime.hour - k2 = stime.minute - k3 = stime.second - x = time(k1, k2, k3) - date = datetime.datetime.combine(sdate, x) - edate = self.cleaned_data.get("enddate") - etime = self.cleaned_data.get("endtime") - k1 = etime.hour - k2 = etime.minute - k3 = etime.second - end_date = datetime.datetime.combine(edate, datetime.time(k1, k2, k3)) - print(date, end_date) - if(date < today): - raise forms.ValidationError("Invalid quiz Start Date") - elif(date > end_date): - raise forms.ValidationError("Start Date but me before End Date") - return self.cleaned_data + super().clean() + validate_start_before_end(start_date=self.sdate, end_date=self.edate) class Experience(models.Model): @@ -161,6 +163,10 @@ class Experience(models.Model): sdate = models.DateField(_("Date"), default=datetime.date.today) edate = models.DateField(null=True, blank=True) + def clean(self): + super().clean() + validate_start_before_end(start_date=self.sdate, end_date=self.edate) + def __str__(self): return '{} - {}'.format(self.unique_id.id, self.company) @@ -173,6 +179,10 @@ class Course(models.Model): sdate = models.DateField(_("Date"), default=datetime.date.today) edate = models.DateField(null=True, blank=True) + def clean(self): + super().clean() + validate_start_before_end(start_date=self.sdate, end_date=self.edate) + def __str__(self): return '{} - {}'.format(self.unique_id.id, self.course_name) @@ -289,6 +299,12 @@ class NotifyStudent(models.Model): def __str__(self): return '{} - {}'.format(self.company_name, self.placement_type) + class Meta: + indexes = [ + models.Index(fields=['placement_type']), + models.Index(fields=['company_name']), + ] + @property def get_placement_schedule_object(self): return PlacementSchedule.objects.filter(notify_id=self.id).first() @@ -302,11 +318,36 @@ def __str__(self): class CompanyDetails(models.Model): company_name = models.CharField(max_length=100, blank=True, null=True) + description = models.TextField(max_length=1000, default='', blank=True) + address = models.TextField(max_length=1000, default='', blank=True) + website = models.CharField(max_length=255, default='', blank=True) + logo = models.ImageField( + upload_to='documents/placement/company_logos', + null=True, + blank=True, + ) def __str__(self): return self.company_name +class PlacementField(models.Model): + FIELD_TYPES = ( + ('text', 'Text'), + ('number', 'Number'), + ('decimal', 'Decimal'), + ('date', 'Date'), + ('time', 'Time'), + ) + + name = models.CharField(max_length=100, unique=True) + type = models.CharField(max_length=20, choices=FIELD_TYPES, default='text') + required = models.BooleanField(default=False) + + def __str__(self): + return self.name + + class PlacementStatus(models.Model): notify_id = models.ForeignKey(NotifyStudent, on_delete=models.CASCADE) unique_id = models.ForeignKey(Student, on_delete=models.CASCADE) @@ -319,6 +360,10 @@ class PlacementStatus(models.Model): class Meta: unique_together = (('notify_id', 'unique_id'),) + indexes = [ + models.Index(fields=['unique_id', 'invitation']), + models.Index(fields=['unique_id', 'timestamp']), + ] @property def response_date(self): @@ -347,6 +392,9 @@ class StudentRecord(models.Model): class Meta: unique_together = (('record_id', 'unique_id'),) + indexes = [ + models.Index(fields=['unique_id', 'record_id']), + ] def __str__(self): return '{} - {}'.format(self.unique_id.id, self.record_id.name) @@ -356,9 +404,26 @@ class ChairmanVisit(models.Model): company_name = models.CharField(max_length=100, default='') location = models.CharField(max_length=100, default='') visiting_date = models.DateField(_("Date"), default=datetime.date.today) + start_date = models.DateField(_("Start Date"), default=datetime.date.today) + end_date = models.DateField(_("End Date"), null=True, blank=True) description = models.TextField(max_length=1000, default='', null=True, blank=True) timestamp = models.DateTimeField(auto_now=True) + def clean(self): + super().clean() + validate_start_before_end( + start_date=self.start_date, + end_date=self.end_date, + start_field='start_date', + end_field='end_date', + ) + + def save(self, *args, **kwargs): + if self.start_date and not self.visiting_date: + self.visiting_date = self.start_date + self.full_clean() + super().save(*args, **kwargs) + def __str__(self): return self.company_name @@ -367,16 +432,32 @@ class PlacementSchedule(models.Model): notify_id = models.ForeignKey(NotifyStudent, on_delete=models.CASCADE) title = models.CharField(max_length=100, default='') placement_date = models.DateField(_("Date"), default=datetime.date.today) + end_date = models.DateField(_("Date"), null=True, blank=True) location = models.CharField(max_length=100, default='') description = models.TextField(max_length=500, default='', null=True, blank=True) + eligibility = models.TextField(max_length=1000, default='', blank=True) + passoutyr = models.CharField(max_length=20, default='', blank=True) + gender = models.CharField(max_length=20, default='', blank=True) + cpi = models.CharField(max_length=20, default='', blank=True) + branch = models.CharField(max_length=100, default='', blank=True) time = models.TimeField() role = models.ForeignKey(Role, on_delete=models.CASCADE, null=True, blank=True) attached_file = models.FileField(upload_to='documents/placement/schedule', null=True, blank=True) schedule_at = models.DateTimeField(auto_now_add=False, auto_now=False, default=timezone.now, blank=True, null=True) + end_datetime = models.DateTimeField(blank=True, null=True) + company = models.ForeignKey(CompanyDetails, on_delete=models.SET_NULL, null=True, blank=True) + fields = models.ManyToManyField(PlacementField, blank=True) def __str__(self): return '{} - {}'.format(self.notify_id.company_name, self.placement_date) + class Meta: + indexes = [ + models.Index(fields=['placement_date']), + models.Index(fields=['schedule_at']), + models.Index(fields=['notify_id', 'placement_date']), + ] + @property def get_role(self): try: @@ -388,6 +469,7 @@ def get_role(self): class StudentPlacement(models.Model): unique_id = models.OneToOneField(Student, primary_key=True, on_delete=models.CASCADE) debar = models.CharField(max_length=20, choices=Constants.DEBAR_TYPE, default='NOT DEBAR') + debar_reason = models.TextField(max_length=1000, default='', blank=True) future_aspect = models.CharField(max_length=20, choices=Constants.PLACEMENT_TYPE, default='PLACEMENT') placed_type = models.CharField(max_length=20, choices=Constants.PLACED_TYPE, @@ -399,3 +481,470 @@ class StudentPlacement(models.Model): def __str__(self): return self.unique_id.id.id + + +class PlacementApplication(models.Model): + STATUS_CHOICES = ( + ('pending', 'Pending'), + ('shortlisted', 'Shortlisted'), + ('interview_scheduled', 'Interview Scheduled'), + ('interview_completed', 'Interview Completed'), + ('offer_released', 'Offer Released'), + ('accept', 'Accept'), + ('reject', 'Reject'), + ('withdrawn', 'Withdrawn'), + ) + + schedule = models.ForeignKey(PlacementSchedule, on_delete=models.CASCADE) + student = models.ForeignKey(Student, on_delete=models.CASCADE) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + remarks = models.TextField(max_length=1000, default='', blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + withdrawn_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = (('schedule', 'student'),) + indexes = [ + models.Index(fields=['student', 'created_at']), + models.Index(fields=['schedule', 'created_at']), + models.Index(fields=['student', 'status']), + models.Index(fields=['schedule', 'status']), + ] + + def __str__(self): + return '{} - {}'.format(self.student.id.id, self.schedule.id) + + +class PlacementApplicationResponse(models.Model): + application = models.ForeignKey(PlacementApplication, on_delete=models.CASCADE) + field = models.ForeignKey(PlacementField, on_delete=models.CASCADE, null=True, blank=True) + value = models.TextField(max_length=5000, default='', blank=True) + + def __str__(self): + return '{} - {}'.format(self.application.id, self.field_id) + + +class PlacementRound(models.Model): + schedule = models.ForeignKey(PlacementSchedule, on_delete=models.CASCADE) + round_no = models.IntegerField(default=0) + test_date = models.DateField(null=True, blank=True) + start_datetime = models.DateTimeField(null=True, blank=True) + end_datetime = models.DateTimeField(null=True, blank=True) + mode = models.CharField(max_length=30, default='', blank=True) + location_link = models.CharField(max_length=255, default='', blank=True) + description = models.TextField(max_length=1000, default='', blank=True) + test_type = models.CharField(max_length=100, default='', blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ('round_no', 'created_at') + indexes = [ + models.Index(fields=['schedule', 'round_no']), + models.Index(fields=['schedule', 'start_datetime']), + ] + + def __str__(self): + return '{} - {}'.format(self.schedule.id, self.round_no) + + +class PlacementApplicationTimeline(models.Model): + application = models.ForeignKey(PlacementApplication, on_delete=models.CASCADE, related_name='timeline_entries') + stage = models.CharField(max_length=100, default='') + remarks = models.TextField(max_length=1000, default='', blank=True) + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ('created_at', 'id') + indexes = [ + models.Index(fields=['application', 'created_at']), + ] + + def __str__(self): + return '{} - {}'.format(self.application.id, self.stage) + + +class PlacementInterviewSchedule(models.Model): + OUTCOME_CHOICES = ( + ('pending', 'Pending'), + ('passed', 'Passed'), + ('failed', 'Failed'), + ('selected', 'Selected'), + ) + + application = models.ForeignKey(PlacementApplication, on_delete=models.CASCADE, related_name='interview_schedules') + round_no = models.IntegerField(default=1) + title = models.CharField(max_length=100, default='', blank=True) + scheduled_at = models.DateTimeField() + end_datetime = models.DateTimeField(null=True, blank=True) + mode = models.CharField(max_length=30, default='', blank=True) + location = models.CharField(max_length=255, default='', blank=True) + meeting_link = models.CharField(max_length=255, default='', blank=True) + remarks = models.TextField(max_length=1000, default='', blank=True) + outcome = models.CharField(max_length=20, choices=OUTCOME_CHOICES, default='pending') + is_active = models.BooleanField(default=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ('-scheduled_at', '-id') + indexes = [ + models.Index(fields=['application', 'scheduled_at']), + models.Index(fields=['application', 'round_no']), + ] + + def __str__(self): + return '{} - {}'.format(self.application.id, self.title or self.round_no) + + +class PlacementRestriction(models.Model): + criteria = models.CharField(max_length=50) + condition = models.CharField(max_length=50) + value = models.CharField(max_length=255) + description = models.TextField(max_length=1000, default='', blank=True) + + def __str__(self): + return '{} - {}'.format(self.criteria, self.condition) + + +class PlacementPolicy(models.Model): + title = models.CharField(max_length=150) + description = models.TextField(max_length=3000) + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ('-updated_at', '-id') + + def __str__(self): + return self.title + + +class PlacementProfileDocument(models.Model): + student = models.ForeignKey(Student, on_delete=models.CASCADE) + name = models.CharField(max_length=100, default='Supporting Document') + document = models.FileField(upload_to='documents/placement/profile_documents') + uploaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ('-uploaded_at', '-id') + indexes = [ + models.Index(fields=['student', 'uploaded_at']), + ] + + def __str__(self): + return '{} - {}'.format(self.student.id.id, self.name) + + +class PlacementProfileAuditLog(models.Model): + student = models.ForeignKey(Student, on_delete=models.CASCADE) + actor = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True) + action = models.CharField(max_length=100) + details = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ('-created_at', '-id') + + def __str__(self): + return '{} - {}'.format(self.student.id.id, self.action) + + +class PlacementNotificationPreference(models.Model): + student = models.OneToOneField(Student, on_delete=models.CASCADE) + enable_portal = models.BooleanField(default=True) + enable_email = models.BooleanField(default=True) + enable_sms = models.BooleanField(default=False) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return '{}'.format(self.student.id.id) + + +class PlacementAppeal(models.Model): + STATUS_CHOICES = ( + ('pending', 'Pending'), + ('reviewed', 'Reviewed'), + ('accepted', 'Accepted'), + ('rejected', 'Rejected'), + ) + + student = models.ForeignKey(Student, on_delete=models.CASCADE) + placement_status = models.ForeignKey(PlacementStatus, on_delete=models.CASCADE) + reason = models.TextField(max_length=2000) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + response = models.TextField(max_length=2000, blank=True, default='') + created_at = models.DateTimeField(auto_now_add=True) + reviewed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = (('student', 'placement_status'),) + + def __str__(self): + return f"Appeal by {self.student.id} for {self.placement_status.id}" + + +class PlacementReportSchedule(models.Model): + FREQUENCY_CHOICES = ( + ('daily', 'Daily'), + ('weekly', 'Weekly'), + ('monthly', 'Monthly'), + ) + + REPORT_TYPE_CHOICES = ( + ('batch', 'Batch Summary'), + ('company', 'Company Summary'), + ('branch', 'Branch Summary'), + ('custom', 'Custom Report'), + ) + + FORMAT_CHOICES = ( + ('excel', 'Excel'), + ('pdf', 'PDF'), + ) + + name = models.CharField(max_length=120) + report_type = models.CharField(max_length=20, choices=REPORT_TYPE_CHOICES, default='custom') + frequency = models.CharField(max_length=20, choices=FREQUENCY_CHOICES, default='weekly') + export_format = models.CharField(max_length=20, choices=FORMAT_CHOICES, default='excel') + filters = models.JSONField(default=dict, blank=True) + recipients = models.TextField(max_length=500, default='', blank=True) + is_active = models.BooleanField(default=True) + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + last_run_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ('-updated_at', '-id') + + def __str__(self): + return self.name + + +class AlumniProfile(models.Model): + STATUS_CHOICES = ( + ('pending', 'Pending'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ) + + user = models.OneToOneField(User, on_delete=models.CASCADE) + graduation_year = models.IntegerField() + degree = models.CharField(max_length=100, default='', blank=True) + current_company = models.CharField(max_length=150, default='', blank=True) + current_designation = models.CharField(max_length=150, default='', blank=True) + linkedin_url = models.URLField(blank=True, default='') + verification_document = models.FileField( + upload_to='documents/placement/alumni_verification', + null=True, + blank=True, + ) + verification_notes = models.TextField(max_length=1000, default='', blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + topics = models.TextField(max_length=1000, default='', blank=True) + availability = models.CharField(max_length=200, default='', blank=True) + bio = models.TextField(max_length=1500, default='', blank=True) + mentorship_enabled = models.BooleanField(default=False) + approved_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='approved_alumni_profiles', + ) + approved_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ('-updated_at', '-id') + + def __str__(self): + return '{} - {}'.format(self.user.username, self.status) + + +class AlumniMentorshipSession(models.Model): + STATUS_CHOICES = ( + ('requested', 'Requested'), + ('scheduled', 'Scheduled'), + ('completed', 'Completed'), + ('cancelled', 'Cancelled'), + ) + + alumni = models.ForeignKey(AlumniProfile, on_delete=models.CASCADE, related_name='sessions') + student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name='alumni_sessions') + topic = models.CharField(max_length=150) + agenda = models.TextField(max_length=1500, default='', blank=True) + scheduled_at = models.DateTimeField() + mode = models.CharField(max_length=50, default='online', blank=True) + meeting_link = models.CharField(max_length=300, default='', blank=True) + student_message = models.TextField(max_length=1500, default='', blank=True) + alumni_message = models.TextField(max_length=1500, default='', blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='requested') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ('scheduled_at', '-id') + + def __str__(self): + return '{} - {}'.format(self.alumni.user.username, self.student.id.id) + + +class AlumniReferral(models.Model): + alumni = models.ForeignKey(AlumniProfile, on_delete=models.CASCADE, related_name='referrals') + title = models.CharField(max_length=150) + company = models.CharField(max_length=150) + location = models.CharField(max_length=150, default='', blank=True) + application_url = models.URLField(blank=True, default='') + description = models.TextField(max_length=2000) + expires_at = models.DateField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ('-created_at', '-id') + + def __str__(self): + return '{} - {}'.format(self.company, self.title) + + +class AlumniConnection(models.Model): + STATUS_CHOICES = ( + ('pending', 'Pending'), + ('connected', 'Connected'), + ('rejected', 'Rejected'), + ) + + alumni = models.ForeignKey(AlumniProfile, on_delete=models.CASCADE, related_name='connections') + student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name='alumni_connections') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + message = models.TextField(max_length=1000, default='', blank=True) + responded_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='alumni_connection_responses', + ) + responded_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = (('alumni', 'student'),) + ordering = ('-created_at', '-id') + + def __str__(self): + return '{} - {}'.format(self.alumni.user.username, self.student.id.id) + + +class PlacementAnnouncement(models.Model): + """A placement-cell announcement broadcast to all roles, postable by the TPO.""" + + title = models.CharField(max_length=300) + body = models.TextField() + posted_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='placement_announcements', + ) + posted_at = models.DateTimeField(auto_now_add=True) + is_pinned = models.BooleanField(default=False) + + class Meta: + ordering = ('-is_pinned', '-posted_at') + + def __str__(self): + return self.title + + +class OffCampusPlacement(models.Model): + """An off-campus offer recorded by the TPO against a student roll number.""" + + PLACEMENT = 'placement' + INTERNSHIP = 'internship' + TYPE_CHOICES = ( + (PLACEMENT, 'Placement'), + (INTERNSHIP, 'Internship'), + ) + + student = models.ForeignKey( + ExtraInfo, + on_delete=models.CASCADE, + related_name='offcampus_placements', + ) + company_name = models.CharField(max_length=255) + role = models.CharField(max_length=200) + offer_type = models.CharField(max_length=20, choices=TYPE_CHOICES, default=PLACEMENT) + ctc = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + stipend = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + offer_date = models.DateField() + notes = models.TextField(blank=True) + added_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='added_offcampus_placements', + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ('-offer_date', '-id') + + def __str__(self): + return '{} - {} ({})'.format( + self.student.user.username, self.company_name, self.offer_type + ) + + +class PlacementCalendarEvent(models.Model): + """A free-form calendar entry the placement cell can add by clicking a date. + + These are separate from drive schedules/rounds: the TPO can drop any note, + deadline or event onto the placement calendar (interview slots, info + sessions, document deadlines, etc.). Students only ever read them. + """ + + EVENT = 'event' + DRIVE = 'drive' + TEST = 'test' + INTERVIEW = 'interview' + DEADLINE = 'deadline' + OTHER = 'other' + CATEGORY_CHOICES = ( + (EVENT, 'Event'), + (DRIVE, 'Drive'), + (TEST, 'Online Test'), + (INTERVIEW, 'Interview'), + (DEADLINE, 'Deadline'), + (OTHER, 'Other'), + ) + + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + start = models.DateTimeField() + end = models.DateTimeField(null=True, blank=True) + all_day = models.BooleanField(default=False) + category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default=EVENT) + location = models.CharField(max_length=255, blank=True) + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='placement_calendar_events', + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ('start', 'id') + + def __str__(self): + return self.title diff --git a/FusionIIIT/applications/placement_cell/selectors.py b/FusionIIIT/applications/placement_cell/selectors.py new file mode 100644 index 000000000..af1c3d8d9 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/selectors.py @@ -0,0 +1,187 @@ +from datetime import date + +from django.contrib.auth.models import User +from django.core.serializers import serialize +from django.db.models import Q + +from applications.academic_information.models import Student +from applications.globals.models import ExtraInfo, HoldsDesignation + +from .models import ( + AlumniProfile, + ChairmanVisit, + CompanyDetails, + NotifyStudent, + PlacementRecord, + PlacementSchedule, + PlacementStatus, + Reference, + Role, +) + + +def get_profile_for_user(user): + return ExtraInfo.objects.get(user=user) + + +def get_student_for_user(user): + profile = get_profile_for_user(user) + return Student.objects.get(id=profile.id) + + +def get_designation_queryset(user, designation_name): + return HoldsDesignation.objects.filter( + Q(working=user, designation__name=designation_name) + ) + + +def get_reference_list_json(student): + reference_objects = Reference.objects.select_related("unique_id").filter( + unique_id=student + ) + return serialize("json", list(reference_objects)) + + +def get_company_names_by_prefix(current_value): + return list( + CompanyDetails.objects.filter( + Q(company_name__startswith=current_value) + ).values_list("company_name", flat=True) + ) + + +def get_role_names_by_prefix(current_value): + return list( + Role.objects.filter(Q(role__startswith=current_value)).values_list( + "role", flat=True + ) + ) + + +def get_upcoming_schedule_notify_ids(): + return PlacementSchedule.objects.select_related("notify_id").filter( + Q(placement_date__gte=date.today()) + ).values_list("notify_id", flat=True) + + +def get_student_upcoming_placement_status(student): + return PlacementStatus.objects.select_related("unique_id", "notify_id").filter( + Q(unique_id=student, notify_id__in=get_upcoming_schedule_notify_ids()) + ).order_by("-timestamp") + + +def get_all_schedules(): + return PlacementSchedule.objects.select_related("notify_id").all() + + +def get_schedule_by_pk(schedule_id): + return PlacementSchedule.objects.select_related("notify_id").get(pk=schedule_id) + + +def get_placement_status_by_pk(status_id): + return PlacementStatus.objects.select_related("unique_id", "notify_id").get( + pk=status_id + ) + + +def get_or_create_company_detail(company_name): + company_detail = CompanyDetails.objects.filter(company_name=company_name).first() + if company_detail is None: + company_detail = CompanyDetails.objects.create(company_name=company_name) + return company_detail + + +def get_or_create_role(role_name): + role = Role.objects.filter(role=role_name).first() + if role is None: + role = Role.objects.create(role=role_name) + return role + + +def get_all_notify_students(): + return NotifyStudent.objects.all() + + +def get_all_roles(): + return Role.objects.all() + + +def get_all_chairman_visits(): + return ChairmanVisit.objects.all() + + +def delete_placement_record_by_id(record_id): + return PlacementRecord.objects.filter(id=record_id).delete() + + +def get_user_by_username(username): + return User.objects.get(username=username) + + +def get_alumni_profile_for_user(user): + return AlumniProfile.objects.filter(user=user).first() + + +def is_student(user): + return get_designation_queryset(user, "student").exists() + + +def is_alumni(user): + return get_designation_queryset(user, "alumni").exists() + + +def is_tpo(user): + return get_designation_queryset(user, "placement officer").exists() or get_designation_queryset(user, "placement chairman").exists() + + +def get_student_published_cpi(student_extra_info): + """Return a student's CPI from the latest *published* semester result. + + Computes the CPI from the examination module's announced results so the + placement cell reflects officially published academic performance rather + than the static ``Student.cpi`` snapshot. Returns ``None`` when the + student's batch has no announced result yet (or anything goes wrong). + """ + try: + from applications.examination.models import ResultAnnouncement + from applications.examination.api.views import calculate_cpi_for_student + + student_obj = Student.objects.select_related('id').get(id=student_extra_info) + + latest = ( + ResultAnnouncement.objects + .filter(batch=student_obj.batch_id, announced=True) + .order_by('-semester') + .first() + ) + if not latest: + return None + + cpi_value, _, _ = calculate_cpi_for_student( + student_obj, latest.semester, latest.semester_type + ) + return cpi_value + except Exception: + return None + + +def batches_with_published_results(): + """Batches that have at least one announced result, newest first. + + Used to populate the batch filter for the published-CPI student view. + """ + from applications.examination.models import ResultAnnouncement + from applications.programme_curriculum.models import Batch + + batch_ids = ( + ResultAnnouncement.objects + .filter(announced=True) + .values_list('batch_id', flat=True) + .distinct() + ) + return ( + Batch.objects + .filter(id__in=batch_ids) + .select_related('discipline') + .order_by('-year', 'name') + ) diff --git a/FusionIIIT/applications/placement_cell/services.py b/FusionIIIT/applications/placement_cell/services.py new file mode 100644 index 000000000..b05c12b1a --- /dev/null +++ b/FusionIIIT/applications/placement_cell/services.py @@ -0,0 +1,121 @@ +from django.core.exceptions import ValidationError +from django.utils import timezone + +from . import selectors +from .models import ( + ChairmanVisit, + NotifyStudent, + PlacementRecord, + PlacementSchedule, + PlacementStatus, +) + + +def _today(): + return timezone.now().date() + + +def update_invitation_status(status_id, invitation): + return ( + PlacementStatus.objects.select_related("unique_id", "notify_id") + .filter(pk=status_id, invitation="PENDING") + .update(invitation=invitation, timestamp=timezone.now()) + ) + + +def delete_invitation_status(status_id): + selectors.get_placement_status_by_pk(status_id).delete() + + +def delete_schedule(schedule_id): + placement_schedule = selectors.get_schedule_by_pk(schedule_id) + NotifyStudent.objects.get(pk=placement_schedule.notify_id.id).delete() + placement_schedule.delete() + + +def create_schedule_and_notification( + *, + placement_type, + company_name, + ctc, + description, + placement_date, + location, + time, + role_name, + attached_file=None, + timestamp=None, +): + company = selectors.get_or_create_company_detail(company_name) + role = selectors.get_or_create_role(role_name) + placement_date_value = placement_date + if hasattr(placement_date_value, "date"): + placement_date_value = placement_date_value.date() + if placement_date_value and placement_date_value < _today(): + raise ValidationError("Placement date cannot be in the past.") + + notify = NotifyStudent.objects.create( + placement_type=placement_type, + company_name=company_name, + description=description, + ctc=ctc, + timestamp=timestamp or timezone.now(), + ) + schedule = PlacementSchedule.objects.create( + notify_id=notify, + title=company_name, + description=description, + placement_date=placement_date, + attached_file=attached_file, + role=role, + location=location, + time=time, + company=company, + ) + notify.save() + schedule.save() + return notify, schedule + + +def create_placement_record( + *, + placement_type, + student_name, + ctc, + year, + test_type, + test_score, +): + record = PlacementRecord.objects.create( + placement_type=placement_type, + name=student_name, + ctc=ctc, + year=year, + test_type=test_type, + test_score=test_score, + ) + record.save() + return record + + +def create_chairman_visit( + *, + company_name, + location, + visiting_date, + description, + timestamp, + start_date=None, + end_date=None, +): + record = ChairmanVisit.objects.create( + company_name=company_name, + location=location, + visiting_date=visiting_date, + start_date=start_date or visiting_date, + end_date=end_date, + description=description, + timestamp=timestamp, + ) + record.save() + return record diff --git a/FusionIIIT/applications/placement_cell/templatetags/pdf_filters.py b/FusionIIIT/applications/placement_cell/templatetags/pdf_filters.py deleted file mode 100644 index 5d35dca0b..000000000 --- a/FusionIIIT/applications/placement_cell/templatetags/pdf_filters.py +++ /dev/null @@ -1,18 +0,0 @@ -import base64 -import io -import urllib - -from django import template - -register = template.Library() - -@register.filter -def get64(url): - """ - Method returning base64 image data instead of URL - """ - if url.startswith("http"): - image = io.StringIO(urllib.urlopen(url).read()) - return 'data:image/jpg;base64,' + base64.b64encode(image.read()) - - return url diff --git a/FusionIIIT/applications/placement_cell/tests.py b/FusionIIIT/applications/placement_cell/tests.py deleted file mode 100644 index e9137c85e..000000000 --- a/FusionIIIT/applications/placement_cell/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.test import TestCase - -# Create your tests here. diff --git a/FusionIIIT/applications/placement_cell/tests/README.md b/FusionIIIT/applications/placement_cell/tests/README.md new file mode 100644 index 000000000..b5d94cc47 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/README.md @@ -0,0 +1,83 @@ +# Placement Cell test suite + +Reliable, self-contained tests for the `placement_cell` API module. + +## Roles + +The placement module recognises four roles (the user's selected designation, +exposed to the frontend as `state.user.role`): + +| Role (designation) | What the role can do | +|---------------------|----------------------| +| `placement officer` | TPO: manage placement schedules, applications, interview rounds, debarments, restrictions, statistics, CV downloads and notifications | +| `placement chairman`| Admin: manage placement policies plus the officer capabilities | +| `student` | Apply for placements, manage placement profile, view schedule/offers, download CV | +| `alumni` | Alumni hub: alumni profile, referrals and mentorship sessions | + +How roles are resolved: + +- **Backend** authorizes via `HoldsDesignation(working=user, designation__name=…)`; + `selectors.is_tpo` is true for `placement officer`/`placement chairman`. +- **Sidebar visibility** comes from `ModuleAccess.placement_cell` for the + designation (surfaced by `/api/auth/me`). +- **Frontend** picks the tab set in `PlacementCellPage` from `state.user.role`. + +### Role accounts + +`manage.py setup_placement_roles` creates one idempotent login per role and +enables `ModuleAccess.placement_cell` for each: + +| Username | Role | +|----------------------|---------------------| +| `placement_officer` | placement officer | +| `placement_chairman` | placement chairman | +| `placement_student` | student | +| `placement_alumni` | alumni | + +Create / refresh them (the password is supplied at runtime and is **not** stored +in this repo — pass `--password` or set `PLACEMENT_ROLE_PASSWORD`): + +```bash +cd FusionIIIT +python manage.py setup_placement_roles --password '' +``` + +## Running + +Tests use a dedicated settings module (`FusionIIIT/test_settings.py`) that +disables migrations so the test database is built directly from the current +models. This is required because the project's historical migration chain does +not apply on a fresh database (e.g. `programme_curriculum.0026`), which would +otherwise break test-DB creation for reasons unrelated to placement. + +List the modules explicitly — `applications/` has no `__init__.py`, so unittest +package/app-level discovery (`manage.py test applications.placement_cell`) fails +project-wide: + +```bash +cd FusionIIIT +python manage.py test \ + applications.placement_cell.tests.test_placement_api \ + applications.placement_cell.tests.test_use_cases \ + applications.placement_cell.tests.test_business_rules \ + applications.placement_cell.tests.test_workflows \ + applications.placement_cell.tests.test_module \ + --settings=test_settings +``` + +Requires `PyYAML` (declared in `requirements.txt`) for the spec-driven modules. + +## Layout + +| File | What it covers | +|------|----------------| +| `test_placement_api.py` | Schema regressions (e.g. `Education.grade` width), URL wiring is API-only, authentication + role authorization. Has no external deps. | +| `test_use_cases.py` | Use-case scenarios from `specs/use_cases.yaml`. | +| `test_business_rules.py` | Business rules from `specs/business_rules.yaml`. | +| `test_workflows.py` | End-to-end workflows from `specs/workflows.yaml`. | +| `test_module.py` | Selector/service unit tests (active). The `PlacementCellApiTests` class is **skipped**: it exercises the `globals` dashboard-notification API (`NotificationList`, `Notification.module`) that is not present on this branch. Re-enable once that lands. | + +## Expected result + +`OK (skipped=36)` — all executed tests pass; the only skips are the documented +cross-module integration tests in `test_module.PlacementCellApiTests`. diff --git a/FusionIIIT/applications/placement_cell/tests/__init__.py b/FusionIIIT/applications/placement_cell/tests/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/FusionIIIT/applications/placement_cell/tests/conftest.py b/FusionIIIT/applications/placement_cell/tests/conftest.py new file mode 100644 index 000000000..4888e4fa8 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/conftest.py @@ -0,0 +1,222 @@ +import datetime +from decimal import Decimal +from pathlib import Path + +import yaml +from django.contrib.auth.models import User +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from applications.academic_information.models import Student +from applications.globals.models import DepartmentInfo, Designation, ExtraInfo, HoldsDesignation +from applications.placement_cell.models import ( + Education, + Has, + NotifyStudent, + PlacementProfileDocument, + PlacementSchedule, + Project, + Skill, +) + + +class PlacementCellSpecBase(TestCase): + specs_dir = Path(__file__).resolve().parent / "specs" + + @classmethod + def load_spec(cls, filename, item_key, item_id): + with (cls.specs_dir / filename).open("r", encoding="utf-8") as spec_file: + payload = yaml.safe_load(spec_file) or {} + for item in payload.get(item_key, []): + if item.get("id") == item_id: + return item + raise AssertionError("Specification {}:{} not found".format(filename, item_id)) + + def setUp(self): + self.department_cse = DepartmentInfo.objects.create(name="CSE") + self.department_ece = DepartmentInfo.objects.create(name="ECE") + self.student_designation = Designation.objects.create(name="student", full_name="Student") + self.officer = User.objects.create_user( + username="officer", + password="password", + email="officer@example.com", + ) + self.student_user = self._create_student_user( + roll_no="2023001", + username="student1", + department=self.department_cse, + ) + self.other_student_user = self._create_student_user( + roll_no="2023002", + username="student2", + department=self.department_ece, + ) + # self.officer performs Training & Placement Officer actions across the + # suite, so grant the designation here. Denial tests use student/alumni + # users (never self.officer), so this does not weaken them. + self._create_officer_designation() + + def api_get(self, path, *, user=None, data=None): + client = APIClient() + if user is not None: + client.force_authenticate(user=user) + return client.get(path, data=data or {}, format="json") + + def api_post(self, path, *, user=None, data=None, format="json"): + client = APIClient() + if user is not None: + client.force_authenticate(user=user) + return client.post(path, data=data or {}, format=format) + + def api_put(self, path, *, user=None, data=None, format="json"): + client = APIClient() + if user is not None: + client.force_authenticate(user=user) + return client.put(path, data=data or {}, format=format) + + def api_delete(self, path, *, user=None, data=None): + client = APIClient() + if user is not None: + client.force_authenticate(user=user) + return client.delete(path, data=data or {}, format="json") + + def _create_student_user(self, *, roll_no, username, department): + user = User.objects.create_user( + username=username, + password="password", + email="{}@example.com".format(username), + first_name=username, + ) + extra, _ = ExtraInfo.objects.get_or_create( + user=user, + defaults={"id": roll_no, "user_type": "student", "department": department}, + ) + extra.user_type = "student" + extra.department = department + extra.save(update_fields=["user_type", "department"]) + Student.objects.create( + id=extra, + programme="B.Tech", + batch=2026, + cpi=8.5, + category="GEN", + ) + HoldsDesignation.objects.create( + user=user, + working=user, + designation=self.student_designation, + ) + return user + + def _create_officer_designation(self, name="placement officer"): + designation, _ = Designation.objects.get_or_create(name=name, defaults={"full_name": name.title()}) + HoldsDesignation.objects.get_or_create( + user=self.officer, + working=self.officer, + designation=designation, + ) + return designation + + def _create_officer_designation_for_user(self, user, name="placement officer"): + designation, _ = Designation.objects.get_or_create(name=name, defaults={"full_name": name.title()}) + extra, _ = ExtraInfo.objects.get_or_create( + user=user, + defaults={ + "id": user.username, + "user_type": "staff", + "department": self.department_cse, + }, + ) + extra.user_type = "staff" + extra.department = self.department_cse + extra.save(update_fields=["user_type", "department"]) + HoldsDesignation.objects.get_or_create( + user=user, + working=user, + designation=designation, + ) + return designation + + def _get_student(self, user): + return Student.objects.get(id__user=user) + + def _create_schedule( + self, + *, + company_name, + placement_type="PLACEMENT", + placement_date=None, + role=None, + location="Campus", + ctc="10.00", + ): + notify = NotifyStudent.objects.create( + placement_type=placement_type, + company_name=company_name, + ctc=Decimal(ctc), + description="Campus drive", + ) + return PlacementSchedule.objects.create( + notify_id=notify, + title=company_name, + placement_date=placement_date or (timezone.now().date() + datetime.timedelta(days=2)), + location=location, + description="Campus drive", + time=datetime.time(10, 0), + schedule_at=timezone.now(), + role=role, + ) + + def _make_profile_complete(self, user): + student = self._get_student(user) + response = self.api_put( + "/placement/api/profile/", + user=user, + data={ + "first_name": "Student", + "last_name": "One", + "email": "{}@example.com".format(user.username), + "phone_no": "9876543210", + "address": "Hostel A", + "about_me": "Ready for placements", + }, + format="multipart", + ) + self.assertEqual(response.status_code, 200) + Education.objects.get_or_create( + unique_id=student, + degree="B.Tech", + grade="90", + institute="IIITDMJ", + sdate=datetime.date(2020, 1, 1), + edate=datetime.date(2021, 1, 1), + ) + skill, _ = Skill.objects.get_or_create(skill="Python") + Has.objects.get_or_create( + skill_id=skill, + unique_id=student, + defaults={"skill_rating": 80}, + ) + Project.objects.get_or_create( + unique_id=student, + project_name="Capstone", + defaults={ + "project_status": "COMPLETED", + "sdate": datetime.date(2020, 1, 1), + "edate": datetime.date(2020, 2, 1), + }, + ) + PlacementProfileDocument.objects.get_or_create( + student=student, + name="Resume", + defaults={ + "document": SimpleUploadedFile( + "resume.pdf", + b"pdf-content", + content_type="application/pdf", + ) + }, + ) + return student diff --git a/FusionIIIT/applications/placement_cell/tests/reports/.gitkeep b/FusionIIIT/applications/placement_cell/tests/reports/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/reports/.gitkeep @@ -0,0 +1 @@ + diff --git a/FusionIIIT/applications/placement_cell/tests/reports/Placement_Cell_Test_Report_Submission.rtf b/FusionIIIT/applications/placement_cell/tests/reports/Placement_Cell_Test_Report_Submission.rtf new file mode 100644 index 000000000..75b297dfd --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/reports/Placement_Cell_Test_Report_Submission.rtf @@ -0,0 +1,130 @@ +{\rtf1\ansi\deff0 +{\fonttbl{\f0 Calibri;}{\f1 Cambria;}} +\paperw12240\paperh15840\margl1440\margr1440\margt1440\margb1440 +\fs24 +\pard\qc\b\f1\fs32 Placement Cell Module\par +\pard\qc\b\f1\fs28 Submission Report\par +\pard\sa160\sb160\b0\f0\fs24 +Course Artifact: Testing and Quality Evaluation Report\par +Module: Placement Cell\par +Execution Date: April 22, 2026\par +Project: Fusion\par +Test Basis: Authored specifications in placement\_cell/tests/specs, automated tests in applications/placement\_cell/tests, and workbook evidence prepared for the module.\par + +\pard\sa200\sb120\b\f1\fs28 1. Introduction\par +\pard\sa120\sb120\b0\f0\fs24 +This report presents the testing outcome for the Placement Cell module of the Fusion system. The purpose of the report is to summarize test adequacy, document the outcome of automated test execution, identify the most important failures and defects, and provide a final evaluation of the module from a deployment-readiness perspective.\par +The report is based on the Placement Cell specification assets already prepared for the assignment:\par +\pard\li360\sa80\sb80\f0\fs24 \'95 22 documented use cases\par +\'95 40 documented business rules\par +\'95 10 documented workflows\par +\'95 166 workbook test design entries\par +\pard\sa120\sb120\b0\f0\fs24 +An automated execution of the Placement Cell test suite was also performed to validate the implemented behavior against the documented expectations.\par + +\pard\sa200\sb120\b\f1\fs28 2. Test Adequacy Summary\par +\pard\sa120\sb120\b0\f0\fs24 +The module shows good overall test adequacy in terms of specification coverage. The available tests cover the major functional areas expected in a Placement Cell platform.\par +\pard\li360\sa80\sb80\f0\fs24 \'95 profile creation and profile update validation\par +\'95 document upload validation\par +\'95 job browsing and job application behavior\par +\'95 duplicate-application prevention\par +\'95 offer visibility and tracking\par +\'95 interview scheduling workflows\par +\'95 alumni registration and alumni engagement features\par +\'95 statistics and reporting APIs\par +\'95 rule-based and workflow-based traceability from YAML specifications\par +\pard\sa120\sb120\b0\f0\fs24 +This level of coverage is a positive indicator because it demonstrates that the module has been tested not only through direct API checks, but also against structured use cases, business rules, and end-to-end workflows.\par +However, adequacy in design does not fully translate into adequacy in execution. Several critical failures were found in business-sensitive features such as schedule creation, company-management flow, interview scheduling, placement finalization, and resume generation.\par +\b Adequate in planned test coverage, but only partially adequate in executed behavior.\b0\par + +\pard\sa200\sb120\b\f1\fs28 3. Automated Test Execution Report\par +\pard\sa120\sb120\b0\f0\fs24 +The Placement Cell automated suite was executed using the Django test command:\par +\pard\li360\sa80\sb80\f0\fs24 manage.py test applications.placement\_cell.tests --verbosity 2\par +\pard\sa120\sb120\b0\f0\fs24 +The test environment initialized successfully, migrations completed successfully, and the execution finished normally. The failures observed were application-level failures.\par +\b Execution Summary\b0\par +\pard\li360\sa80\sb80\f0\fs24 \'95 Total executed: 161\par +\'95 Passed: 138\par +\'95 Failed: 19\par +\'95 Errors: 4\par +\'95 Duration: about 9.3s\par +\pard\sa120\sb120\b0\f0\fs24 +The pass count shows that a large part of the module is already functional. At the same time, the 19 failures and 4 errors are too significant to ignore because they affect core placement workflows and management actions.\par +\b Completed with failures\b0\par + +\pard\sa200\sb120\b\f1\fs28 4. Key Failures Found\par +\pard\sa120\sb120\b\f0\fs24 4.1 Authorization failures in TPO management flows\par +\pard\sa120\sb120\b0\f0\fs24 +Multiple tests related to job posting, schedule creation, and company-management behavior failed because the API returned 403 Forbidden before functional validation could proceed. This affected business-rule tests and workflow tests that expected 200, 201, or 400 responses.\par +\pard\sa120\sb120\b\f0\fs24 4.2 Student placement listing contract mismatch\par +\pard\sa120\sb120\b0\f0\fs24 +The student-facing placement listing returned more schedule records than the test expected. This indicates a mismatch between implementation behavior and the intended API contract.\par +\pard\sa120\sb120\b\f0\fs24 4.3 Application-limit workflow inconsistency\par +\pard\sa120\sb120\b0\f0\fs24 +The active-application limit tests failed because the returned status codes did not match the expected outcomes. This suggests either incorrect validation order or a contract mismatch between implementation and tests.\par +\pard\sa120\sb120\b\f0\fs24 4.4 Interview scheduling failure\par +\pard\sa120\sb120\b0\f0\fs24 +The interview scheduling API returned 400 instead of successfully creating an interview entry, affecting a key recruitment transition.\par +\pard\sa120\sb120\b\f0\fs24 4.5 Profile editing payload mismatch\par +\pard\sa120\sb120\b0\f0\fs24 +The profile payload used for editing did not contain the expected skills structure in one of the tests. This may lead to frontend issues during profile editing.\par + +\pard\sa200\sb120\b\f1\fs28 5. Major Defects Identified\par +\pard\sa120\sb120\b\f0\fs24 Defect 1: Placement finalization crashes during reporting update\par +\pard\sa120\sb120\b0\f0\fs24 +Observed issue: NameError because StudentRecord is not defined.\par +Impact: Finalized application status cannot be safely converted into placement reporting or statistics records.\par +Severity: Critical\par +\pard\sa120\sb120\b\f0\fs24 Defect 2: Resume generation fails for non-numeric identifiers\par +\pard\sa120\sb120\b0\f0\fs24 +Observed issue: ValueError caused by numeric parsing assumptions.\par +Impact: Resume generation fails for certain identifier formats.\par +Severity: Critical\par +\pard\sa120\sb120\b\f0\fs24 Defect 3: Notification contract mismatch\par +\pard\sa120\sb120\b0\f0\fs24 +Observed issue: Notification verification fails because the expected metadata shape does not match the actual Notification model fields.\par +Impact: Notification validation is unreliable.\par +Severity: Major\par +\pard\sa120\sb120\b\f0\fs24 Defect 4: Profile payload missing editable skills data\par +\pard\sa120\sb120\b0\f0\fs24 +Observed issue: KeyError for skills.\par +Impact: Frontend edit workflows may fail or render incomplete data.\par +Severity: Major\par +\pard\sa120\sb120\b\f0\fs24 Defect 5: TPO authorization or role-selection behavior is not aligned with expected management access\par +\pard\sa120\sb120\b0\f0\fs24 +Observed issue: Multiple creation and workflow tests fail with 403 Forbidden.\par +Impact: Job posting, company-management, and workflow progression are blocked.\par +Severity: Critical\par + +\pard\sa200\sb120\b\f1\fs28 6. Changes Required Before Deployment\par +\pard\sa120\sb120\b\f0\fs24 6.1 Fix authorization for management endpoints\par +\pard\sa120\sb120\b0\f0\fs24 +The role and authorization checks for TPO and related placement-management users must be reviewed carefully. Deployment should proceed only after TPO users can create schedules, manage company records, and complete expected workflow actions successfully.\par +\pard\sa120\sb120\b\f0\fs24 6.2 Correct placement finalization and statistics integration\par +\pard\sa120\sb120\b0\f0\fs24 +The logic that creates or updates placement reporting records must be fixed so that selected applications can be finalized without runtime errors.\par +\pard\sa120\sb120\b\f0\fs24 6.3 Refactor CV generation to remove brittle identifier logic\par +\pard\sa120\sb120\b0\f0\fs24 +Resume generation should not depend on string slicing assumptions for profile identifiers. Roll-year or academic details should be taken from stable student data.\par +\pard\sa120\sb120\b\f0\fs24 6.4 Align API contracts with test and frontend expectations\par +\pard\sa120\sb120\b0\f0\fs24 +The response payloads for profile editing, placement listing, and notifications should be made consistent across backend implementation, automated tests, frontend usage, and documented specifications.\par +\pard\sa120\sb120\b\f0\fs24 6.5 Revalidate workflow-critical features\par +\pard\sa120\sb120\b0\f0\fs24 +The following flows should be re-tested after fixes: schedule creation, company registration, interview scheduling, application-limit enforcement, placement finalization, and resume generation.\par +\pard\sa120\sb120\b\f0\fs24 6.6 Strengthen deployment gate checks\par +\pard\sa120\sb120\b0\f0\fs24 +Before release, all critical and major defects should be resolved, the Placement Cell automated suite should pass for core workflows, and workbook evidence should be synchronized with automated evidence.\par + +\pard\sa200\sb120\b\f1\fs28 7. Final Module Evaluation\par +\pard\sa120\sb120\b0\f0\fs24 +The Placement Cell module has strong structural coverage and a substantial amount of implemented functionality. Many important features are already working correctly. However, the current version of the module cannot be recommended for deployment because the failed and errored tests affect core operational features.\par +\b Module status: Partially acceptable for submission, not acceptable for deployment in the current state.\b0\par +\pard\sa120\sb120\b0\f0\fs24 +The module is suitable for academic submission because test documentation exists, automated execution evidence exists, major risks have been identified clearly, and the testing process is traceable to specifications.\par +However, the module is not deployment-ready because critical defects still remain in TPO schedule and company-management authorization, placement finalization and reporting, interview scheduling, resume generation, and response contract consistency.\par +The recommended next step is to fix the deployment-blocking defects, run the full automated Placement Cell suite again, update the workbook where required, and only then consider the module ready for release.\par +} diff --git a/FusionIIIT/applications/placement_cell/tests/runner.py b/FusionIIIT/applications/placement_cell/tests/runner.py new file mode 100644 index 000000000..b7350ee27 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/runner.py @@ -0,0 +1,130 @@ +import csv +from pathlib import Path + +import yaml + + +BASE_DIR = Path(__file__).resolve().parent +SPECS_DIR = BASE_DIR / "specs" +REPORTS_DIR = BASE_DIR / "reports" + + +def _load_yaml(filename): + path = SPECS_DIR / filename + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + + +def _write_csv(path, headers, rows): + REPORTS_DIR.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=headers) + writer.writeheader() + for row in rows: + writer.writerow({header: row.get(header, "") for header in headers}) + + +def _collect_design_rows(items, item_id_key, scenario_keys): + rows = [] + for item in items: + item_id = item.get("id", "") + item_name = item.get("name", "") + endpoint = item.get("endpoint", "") + method = item.get("method", "") + description = item.get("description", "") + scenarios = item.get("scenarios", {}) + for scenario_key in scenario_keys: + scenario = scenarios.get(scenario_key, {}) + if not scenario: + continue + rows.append( + { + "test_id": scenario.get("_test_id", ""), + item_id_key: scenario.get("_{}_id".format(item_id_key.split("_")[0]), item_id), + "name": item_name, + "scenario": scenario.get("_scenario", ""), + "expected_result": scenario.get("_expected_result", ""), + "endpoint": endpoint, + "method": method, + "description": description, + } + ) + return rows + + +def generate_reports(): + use_cases = _load_yaml("use_cases.yaml").get("use_cases", []) + business_rules = _load_yaml("business_rules.yaml").get("business_rules", []) + workflows = _load_yaml("workflows.yaml").get("workflows", []) + + uc_rows = _collect_design_rows( + use_cases, + "uc_id", + ["happy_path", "alternate_path", "exception_path"], + ) + br_rows = _collect_design_rows( + business_rules, + "br_id", + ["valid", "invalid"], + ) + wf_rows = _collect_design_rows( + workflows, + "wf_id", + ["end_to_end", "negative"], + ) + + summary_rows = [ + {"artifact": "Use Cases", "count": len(uc_rows)}, + {"artifact": "Business Rules", "count": len(br_rows)}, + {"artifact": "Workflows", "count": len(wf_rows)}, + {"artifact": "Total Tests Designed", "count": len(uc_rows) + len(br_rows) + len(wf_rows)}, + ] + + _write_csv( + REPORTS_DIR / "Module_Test_Summary.csv", + ["artifact", "count"], + summary_rows, + ) + _write_csv( + REPORTS_DIR / "UC_Test_Design.csv", + ["test_id", "uc_id", "name", "scenario", "expected_result", "endpoint", "method", "description"], + uc_rows, + ) + _write_csv( + REPORTS_DIR / "BR_Test_Design.csv", + ["test_id", "br_id", "name", "scenario", "expected_result", "endpoint", "method", "description"], + br_rows, + ) + _write_csv( + REPORTS_DIR / "WF_Test_Design.csv", + ["test_id", "wf_id", "name", "scenario", "expected_result", "endpoint", "method", "description"], + wf_rows, + ) + _write_csv( + REPORTS_DIR / "Test_Execution_Log.csv", + ["test_id", "status", "executed_at", "notes"], + [], + ) + _write_csv( + REPORTS_DIR / "Defect_Log.csv", + ["defect_id", "test_id", "severity", "summary", "status"], + [], + ) + _write_csv( + REPORTS_DIR / "Artifact_Evaluation.csv", + ["artifact", "status", "remarks"], + [ + {"artifact": "use_cases.yaml", "status": "present", "remarks": ""}, + {"artifact": "business_rules.yaml", "status": "present", "remarks": ""}, + {"artifact": "workflows.yaml", "status": "present", "remarks": ""}, + {"artifact": "test_use_cases.py", "status": "present", "remarks": ""}, + {"artifact": "test_business_rules.py", "status": "present", "remarks": ""}, + {"artifact": "test_workflows.py", "status": "present", "remarks": ""}, + ], + ) + + +if __name__ == "__main__": + generate_reports() diff --git a/FusionIIIT/applications/placement_cell/tests/specs/business_rules.yaml b/FusionIIIT/applications/placement_cell/tests/specs/business_rules.yaml new file mode 100644 index 000000000..4452c2e60 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/specs/business_rules.yaml @@ -0,0 +1,720 @@ +business_rules: + - id: BR01 + source_id: BR-JM-SCHEDULE-DATE + name: Placement Date Must Not Be In The Past + endpoint: /placement/api/placement/ + method: POST + description: Placement schedules must be created only for current or future dates. + scenarios: + valid: + _test_id: BR01_V_01 + _br_id: BR01 + _scenario: Valid + _expected_result: Future-dated placement schedule is created successfully. + invalid: + _test_id: BR01_I_01 + _br_id: BR01 + _scenario: Invalid + _expected_result: Past-dated placement schedule is rejected. + + - id: BR02 + source_id: BR-SP-001 + name: Placement Profile Requires Mandatory Fields + endpoint: /placement/api/profile/ + method: PUT + description: Student profile must include valid mandatory contact and identity details. + scenarios: + valid: + _test_id: BR02_V_01 + _br_id: BR02 + _scenario: Valid + _expected_result: Valid profile payload is accepted. + invalid: + _test_id: BR02_I_01 + _br_id: BR02 + _scenario: Invalid + _expected_result: Invalid or blank mandatory fields are rejected. + + - id: BR03 + source_id: BR-JM-006 + name: Duplicate Applications Are Not Allowed + endpoint: /placement/api/apply-for-placement/ + method: POST + description: A student cannot submit more than one active application for the same job. + scenarios: + valid: + _test_id: BR03_V_01 + _br_id: BR03 + _scenario: Valid + _expected_result: First application submission succeeds. + invalid: + _test_id: BR03_I_01 + _br_id: BR03 + _scenario: Invalid + _expected_result: Second application submission for the same job is rejected. + + - id: BR04 + source_id: BR-SP-004 + name: Profile Update Frequency + endpoint: /placement/api/profile/ + method: PUT + description: Profile updates follow governance rules for restricted or controlled update windows. + scenarios: + valid: + _test_id: BR04_V_01 + _br_id: BR04 + _scenario: Valid + _expected_result: Profile update within allowed conditions is accepted. + invalid: + _test_id: BR04_I_01 + _br_id: BR04 + _scenario: Invalid + _expected_result: Restricted profile modification attempt is rejected. + + - id: BR05 + source_id: BR-SP-005 + name: Skills and Certifications + endpoint: /placement/api/profile/ + method: GET, PUT + description: Skills and certification data must be represented in a verifiable and usable manner. + scenarios: + valid: + _test_id: BR05_V_01 + _br_id: BR05 + _scenario: Valid + _expected_result: Verified skill and certification details are retained correctly. + invalid: + _test_id: BR05_I_01 + _br_id: BR05 + _scenario: Invalid + _expected_result: Missing or invalid proof leaves the record rejected or flagged. + + - id: BR06 + source_id: BR-JM-001 + name: Job Posting Approval + endpoint: /placement/api/placement/ + method: POST, PUT + description: Job postings must be reviewed and accepted according to placement management rules. + scenarios: + valid: + _test_id: BR06_V_01 + _br_id: BR06 + _scenario: Valid + _expected_result: Approved posting is created and can proceed in the workflow. + invalid: + _test_id: BR06_I_01 + _br_id: BR06 + _scenario: Invalid + _expected_result: Posting that fails approval constraints is rejected or not published. + + - id: BR07 + source_id: BR-JM-002 + name: Eligibility Criteria Enforcement + endpoint: /placement/api/apply-for-placement/ + method: POST + description: Students may apply only when they satisfy the job eligibility criteria. + scenarios: + valid: + _test_id: BR07_V_01 + _br_id: BR07 + _scenario: Valid + _expected_result: Eligible student can submit an application successfully. + invalid: + _test_id: BR07_I_01 + _br_id: BR07 + _scenario: Invalid + _expected_result: Ineligible student receives a rejection with reasons. + + - id: BR08 + source_id: BR-JM-003 + name: Application Deadline Management + endpoint: /placement/api/apply-for-placement/ + method: POST + description: Job applications are allowed only while the relevant deadline window is open. + scenarios: + valid: + _test_id: BR08_V_01 + _br_id: BR08 + _scenario: Valid + _expected_result: Application submitted before the deadline is accepted. + invalid: + _test_id: BR08_I_01 + _br_id: BR08 + _scenario: Invalid + _expected_result: Application submitted after the deadline is rejected. + + - id: BR09 + source_id: BR-JM-004 + name: Duplicate Application Prevention + endpoint: /placement/api/apply-for-placement/ + method: POST + description: The system must prevent duplicate applications for the same schedule by the same student. + scenarios: + valid: + _test_id: BR09_V_01 + _br_id: BR09 + _scenario: Valid + _expected_result: First-time application succeeds. + invalid: + _test_id: BR09_I_01 + _br_id: BR09 + _scenario: Invalid + _expected_result: Repeated application for the same job is rejected. + + - id: BR10 + source_id: BR-JM-005 + name: Application Information Integrity + endpoint: /placement/api/application-detail/{application_id}/ + method: GET, PUT + description: Application records must remain accurate, traceable, and internally consistent. + scenarios: + valid: + _test_id: BR10_V_01 + _br_id: BR10 + _scenario: Valid + _expected_result: Valid application status update preserves a consistent application record. + invalid: + _test_id: BR10_I_01 + _br_id: BR10 + _scenario: Invalid + _expected_result: Invalid application mutation is rejected to preserve integrity. + + - id: BR11 + source_id: BR-JM-006 + name: Company-Student Application Limits + endpoint: /placement/api/apply-for-placement/ + method: POST + description: The system enforces configured limits on active student applications. + scenarios: + valid: + _test_id: BR11_V_01 + _br_id: BR11 + _scenario: Valid + _expected_result: Application is accepted while the active application count is within limit. + invalid: + _test_id: BR11_I_01 + _br_id: BR11 + _scenario: Invalid + _expected_result: Application is rejected when the active application limit is exceeded. + + - id: BR12 + source_id: BR-JM-007 + name: Job Posting Content Standards + endpoint: /placement/api/placement/ + method: POST, PUT + description: Job postings must contain valid descriptive and structural information. + scenarios: + valid: + _test_id: BR12_V_01 + _br_id: BR12 + _scenario: Valid + _expected_result: Posting with required content and structure is accepted. + invalid: + _test_id: BR12_I_01 + _br_id: BR12 + _scenario: Invalid + _expected_result: Posting with malformed or incomplete content is rejected. + + - id: BR13 + source_id: BR-IP-001 + name: Interview Scheduling Constraints + endpoint: /placement/api/application-detail/{application_id}/interview/ + method: POST + description: Interview scheduling must satisfy timing and conflict constraints. + scenarios: + valid: + _test_id: BR13_V_01 + _br_id: BR13 + _scenario: Valid + _expected_result: Valid interview slot is scheduled successfully. + invalid: + _test_id: BR13_I_01 + _br_id: BR13 + _scenario: Invalid + _expected_result: Conflicting or malformed interview schedule is rejected. + + - id: BR14 + source_id: BR-IP-002 + name: Shortlisting Criteria + endpoint: /placement/api/application-detail/{application_id}/ + method: PUT + description: Shortlisting decisions must follow the defined review and selection logic. + scenarios: + valid: + _test_id: BR14_V_01 + _br_id: BR14 + _scenario: Valid + _expected_result: Application status update consistent with shortlisting rules is accepted. + invalid: + _test_id: BR14_I_01 + _br_id: BR14 + _scenario: Invalid + _expected_result: Improper shortlisting action is rejected or blocked. + + - id: BR15 + source_id: BR-IP-003 + name: Interview Process Documentation + endpoint: /placement/api/my-applications/ + method: GET + description: The interview process must remain visible and traceable to students and administrators. + scenarios: + valid: + _test_id: BR15_V_01 + _br_id: BR15 + _scenario: Valid + _expected_result: Interview rounds and status history are exposed consistently in the application timeline. + invalid: + _test_id: BR15_I_01 + _br_id: BR15 + _scenario: Invalid + _expected_result: Missing or inconsistent interview documentation is treated as a workflow failure. + + - id: BR16 + source_id: BR-IP-004 + name: Multiple Offer Management + endpoint: /placement/api/offer/{offer_id}/respond/ + method: POST + description: The offer process enforces institute constraints around multiple concurrent offers. + scenarios: + valid: + _test_id: BR16_V_01 + _br_id: BR16 + _scenario: Valid + _expected_result: Student accepts a valid pending offer when no blocking accepted offer exists. + invalid: + _test_id: BR16_I_01 + _br_id: BR16 + _scenario: Invalid + _expected_result: Student is blocked from accepting another offer when policy forbids it. + + - id: BR17 + source_id: BR-IP-005 + name: Interview Feedback and Appeals + endpoint: /placement/api/placement-appeals/ + method: GET, POST, PUT + description: Students and TPO can record and process interview-related appeals and follow-up decisions. + scenarios: + valid: + _test_id: BR17_V_01 + _br_id: BR17 + _scenario: Valid + _expected_result: Valid appeal submission or decision is accepted and tracked. + invalid: + _test_id: BR17_I_01 + _br_id: BR17 + _scenario: Invalid + _expected_result: Unauthorized or malformed appeal action is rejected. + + - id: BR18 + source_id: BR-NT-001 + name: Notification Delivery Requirements + endpoint: /placement/api/send-notification/ + method: POST + description: Notifications must be delivered using approved targeting and channel rules. + scenarios: + valid: + _test_id: BR18_V_01 + _br_id: BR18 + _scenario: Valid + _expected_result: Notification request with valid target selection is processed successfully. + invalid: + _test_id: BR18_I_01 + _br_id: BR18 + _scenario: Invalid + _expected_result: Notification request with invalid recipient data is rejected. + + - id: BR19 + source_id: BR-NT-002 + name: Notification Content Standards + endpoint: /placement/api/send-notification/ + method: POST + description: Notification payloads must follow message-content quality and formatting requirements. + scenarios: + valid: + _test_id: BR19_V_01 + _br_id: BR19 + _scenario: Valid + _expected_result: Well-formed notification content is accepted for delivery. + invalid: + _test_id: BR19_I_01 + _br_id: BR19 + _scenario: Invalid + _expected_result: Empty or malformed notification content is rejected. + + - id: BR20 + source_id: BR-NT-003 + name: Communication Hierarchy + endpoint: /placement/api/send-notification/ + method: POST + description: Communication actions must respect placement governance and authorized sender roles. + scenarios: + valid: + _test_id: BR20_V_01 + _br_id: BR20 + _scenario: Valid + _expected_result: Authorized user sends an allowed notification successfully. + invalid: + _test_id: BR20_I_01 + _br_id: BR20 + _scenario: Invalid + _expected_result: Unauthorized notification action is denied. + + - id: BR21 + source_id: BR-NT-004 + name: Emergency Communication Protocol + endpoint: /placement/api/send-notification/ + method: POST + description: Critical notification flows must support urgent communication behavior. + scenarios: + valid: + _test_id: BR21_V_01 + _br_id: BR21 + _scenario: Valid + _expected_result: Emergency notification request is accepted with valid targeting information. + invalid: + _test_id: BR21_I_01 + _br_id: BR21 + _scenario: Invalid + _expected_result: Emergency notification request missing required information is rejected. + + - id: BR22 + source_id: BR-NT-005 + name: Privacy and Confidentiality + endpoint: /placement/api/send-notification/ + method: POST + description: Notifications and placement data handling must respect privacy boundaries and confidentiality. + scenarios: + valid: + _test_id: BR22_V_01 + _br_id: BR22 + _scenario: Valid + _expected_result: Authorized user accesses only permitted notification data and actions. + invalid: + _test_id: BR22_I_01 + _br_id: BR22 + _scenario: Invalid + _expected_result: Disallowed access or disclosure attempt is rejected. + + - id: BR23 + source_id: BR-AC-001 + name: Role-Based Access Control + endpoint: /placement/api/reports/ + method: GET + description: Endpoints must enforce permissions based on the current user role. + scenarios: + valid: + _test_id: BR23_V_01 + _br_id: BR23 + _scenario: Valid + _expected_result: Authorized role accesses the protected resource successfully. + invalid: + _test_id: BR23_I_01 + _br_id: BR23 + _scenario: Invalid + _expected_result: Unauthorized role is denied access to the protected resource. + + - id: BR24 + source_id: BR-AC-002 + name: Authentication and Session Management + endpoint: /placement/api/profile/ + method: GET + description: Placement APIs must require valid authenticated user context. + scenarios: + valid: + _test_id: BR24_V_01 + _br_id: BR24 + _scenario: Valid + _expected_result: Authenticated request succeeds. + invalid: + _test_id: BR24_I_01 + _br_id: BR24 + _scenario: Invalid + _expected_result: Unauthenticated request is rejected. + + - id: BR25 + source_id: BR-AC-003 + name: Data Access Restrictions + endpoint: /placement/api/statistics/ + method: GET + description: Data access must be restricted to permitted scopes and users. + scenarios: + valid: + _test_id: BR25_V_01 + _br_id: BR25 + _scenario: Valid + _expected_result: Permitted user accesses allowed data successfully. + invalid: + _test_id: BR25_I_01 + _br_id: BR25 + _scenario: Invalid + _expected_result: Disallowed data-access attempt is blocked. + + - id: BR26 + source_id: BR-AC-004 + name: System Administration Rules + endpoint: /placement/api/report-schedules/ + method: GET, POST, PUT + description: Administrative placement operations must follow privileged access rules. + scenarios: + valid: + _test_id: BR26_V_01 + _br_id: BR26 + _scenario: Valid + _expected_result: Administrator-level operation succeeds for an authorized user. + invalid: + _test_id: BR26_I_01 + _br_id: BR26 + _scenario: Invalid + _expected_result: Non-admin user is denied administrative operation access. + + - id: BR27 + source_id: BR-AC-005 + name: External Integration Security + endpoint: /placement/api/registration/ + method: POST + description: External-facing integrations such as company registration must follow secure handling rules. + scenarios: + valid: + _test_id: BR27_V_01 + _br_id: BR27 + _scenario: Valid + _expected_result: Secure registration request is accepted. + invalid: + _test_id: BR27_I_01 + _br_id: BR27 + _scenario: Invalid + _expected_result: Insecure or malformed external request is rejected. + + - id: BR28 + source_id: BR-DM-001 + name: Data Quality and Validation + endpoint: /placement/api/profile/ + method: PUT + description: Placement data must satisfy validation rules before it is persisted. + scenarios: + valid: + _test_id: BR28_V_01 + _br_id: BR28 + _scenario: Valid + _expected_result: Valid data payload is stored successfully. + invalid: + _test_id: BR28_I_01 + _br_id: BR28 + _scenario: Invalid + _expected_result: Invalid data payload is rejected with errors. + + - id: BR29 + source_id: BR-DM-002 + name: Data Retention and Archival + endpoint: /placement/api/reports/ + method: GET + description: Placement data should remain available according to retention and archival policy. + scenarios: + valid: + _test_id: BR29_V_01 + _br_id: BR29 + _scenario: Valid + _expected_result: Retained report data remains available within policy scope. + invalid: + _test_id: BR29_I_01 + _br_id: BR29 + _scenario: Invalid + _expected_result: Access to data outside retention policy is unavailable or rejected. + + - id: BR30 + source_id: BR-DM-003 + name: Reporting and Analytics + endpoint: /placement/api/reports/ + method: GET + description: Reporting APIs must generate consistent analytics structures for placement data. + scenarios: + valid: + _test_id: BR30_V_01 + _br_id: BR30 + _scenario: Valid + _expected_result: Requested report payload is generated successfully. + invalid: + _test_id: BR30_I_01 + _br_id: BR30 + _scenario: Invalid + _expected_result: Invalid report request is rejected or returns no generated report. + + - id: BR31 + source_id: BR-DM-004 + name: Data Privacy and Compliance + endpoint: /placement/api/alumni/profile/ + method: GET, PUT + description: Personal placement-related data must be handled according to privacy and compliance rules. + scenarios: + valid: + _test_id: BR31_V_01 + _br_id: BR31 + _scenario: Valid + _expected_result: Authorized user accesses or updates personal data within allowed scope. + invalid: + _test_id: BR31_I_01 + _br_id: BR31 + _scenario: Invalid + _expected_result: Non-compliant data access or update attempt is rejected. + + - id: BR32 + source_id: BR-DM-005 + name: Statistical Analysis and Insights + endpoint: /placement/api/statistics/ + method: GET + description: Statistical endpoints must produce meaningful and filterable placement insights. + scenarios: + valid: + _test_id: BR32_V_01 + _br_id: BR32 + _scenario: Valid + _expected_result: Statistics endpoint returns structured insight data for valid filters. + invalid: + _test_id: BR32_I_01 + _br_id: BR32 + _scenario: Invalid + _expected_result: Invalid filter combination or unavailable dataset is handled safely. + + - id: BR33 + source_id: BR-SI-001 + name: Academic System Integration + endpoint: /placement/api/profile/ + method: GET + description: Placement data should remain consistent with linked academic information. + scenarios: + valid: + _test_id: BR33_V_01 + _br_id: BR33 + _scenario: Valid + _expected_result: Academic-linked placement data remains synchronized and usable. + invalid: + _test_id: BR33_I_01 + _br_id: BR33 + _scenario: Invalid + _expected_result: Academic inconsistency is detected or blocks dependent placement action. + + - id: BR34 + source_id: BR-SI-002 + name: Communication System Integration + endpoint: /placement/api/send-notification/ + method: POST + description: Placement notifications integrate with supported communication channels. + scenarios: + valid: + _test_id: BR34_V_01 + _br_id: BR34 + _scenario: Valid + _expected_result: Notification request is accepted for configured channels. + invalid: + _test_id: BR34_I_01 + _br_id: BR34 + _scenario: Invalid + _expected_result: Channel or recipient mismatch causes rejection or failure response. + + - id: BR35 + source_id: BR-SI-003 + name: External Platform Integration + endpoint: /placement/api/registration/ + method: GET, POST + description: Placement data exchange with external platforms must remain consistent and controlled. + scenarios: + valid: + _test_id: BR35_V_01 + _br_id: BR35 + _scenario: Valid + _expected_result: External platform–related data is accepted in the supported integration flow. + invalid: + _test_id: BR35_I_01 + _br_id: BR35 + _scenario: Invalid + _expected_result: Unsupported integration request is rejected. + + - id: BR36 + source_id: BR-SI-004 + name: Workflow Automation Rules + endpoint: /placement/api/application-detail/{application_id}/ + method: PUT + description: Placement workflows may trigger automated state updates and notifications under allowed rules. + scenarios: + valid: + _test_id: BR36_V_01 + _br_id: BR36 + _scenario: Valid + _expected_result: Valid state transition triggers the expected automated follow-up behavior. + invalid: + _test_id: BR36_I_01 + _br_id: BR36 + _scenario: Invalid + _expected_result: Disallowed automated transition is rejected. + + - id: BR37 + source_id: BR-SI-005 + name: System Performance Rules + endpoint: /placement/api/placement/ + method: GET + description: The system must respond reliably under normal placement usage conditions. + scenarios: + valid: + _test_id: BR37_V_01 + _br_id: BR37 + _scenario: Valid + _expected_result: Supported request completes successfully within expected operational conditions. + invalid: + _test_id: BR37_I_01 + _br_id: BR37 + _scenario: Invalid + _expected_result: Degraded or unavailable behavior is surfaced as a controlled failure. + + - id: BR38 + source_id: BR-EX-001 + name: TPO Override Authority + endpoint: /placement/api/placement-appeals/ + method: GET, POST, PUT + description: TPO override actions must be documented and limited to authorized review flows. + scenarios: + valid: + _test_id: BR38_V_01 + _br_id: BR38 + _scenario: Valid + _expected_result: Authorized override decision is recorded successfully. + invalid: + _test_id: BR38_I_01 + _br_id: BR38 + _scenario: Invalid + _expected_result: Unauthorized or unsupported override request is rejected. + + - id: BR39 + source_id: BR-EX-002 + name: Emergency Procedures + endpoint: /placement/api/send-notification/ + method: POST + description: Emergency handling must support alternative communication or recovery actions. + scenarios: + valid: + _test_id: BR39_V_01 + _br_id: BR39 + _scenario: Valid + _expected_result: Emergency procedure request is accepted for a supported scenario. + invalid: + _test_id: BR39_I_01 + _br_id: BR39 + _scenario: Invalid + _expected_result: Unsupported emergency procedure invocation is rejected. + + - id: BR40 + source_id: BR-EX-003 + name: Policy Violation Handling + endpoint: /placement/api/placement-appeals/ + method: GET, POST, PUT + description: Policy violations and related review actions must follow a documented control path. + scenarios: + valid: + _test_id: BR40_V_01 + _br_id: BR40 + _scenario: Valid + _expected_result: Valid policy-violation review action is recorded successfully. + invalid: + _test_id: BR40_I_01 + _br_id: BR40 + _scenario: Invalid + _expected_result: Invalid policy-violation handling action is rejected. diff --git a/FusionIIIT/applications/placement_cell/tests/specs/use_cases.yaml b/FusionIIIT/applications/placement_cell/tests/specs/use_cases.yaml new file mode 100644 index 000000000..1dcf61362 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/specs/use_cases.yaml @@ -0,0 +1,506 @@ +use_cases: + - id: UC01 + source_id: PC-UC-S001 + name: Profile Management + endpoint: /placement/api/profile/ + method: GET, PUT + description: Students create and update their comprehensive placement profile including personal, academic, and skills details. + scenarios: + happy_path: + _test_id: UC01_HP_01 + _uc_id: UC01 + _scenario: Happy Path + _expected_result: Student saves a valid placement profile and sees an updated profile summary. + alternate_path: + _test_id: UC01_AP_01 + _uc_id: UC01 + _scenario: Alternate Path + _expected_result: Student retrieves profile completeness, eligibility summary, documents, and audit history. + exception_path: + _test_id: UC01_EP_01 + _uc_id: UC01 + _scenario: Exception Path + _expected_result: Invalid profile details are rejected with field-level validation errors. + + - id: UC02 + source_id: PC-UC-S002 + name: Browse and Search Jobs + endpoint: /placement/api/placement/ + method: GET + description: Students browse available job postings and search for opportunities based on preferences and eligibility. + scenarios: + happy_path: + _test_id: UC02_HP_01 + _uc_id: UC02 + _scenario: Happy Path + _expected_result: Student sees active upcoming job opportunities. + alternate_path: + _test_id: UC02_AP_01 + _uc_id: UC02 + _scenario: Alternate Path + _expected_result: Filtered search returns only matching placements. + exception_path: + _test_id: UC02_EP_01 + _uc_id: UC02 + _scenario: Exception Path + _expected_result: No active jobs results in an empty list with no server error. + + - id: UC03 + source_id: PC-UC-S003 + name: Apply for Jobs + endpoint: /placement/api/apply-for-placement/ + method: POST + description: Students submit applications for eligible job postings with required information and supporting profile data. + scenarios: + happy_path: + _test_id: UC03_HP_01 + _uc_id: UC03 + _scenario: Happy Path + _expected_result: Eligible student with a complete profile submits an application successfully. + alternate_path: + _test_id: UC03_AP_01 + _uc_id: UC03 + _scenario: Alternate Path + _expected_result: Ineligible student is blocked with eligibility reasons. + exception_path: + _test_id: UC03_EP_01 + _uc_id: UC03 + _scenario: Exception Path + _expected_result: Duplicate application attempt is rejected with a conflict response. + + - id: UC04 + source_id: PC-UC-S004 + name: Track Application Status + endpoint: /placement/api/my-applications/ + method: GET + description: Students monitor the status and progress of submitted applications. + scenarios: + happy_path: + _test_id: UC04_HP_01 + _uc_id: UC04 + _scenario: Happy Path + _expected_result: Student sees a list of submitted applications with current statuses. + alternate_path: + _test_id: UC04_AP_01 + _uc_id: UC04 + _scenario: Alternate Path + _expected_result: Student sees interview rounds, next interview details, and offer references for an application. + exception_path: + _test_id: UC04_EP_01 + _uc_id: UC04 + _scenario: Exception Path + _expected_result: Student with no applications receives an empty applications list. + + - id: UC05 + source_id: PC-UC-S005 + name: Manage Job Offers + endpoint: /placement/api/my-offers/ + method: GET, POST + description: Students review, accept, or reject job offers received from companies. + scenarios: + happy_path: + _test_id: UC05_HP_01 + _uc_id: UC05 + _scenario: Happy Path + _expected_result: Student views active offers and can accept an eligible pending offer. + alternate_path: + _test_id: UC05_AP_01 + _uc_id: UC05 + _scenario: Alternate Path + _expected_result: Student rejects an offer and the response is recorded successfully. + exception_path: + _test_id: UC05_EP_01 + _uc_id: UC05 + _scenario: Exception Path + _expected_result: Expired or already-responded offers are blocked with an appropriate error. + + - id: UC06 + source_id: PC-UC-S006 + name: Generate Resume + endpoint: /placement/api/generate-cv/ + method: POST + description: Students generate formatted resumes using profile information stored in the placement module. + scenarios: + happy_path: + _test_id: UC06_HP_01 + _uc_id: UC06 + _scenario: Happy Path + _expected_result: Student generates and downloads a resume successfully. + alternate_path: + _test_id: UC06_AP_01 + _uc_id: UC06 + _scenario: Alternate Path + _expected_result: Resume is generated using the latest available profile, education, and project data. + exception_path: + _test_id: UC06_EP_01 + _uc_id: UC06 + _scenario: Exception Path + _expected_result: Resume generation fails gracefully when required data is incomplete or missing. + + - id: UC07 + source_id: PC-UC-S007 + name: View Placement Statistics + endpoint: /placement/api/statistics/ + method: GET + description: Students access placement statistics and trends for their batch and department. + scenarios: + happy_path: + _test_id: UC07_HP_01 + _uc_id: UC07 + _scenario: Happy Path + _expected_result: Student views placement statistics dashboard data successfully. + alternate_path: + _test_id: UC07_AP_01 + _uc_id: UC07 + _scenario: Alternate Path + _expected_result: Student filters statistics by company, year, or department and sees updated results. + exception_path: + _test_id: UC07_EP_01 + _uc_id: UC07 + _scenario: Exception Path + _expected_result: When no statistics match the filters, the API returns an empty dataset without failing. + + - id: UC08 + source_id: PC-UC-S008 + name: Withdraw Application + endpoint: /placement/api/apply-for-placement/{schedule_id}/ + method: DELETE + description: Students withdraw submitted applications before the recruitment process is completed. + scenarios: + happy_path: + _test_id: UC08_HP_01 + _uc_id: UC08 + _scenario: Happy Path + _expected_result: Student withdraws an active application successfully. + alternate_path: + _test_id: UC08_AP_01 + _uc_id: UC08 + _scenario: Alternate Path + _expected_result: Withdrawal also updates related invitation status and sends notifications. + exception_path: + _test_id: UC08_EP_01 + _uc_id: UC08 + _scenario: Exception Path + _expected_result: Already withdrawn applications cannot be withdrawn again. + + - id: UC09 + source_id: PC-UC-T001 + name: Manage Job Postings + endpoint: /placement/api/placement/ + method: GET, POST, PUT + description: TPO reviews, approves, creates, updates, and publishes job postings. + scenarios: + happy_path: + _test_id: UC09_HP_01 + _uc_id: UC09 + _scenario: Happy Path + _expected_result: TPO creates a valid job posting and it becomes visible to eligible students. + alternate_path: + _test_id: UC09_AP_01 + _uc_id: UC09 + _scenario: Alternate Path + _expected_result: TPO updates an existing job posting details successfully. + exception_path: + _test_id: UC09_EP_01 + _uc_id: UC09 + _scenario: Exception Path + _expected_result: Invalid schedule data such as past placement date is rejected. + + - id: UC10 + source_id: PC-UC-T002 + name: Schedule Interviews + endpoint: /placement/api/application-detail/{application_id}/interview/ + method: GET, POST + description: TPO coordinates interview schedules for shortlisted students and companies. + scenarios: + happy_path: + _test_id: UC10_HP_01 + _uc_id: UC10 + _scenario: Happy Path + _expected_result: TPO schedules an interview for an application successfully. + alternate_path: + _test_id: UC10_AP_01 + _uc_id: UC10 + _scenario: Alternate Path + _expected_result: Student and stakeholders can view the scheduled interview timeline. + exception_path: + _test_id: UC10_EP_01 + _uc_id: UC10 + _scenario: Exception Path + _expected_result: Conflicting or invalid interview timings are rejected. + + - id: UC11 + source_id: PC-UC-T003 + name: Generate Placement Reports + endpoint: /placement/api/reports/ + method: GET + description: TPO generates placement reports and analytics across company, batch, branch, and detailed views. + scenarios: + happy_path: + _test_id: UC11_HP_01 + _uc_id: UC11 + _scenario: Happy Path + _expected_result: TPO retrieves a placement report payload successfully. + alternate_path: + _test_id: UC11_AP_01 + _uc_id: UC11 + _scenario: Alternate Path + _expected_result: TPO applies report filters and receives a narrowed dataset. + exception_path: + _test_id: UC11_EP_01 + _uc_id: UC11 + _scenario: Exception Path + _expected_result: Non-TPO users are denied access to report APIs. + + - id: UC12 + source_id: PC-UC-C001 + name: Oversight Dashboard Access + endpoint: /placement/api/reports/ + method: GET + description: Placement chairman accesses consolidated oversight data for placement activities. + scenarios: + happy_path: + _test_id: UC12_HP_01 + _uc_id: UC12 + _scenario: Happy Path + _expected_result: Chairman accesses placement reports and dashboard-level summaries successfully. + alternate_path: + _test_id: UC12_AP_01 + _uc_id: UC12 + _scenario: Alternate Path + _expected_result: Chairman reviews filtered reports for a specific company, year, or department. + exception_path: + _test_id: UC12_EP_01 + _uc_id: UC12 + _scenario: Exception Path + _expected_result: Unauthorized users cannot access chairman-level oversight data. + + - id: UC13 + source_id: PC-UC-C002 + name: Make Official Announcements + endpoint: /placement/api/send-notification/ + method: POST + description: Chairman publishes official announcements and major placement updates to stakeholders. + scenarios: + happy_path: + _test_id: UC13_HP_01 + _uc_id: UC13 + _scenario: Happy Path + _expected_result: Chairman sends an announcement to all target recipients successfully. + alternate_path: + _test_id: UC13_AP_01 + _uc_id: UC13 + _scenario: Alternate Path + _expected_result: Chairman sends a targeted notification to a specific user or role group. + exception_path: + _test_id: UC13_EP_01 + _uc_id: UC13 + _scenario: Exception Path + _expected_result: Invalid recipient selection returns a not-found or validation error. + + - id: UC14 + source_id: PC-UC-R001 + name: Company Registration + endpoint: /placement/api/registration/ + method: GET, POST + description: Companies register on the platform and create a company profile for placement activities. + scenarios: + happy_path: + _test_id: UC14_HP_01 + _uc_id: UC14 + _scenario: Happy Path + _expected_result: Company submits registration details successfully. + alternate_path: + _test_id: UC14_AP_01 + _uc_id: UC14 + _scenario: Alternate Path + _expected_result: Authorized user retrieves the company directory or registration listing. + exception_path: + _test_id: UC14_EP_01 + _uc_id: UC14 + _scenario: Exception Path + _expected_result: Invalid or incomplete registration payload is rejected. + + - id: UC15 + source_id: PC-UC-R002 + name: Post Job Opportunities + endpoint: /placement/api/placement/ + method: POST + description: Companies create and submit job postings with detailed eligibility criteria and schedules. + scenarios: + happy_path: + _test_id: UC15_HP_01 + _uc_id: UC15 + _scenario: Happy Path + _expected_result: Company or authorized staff submits a valid job opportunity successfully. + alternate_path: + _test_id: UC15_AP_01 + _uc_id: UC15 + _scenario: Alternate Path + _expected_result: Job posting includes company linkage, filters, and optional attachment metadata. + exception_path: + _test_id: UC15_EP_01 + _uc_id: UC15 + _scenario: Exception Path + _expected_result: Posting with invalid schedule or malformed fields is rejected. + + - id: UC16 + source_id: PC-UC-R003 + name: Review Applications + endpoint: /placement/api/student-applications/{identifier}/ + method: GET, PUT + description: Companies review student applications, shortlist candidates, and update application status. + scenarios: + happy_path: + _test_id: UC16_HP_01 + _uc_id: UC16 + _scenario: Happy Path + _expected_result: Reviewer fetches submitted applications for a job and updates application status successfully. + alternate_path: + _test_id: UC16_AP_01 + _uc_id: UC16 + _scenario: Alternate Path + _expected_result: Reviewer accesses detailed application information including resume and interview history. + exception_path: + _test_id: UC16_EP_01 + _uc_id: UC16 + _scenario: Exception Path + _expected_result: Invalid application identifier or unauthorized review request is rejected. + + - id: UC17 + source_id: PC-UC-A001 + name: Alumni Registration and Verification + endpoint: /placement/api/alumni/profile/ + method: GET, POST, PUT + description: Alumni register on the platform, upload verification data, and await approval for alumni features. + scenarios: + happy_path: + _test_id: UC17_HP_01 + _uc_id: UC17 + _scenario: Happy Path + _expected_result: Alumni submits a new profile for verification successfully. + alternate_path: + _test_id: UC17_AP_01 + _uc_id: UC17 + _scenario: Alternate Path + _expected_result: Approved alumni updates profile details and mentorship preferences successfully. + exception_path: + _test_id: UC17_EP_01 + _uc_id: UC17 + _scenario: Exception Path + _expected_result: Unapproved alumni attempting restricted updates receive a pending-approval error. + + - id: UC18 + source_id: PC-UC-S009 + name: Receive Notifications + endpoint: /placement/api/notification-preferences/ + method: GET, PUT + description: Students receive and manage notification preferences for placement activities and updates. + scenarios: + happy_path: + _test_id: UC18_HP_01 + _uc_id: UC18 + _scenario: Happy Path + _expected_result: Student views current notification preferences successfully. + alternate_path: + _test_id: UC18_AP_01 + _uc_id: UC18 + _scenario: Alternate Path + _expected_result: Student updates portal, email, and SMS notification preferences successfully. + exception_path: + _test_id: UC18_EP_01 + _uc_id: UC18 + _scenario: Exception Path + _expected_result: Unauthorized access to notification preferences is denied. + + - id: UC19 + source_id: PC-UC-T004 + name: Generate Placement Reports + endpoint: /placement/api/report-schedules/ + method: GET, POST, PUT + description: TPO manages scheduled placement reports and recurring report generation settings. + scenarios: + happy_path: + _test_id: UC19_HP_01 + _uc_id: UC19 + _scenario: Happy Path + _expected_result: TPO creates a scheduled report successfully. + alternate_path: + _test_id: UC19_AP_01 + _uc_id: UC19 + _scenario: Alternate Path + _expected_result: TPO updates report schedule configuration and notification targets successfully. + exception_path: + _test_id: UC19_EP_01 + _uc_id: UC19 + _scenario: Exception Path + _expected_result: Non-admin users cannot manage report schedules. + + - id: UC20 + source_id: PC-UC-T005 + name: Manage Company Relations + endpoint: /placement/api/registration/ + method: GET, POST + description: TPO manages company records and tracks recruiting organizations in the placement system. + scenarios: + happy_path: + _test_id: UC20_HP_01 + _uc_id: UC20 + _scenario: Happy Path + _expected_result: TPO registers or maintains company information successfully. + alternate_path: + _test_id: UC20_AP_01 + _uc_id: UC20 + _scenario: Alternate Path + _expected_result: TPO reviews available company entries and uses them while creating schedules. + exception_path: + _test_id: UC20_EP_01 + _uc_id: UC20 + _scenario: Exception Path + _expected_result: Invalid company information is rejected by registration validation rules. + + - id: UC21 + source_id: PC-UC-T006 + name: Send Notifications + endpoint: /placement/api/send-notification/ + method: POST + description: TPO sends targeted notifications to students, companies, or other placement stakeholders. + scenarios: + happy_path: + _test_id: UC21_HP_01 + _uc_id: UC21 + _scenario: Happy Path + _expected_result: TPO sends a notification to all students successfully. + alternate_path: + _test_id: UC21_AP_01 + _uc_id: UC21 + _scenario: Alternate Path + _expected_result: TPO sends a notification to a specific recipient successfully. + exception_path: + _test_id: UC21_EP_01 + _uc_id: UC21 + _scenario: Exception Path + _expected_result: Notification request with an unknown recipient fails with a descriptive error. + + - id: UC22 + source_id: PC-UC-T007 + name: Manage Student Eligibility Override + endpoint: /placement/api/placement-appeals/ + method: GET, POST, PUT + description: TPO handles student eligibility exceptions, appeals, and override-style review decisions. + scenarios: + happy_path: + _test_id: UC22_HP_01 + _uc_id: UC22 + _scenario: Happy Path + _expected_result: TPO reviews a student appeal and records a decision successfully. + alternate_path: + _test_id: UC22_AP_01 + _uc_id: UC22 + _scenario: Alternate Path + _expected_result: Student submits an eligibility-related appeal with supporting notes successfully. + exception_path: + _test_id: UC22_EP_01 + _uc_id: UC22 + _scenario: Exception Path + _expected_result: Invalid or unauthorized appeal actions are rejected. diff --git a/FusionIIIT/applications/placement_cell/tests/specs/workflows.yaml b/FusionIIIT/applications/placement_cell/tests/specs/workflows.yaml new file mode 100644 index 000000000..a4c292a39 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/specs/workflows.yaml @@ -0,0 +1,201 @@ +workflows: + - id: WF01 + source_section: End-to-End Placement Workflow + name: End-to-End Placement Workflow + description: Placement season progresses from job publication through student application, selection, offer, and finalization. + steps: + - Company or TPO publishes a job + - Student applies and company reviews applications + - Interviews and selection decisions are recorded + - Offer is released and placement records are finalized + scenarios: + end_to_end: + _test_id: WF01_E2E_01 + _wf_id: WF01 + _scenario: End-to-End + _expected_result: Published job moves through application and offer stages to a completed placement outcome. + negative: + _test_id: WF01_NEG_01 + _wf_id: WF01 + _scenario: Negative + _expected_result: Workflow halts safely when the student cannot complete the application path. + + - id: WF02 + source_section: Student Application Workflow + name: Student Application Workflow + description: Student completes the placement profile, browses jobs, passes eligibility checks, and submits an application. + steps: + - Complete placement profile + - Retrieve profile summary + - Submit application for an eligible job + scenarios: + end_to_end: + _test_id: WF02_E2E_01 + _wf_id: WF02 + _scenario: End-to-End + _expected_result: Completed profile enables successful job application. + negative: + _test_id: WF02_NEG_01 + _wf_id: WF02 + _scenario: Negative + _expected_result: Incomplete profile blocks job application. + + - id: WF03 + source_section: TPO Job Posting Management Workflow + name: TPO Job Posting Management Workflow + description: TPO reviews job details, validates the posting, and publishes it to eligible students. + steps: + - Review job details and company information + - Validate compensation and content standards + - Approve and publish the job posting + scenarios: + end_to_end: + _test_id: WF03_E2E_01 + _wf_id: WF03 + _scenario: End-to-End + _expected_result: Valid posting is approved and becomes visible to students. + negative: + _test_id: WF03_NEG_01 + _wf_id: WF03 + _scenario: Negative + _expected_result: Invalid posting is rejected before publication. + + - id: WF04 + source_section: Interview Scheduling Workflow + name: Interview Scheduling Workflow + description: TPO creates interview schedules after shortlisting and ensures students can track upcoming rounds. + steps: + - Create or update application shortlist state + - Schedule interview round for an application + - Student views interview schedule in application timeline + scenarios: + end_to_end: + _test_id: WF04_E2E_01 + _wf_id: WF04 + _scenario: End-to-End + _expected_result: Interview is scheduled successfully and appears in the tracked application timeline. + negative: + _test_id: WF04_NEG_01 + _wf_id: WF04 + _scenario: Negative + _expected_result: Conflicting or invalid interview schedule request is rejected. + + - id: WF05 + source_section: Offer Management Workflow + name: Offer Release To Offer Dashboard Visibility + description: After an application is processed and an offer is released, the student can see it on the offer dashboard. + steps: + - Student applies for a schedule + - TPO updates application status to offer released + - Student views my offers + scenarios: + end_to_end: + _test_id: WF05_E2E_01 + _wf_id: WF05 + _scenario: End-to-End + _expected_result: Released offer becomes visible on the student offer dashboard. + negative: + _test_id: WF05_NEG_01 + _wf_id: WF05 + _scenario: Negative + _expected_result: Offer dashboard stays empty when no offer has been released. + + - id: WF06 + source_section: Company Registration Workflow + name: Company Registration Workflow + description: Company details are submitted, validated, and added to the placement system for later recruiting activity. + steps: + - Submit company registration details + - Validate registration payload + - Persist company record for placement usage + scenarios: + end_to_end: + _test_id: WF06_E2E_01 + _wf_id: WF06 + _scenario: End-to-End + _expected_result: Company registration is stored successfully and can be retrieved later. + negative: + _test_id: WF06_NEG_01 + _wf_id: WF06 + _scenario: Negative + _expected_result: Invalid company registration request is rejected safely. + + - id: WF07 + source_section: Alumni Engagement Workflow + name: Alumni Registration And Verification Workflow + description: Alumni submit registration data, wait for review, and then access alumni capabilities after approval. + steps: + - Alumni submits profile and verification details + - TPO reviews verification queue and approves profile + - Approved alumni updates or accesses alumni features + scenarios: + end_to_end: + _test_id: WF07_E2E_01 + _wf_id: WF07 + _scenario: End-to-End + _expected_result: Alumni profile is submitted, approved, and becomes accessible. + negative: + _test_id: WF07_NEG_01 + _wf_id: WF07 + _scenario: Negative + _expected_result: Pending or rejected alumni profile cannot access restricted alumni capabilities. + + - id: WF08 + source_section: Notification Management Workflow + name: Notification Management Workflow + description: Authorized placement users send notifications to all students or a specific recipient and the request is validated. + steps: + - Compose placement notification + - Select recipients or all-students target + - Submit the notification request + scenarios: + end_to_end: + _test_id: WF08_E2E_01 + _wf_id: WF08 + _scenario: End-to-End + _expected_result: Valid notification request is accepted and logged as sent. + negative: + _test_id: WF08_NEG_01 + _wf_id: WF08 + _scenario: Negative + _expected_result: Notification request with an invalid recipient is rejected. + + - id: WF09 + source_section: Load Balancing Workflow + name: Placement Access Under Load Workflow + description: Placement browsing and reporting continue to function under expected operational load and access patterns. + steps: + - User accesses placement listing or analytics endpoint + - System processes the request under normal operating conditions + - Response is returned in a usable form + scenarios: + end_to_end: + _test_id: WF09_E2E_01 + _wf_id: WF09 + _scenario: End-to-End + _expected_result: Core placement endpoint responds successfully for a normal request. + negative: + _test_id: WF09_NEG_01 + _wf_id: WF09 + _scenario: Negative + _expected_result: Degraded or invalid request path fails in a controlled way. + + - id: WF10 + source_section: Backup and Recovery Workflow + name: Reporting And Recovery Visibility Workflow + description: Historical placement data remains queryable through reports and statistics after normal operational changes. + steps: + - Create or retain placement records + - Request reports or statistics + - Validate accessible recovered or retained data view + scenarios: + end_to_end: + _test_id: WF10_E2E_01 + _wf_id: WF10 + _scenario: End-to-End + _expected_result: Reporting endpoints return retained placement information successfully. + negative: + _test_id: WF10_NEG_01 + _wf_id: WF10 + _scenario: Negative + _expected_result: Missing or invalid report request path is handled without breaking the workflow. diff --git a/FusionIIIT/applications/placement_cell/tests/test_business_rules.py b/FusionIIIT/applications/placement_cell/tests/test_business_rules.py new file mode 100644 index 000000000..7b48e79f9 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/test_business_rules.py @@ -0,0 +1,905 @@ +import datetime + +import yaml +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings +from django.utils import timezone + +from applications.academic_information.models import Student +from applications.placement_cell.models import ( + PlacementAppeal, + PlacementApplication, + PlacementInterviewSchedule, + PlacementPolicy, + PlacementRecord, + PlacementReportSchedule, + PlacementStatus, + StudentPlacement, +) +from applications.placement_cell.tests.conftest import PlacementCellSpecBase + + +class TestBusinessRuleCatalogIntegrity(PlacementCellSpecBase): + def test_all_documented_business_rules_define_two_scenarios(self): + with (self.specs_dir / "business_rules.yaml").open("r", encoding="utf-8") as handle: + payload = yaml.safe_load(handle) or {} + + business_rules = payload.get("business_rules", []) + self.assertGreaterEqual(len(business_rules), 20) + + for business_rule in business_rules: + self.assertIn("source_id", business_rule) + self.assertIn("endpoint", business_rule) + self.assertIn("method", business_rule) + self.assertEqual(sorted(business_rule.get("scenarios", {}).keys()), ["invalid", "valid"]) + + def test_business_rule_test_ids_are_unique(self): + with (self.specs_dir / "business_rules.yaml").open("r", encoding="utf-8") as handle: + payload = yaml.safe_load(handle) or {} + + test_ids = [] + for business_rule in payload.get("business_rules", []): + for scenario in business_rule.get("scenarios", {}).values(): + test_ids.append(scenario["_test_id"]) + + self.assertEqual(len(test_ids), len(set(test_ids))) + + +class TestBR01_FuturePlacementDate(PlacementCellSpecBase): + def test_valid_future_date_is_accepted(self): + metadata = self.load_spec("business_rules.yaml", "business_rules", "BR01")["scenarios"]["valid"] + + response = self.api_post( + "/placement/api/placement/", + user=self.officer, + data={ + "company_name": "Future Co", + "title": "Future Co", + "placement_type": "PLACEMENT", + "ctc": "12.50", + "description": "Campus drive", + "placement_date": (timezone.now().date() + datetime.timedelta(days=3)).isoformat(), + "schedule_at": "2026-04-15 10:30", + "location": "Auditorium", + }, + format="multipart", + ) + + self.assertEqual(metadata["_scenario"], "Valid") + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["company_name"], "Future Co") + + def test_invalid_past_date_is_rejected(self): + metadata = self.load_spec("business_rules.yaml", "business_rules", "BR01")["scenarios"]["invalid"] + + response = self.api_post( + "/placement/api/placement/", + user=self.officer, + data={ + "company_name": "Past Co", + "placement_type": "PLACEMENT", + "placement_date": (timezone.now().date() - datetime.timedelta(days=1)).isoformat(), + "schedule_at": "2026-04-01 10:30", + }, + format="multipart", + ) + + self.assertEqual(metadata["_scenario"], "Invalid") + self.assertEqual(response.status_code, 400) + self.assertIn("placement_date", response.data) + + +class TestBR02_MandatoryProfileFields(PlacementCellSpecBase): + def test_valid_profile_payload_is_accepted(self): + metadata = self.load_spec("business_rules.yaml", "business_rules", "BR02")["scenarios"]["valid"] + + response = self.api_put( + "/placement/api/profile/", + user=self.student_user, + data={ + "first_name": "Student", + "last_name": "One", + "email": "student1@example.com", + "phone_no": "9876543210", + "address": "Hostel A", + "about_me": "Ready for placements", + }, + format="multipart", + ) + + self.assertEqual(metadata["_scenario"], "Valid") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["profile"]["address"], "Hostel A") + self.assertNotIn("first_name", response.data["field_errors"]) + self.assertNotIn("email", response.data["field_errors"]) + + def test_invalid_profile_payload_is_rejected(self): + metadata = self.load_spec("business_rules.yaml", "business_rules", "BR02")["scenarios"]["invalid"] + + response = self.api_put( + "/placement/api/profile/", + user=self.student_user, + data={ + "first_name": "", + "last_name": "", + "email": "not-an-email", + "phone_no": "123", + "address": "", + "about_me": "", + }, + format="multipart", + ) + + self.assertEqual(metadata["_scenario"], "Invalid") + self.assertEqual(response.status_code, 400) + self.assertIn("field_errors", response.data) + self.assertIn("phone_no", response.data["field_errors"]) + + +class TestBR03_NoDuplicateApplication(PlacementCellSpecBase): + def test_valid_first_application_succeeds(self): + metadata = self.load_spec("business_rules.yaml", "business_rules", "BR03")["scenarios"]["valid"] + student = self._make_profile_complete(self.student_user) + schedule = self._create_schedule(company_name="Rule Corp") + + response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + + self.assertEqual(metadata["_scenario"], "Valid") + self.assertEqual(response.status_code, 200) + self.assertTrue(PlacementApplication.objects.filter(schedule=schedule, student=student).exists()) + + def test_invalid_duplicate_application_is_rejected(self): + metadata = self.load_spec("business_rules.yaml", "business_rules", "BR03")["scenarios"]["invalid"] + self._make_profile_complete(self.student_user) + schedule = self._create_schedule(company_name="Rule Duplicate Corp") + self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + + response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + + self.assertEqual(metadata["_scenario"], "Invalid") + self.assertEqual(response.status_code, 409) + self.assertIn("already applied", response.data["detail"]) + + +class TestPlacementPolicyManagement(PlacementCellSpecBase): + def test_chairman_can_view_and_add_policies(self): + self._create_officer_designation(name="placement chairman") + + create_response = self.api_post( + "/placement/api/policies/", + user=self.officer, + data={ + "title": "Dream Offer Rule", + "description": "Students with an accepted dream offer cannot continue in regular drives.", + }, + ) + + self.assertEqual(create_response.status_code, 201) + self.assertEqual(create_response.data["title"], "Dream Offer Rule") + self.assertTrue(PlacementPolicy.objects.filter(title="Dream Offer Rule").exists()) + + list_response = self.api_get("/placement/api/policies/", user=self.officer) + + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.data[0]["title"], "Dream Offer Rule") + + def test_chairman_can_edit_policies(self): + self._create_officer_designation(name="placement chairman") + policy = PlacementPolicy.objects.create( + title="Original Rule", + description="Original description", + created_by=self.officer, + ) + + response = self.api_put( + f"/placement/api/policies/{policy.id}/", + user=self.officer, + data={ + "title": "Updated Rule", + "description": "Updated description", + }, + ) + + self.assertEqual(response.status_code, 200) + policy.refresh_from_db() + self.assertEqual(policy.title, "Updated Rule") + self.assertEqual(policy.description, "Updated description") + + def test_non_chairman_cannot_manage_policies(self): + response = self.api_get("/placement/api/policies/", user=self.student_user) + + self.assertEqual(response.status_code, 403) + self.assertIn("Only placement chairman users", response.data["detail"]) + + +class TestGeneratedBusinessRules(PlacementCellSpecBase): + def _get_br_metadata(self, br_id, scenario_key): + return self.load_spec("business_rules.yaml", "business_rules", br_id)["scenarios"][scenario_key] + + def _create_submitted_application(self, *, company_name="Rule Workflow Corp"): + student = self._make_profile_complete(self.student_user) + schedule = self._create_schedule(company_name=company_name) + response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + self.assertEqual(response.status_code, 200) + application = PlacementApplication.objects.get(schedule=schedule, student=student) + return student, schedule, application + + def _create_rejected_appeal_context(self): + student, schedule, application = self._create_submitted_application(company_name="Appeal Corp") + application.status = "reject" + application.remarks = "Rejected for rule validation" + application.save(update_fields=["status", "remarks", "updated_at"]) + placement_status, _ = PlacementStatus.objects.get_or_create( + notify_id=schedule.notify_id, + unique_id=student, + defaults={ + "invitation": "REJECTED", + "no_of_days": 2, + }, + ) + placement_status.invitation = "REJECTED" + placement_status.no_of_days = 2 + placement_status.save(update_fields=["invitation", "no_of_days", "timestamp"]) + return student, schedule, application, placement_status + + def _assert_metadata_and_status(self, metadata, expected_label, response, expected_statuses): + self.assertEqual(metadata["_scenario"], expected_label) + self.assertIn(response.status_code, expected_statuses) + + def _run_profile_update_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + response = self.api_put( + "/placement/api/profile/", + user=self.student_user, + data={ + "first_name": "Student", + "last_name": "One", + "email": "student1@example.com", + "phone_no": "9876543210", + "address": "Hostel A", + "about_me": "Ready for placements", + }, + format="multipart", + ) + self._assert_metadata_and_status(metadata, "Valid", response, {200}) + self.assertEqual(response.data["profile"]["address"], "Hostel A") + + def _run_profile_update_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + response = self.api_put( + "/placement/api/profile/", + user=self.student_user, + data={ + "first_name": "", + "last_name": "", + "email": "bad-email", + "phone_no": "123", + "address": "", + "about_me": "", + }, + format="multipart", + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {400}) + self.assertIn("field_errors", response.data) + + def _run_profile_completeness_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + self._make_profile_complete(self.student_user) + response = self.api_get("/placement/api/profile/", user=self.student_user) + self._assert_metadata_and_status(metadata, "Valid", response, {200}) + self.assertTrue(response.data["is_complete"]) + + def _run_profile_completeness_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + response = self.api_get("/placement/api/profile/", user=self.student_user) + self._assert_metadata_and_status(metadata, "Invalid", response, {200}) + self.assertFalse(response.data["is_complete"]) + self.assertIn("skills", response.data["field_errors"]) + + def _run_document_upload_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + response = self.api_post( + "/placement/api/profile/", + user=self.student_user, + data={ + "name": "Resume", + "document": SimpleUploadedFile("resume.pdf", b"pdf-content", content_type="application/pdf"), + }, + format="multipart", + ) + self._assert_metadata_and_status(metadata, "Valid", response, {201}) + self.assertEqual(response.data["name"], "Resume") + + def _run_document_upload_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + response = self.api_post( + "/placement/api/profile/", + user=self.student_user, + data={ + "name": "Resume", + "document": SimpleUploadedFile("resume.exe", b"binary", content_type="application/octet-stream"), + }, + format="multipart", + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {400}) + self.assertIn("document", response.data) + + def _run_schedule_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + response = self.api_post( + "/placement/api/placement/", + user=self.officer, + data={ + "company_name": "Schedule Corp", + "title": "Schedule Corp", + "placement_type": "PLACEMENT", + "ctc": "12.50", + "description": "Campus drive", + "placement_date": (timezone.now().date() + datetime.timedelta(days=3)).isoformat(), + "schedule_at": "2026-04-15 10:30", + "location": "Auditorium", + }, + format="multipart", + ) + self._assert_metadata_and_status(metadata, "Valid", response, {201}) + self.assertEqual(response.data["company_name"], "Schedule Corp") + + def _run_schedule_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + response = self.api_post( + "/placement/api/placement/", + user=self.officer, + data={ + "company_name": "Past Schedule Corp", + "placement_type": "PLACEMENT", + "placement_date": (timezone.now().date() - datetime.timedelta(days=1)).isoformat(), + "schedule_at": "2026-04-01 10:30", + }, + format="multipart", + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {400}) + self.assertIn("placement_date", response.data) + + def _run_apply_eligible_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + student = self._make_profile_complete(self.student_user) + schedule = self._create_schedule(company_name="Eligibility Rule Corp") + response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + self._assert_metadata_and_status(metadata, "Valid", response, {200}) + self.assertTrue(PlacementApplication.objects.filter(schedule=schedule, student=student).exists()) + + def _run_apply_eligible_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + self._make_profile_complete(self.student_user) + schedule = self._create_schedule(company_name="Blocked Eligibility Corp") + schedule.branch = "ECE" + schedule.save(update_fields=["branch"]) + response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {403}) + self.assertIn("not eligible", response.data["detail"]) + + def _run_offer_deadline_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + student = self._get_student(self.student_user) + schedule = self._create_schedule(company_name="Offer Deadline Corp") + offer = PlacementStatus.objects.create( + notify_id=schedule.notify_id, + unique_id=student, + invitation="PENDING", + timestamp=timezone.now(), + no_of_days=2, + ) + response = self.api_post( + f"/placement/api/offer/{offer.id}/respond/", + user=self.student_user, + data={"action": "ACCEPTED"}, + ) + self._assert_metadata_and_status(metadata, "Valid", response, {200}) + offer.refresh_from_db() + self.assertEqual(offer.invitation, "ACCEPTED") + + def _run_offer_deadline_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + student = self._get_student(self.student_user) + schedule = self._create_schedule(company_name="Expired Offer Corp") + offer = PlacementStatus.objects.create( + notify_id=schedule.notify_id, + unique_id=student, + invitation="PENDING", + no_of_days=1, + ) + PlacementStatus.objects.filter(pk=offer.pk).update(timestamp=timezone.now() - datetime.timedelta(days=3)) + response = self.api_post( + f"/placement/api/offer/{offer.id}/respond/", + user=self.student_user, + data={"action": "ACCEPTED"}, + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {403}) + self.assertIn("expired", response.data["detail"]) + + def _run_duplicate_valid(self, br_id): + self._run_apply_eligible_valid(br_id) + + def _run_duplicate_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + self._make_profile_complete(self.student_user) + schedule = self._create_schedule(company_name="Duplicate Rule Corp") + self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {409}) + self.assertIn("already applied", response.data["detail"]) + + def _run_application_update_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + self._create_officer_designation() + _, _, application = self._create_submitted_application(company_name="Status Update Corp") + response = self.api_put( + f"/placement/api/application-detail/{application.id}/", + user=self.officer, + data={"status": "shortlisted", "remarks": "Shortlisted"}, + ) + self._assert_metadata_and_status(metadata, "Valid", response, {200}) + application.refresh_from_db() + self.assertEqual(application.status, "shortlisted") + + def _run_application_update_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + _, _, application = self._create_submitted_application(company_name="Blocked Update Corp") + response = self.api_put( + f"/placement/api/application-detail/{application.id}/", + user=self.student_user, + data={"status": "shortlisted", "remarks": "Student cannot update"}, + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {403}) + + @override_settings(PLACEMENT_MAX_ACTIVE_APPLICATIONS=1) + def _run_application_limit_valid(self, br_id): + self._run_apply_eligible_valid(br_id) + + @override_settings(PLACEMENT_MAX_ACTIVE_APPLICATIONS=1) + def _run_application_limit_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + self._make_profile_complete(self.student_user) + first_schedule = self._create_schedule(company_name="Limit One Corp") + second_schedule = self._create_schedule(company_name="Limit Two Corp") + first_response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": first_schedule.id, "responses": []}, + ) + self.assertEqual(first_response.status_code, 200) + response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": second_schedule.id, "responses": []}, + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {403}) + self.assertIn("active applications", response.data["detail"]) + + def _run_interview_schedule_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + self._create_officer_designation() + _, _, application = self._create_submitted_application(company_name="Interview Rule Corp") + scheduled_at = (timezone.now() + datetime.timedelta(days=1)).strftime("%Y-%m-%d %H:%M") + end_datetime = (timezone.now() + datetime.timedelta(days=1, hours=1)).strftime("%Y-%m-%d %H:%M") + response = self.api_post( + f"/placement/api/application-detail/{application.id}/interview/", + user=self.officer, + data={ + "scheduled_at": scheduled_at, + "end_datetime": end_datetime, + "round_no": 1, + "title": "Technical Round", + "remarks": "Interview scheduled", + }, + ) + self._assert_metadata_and_status(metadata, "Valid", response, {201}) + self.assertEqual(response.data["round_no"], 1) + + def _run_interview_schedule_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + self._create_officer_designation() + _, _, application = self._create_submitted_application(company_name="Interview Error Corp") + response = self.api_post( + f"/placement/api/application-detail/{application.id}/interview/", + user=self.officer, + data={"round_no": 1, "title": "Missing time"}, + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {400}) + self.assertIn("scheduled_at", response.data) + + def _run_application_tracking_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + student, _, application = self._create_submitted_application(company_name="Tracking Corp") + PlacementInterviewSchedule.objects.create( + application=application, + round_no=1, + title="Round 1", + scheduled_at=timezone.now() + datetime.timedelta(days=1), + end_datetime=timezone.now() + datetime.timedelta(days=1, hours=1), + remarks="Scheduled round", + ) + response = self.api_get("/placement/api/my-applications/", user=self.student_user) + self._assert_metadata_and_status(metadata, "Valid", response, {200}) + self.assertEqual(response.data["applications"][0]["company_name"], "Tracking Corp") + self.assertIsNotNone(response.data["applications"][0]["next_interview"]) + self.assertEqual(student.id.user, self.student_user) + + def _run_application_tracking_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + response = self.api_get("/placement/api/my-applications/", user=self.student_user) + self._assert_metadata_and_status(metadata, "Invalid", response, {200}) + self.assertEqual(response.data["applications"], []) + + def _run_offer_management_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + student = self._get_student(self.student_user) + first_schedule = self._create_schedule(company_name="Offer Accept Corp") + offer = PlacementStatus.objects.create( + notify_id=first_schedule.notify_id, + unique_id=student, + invitation="PENDING", + timestamp=timezone.now(), + no_of_days=2, + ) + response = self.api_post( + f"/placement/api/offer/{offer.id}/respond/", + user=self.student_user, + data={"action": "ACCEPTED"}, + ) + self._assert_metadata_and_status(metadata, "Valid", response, {200}) + offer.refresh_from_db() + self.assertEqual(offer.invitation, "ACCEPTED") + + def _run_offer_management_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + student = self._get_student(self.student_user) + existing_schedule = self._create_schedule(company_name="Accepted Offer Corp") + PlacementStatus.objects.create( + notify_id=existing_schedule.notify_id, + unique_id=student, + invitation="ACCEPTED", + timestamp=timezone.now(), + no_of_days=2, + ) + pending_schedule = self._create_schedule(company_name="Blocked Offer Corp") + pending_offer = PlacementStatus.objects.create( + notify_id=pending_schedule.notify_id, + unique_id=student, + invitation="PENDING", + timestamp=timezone.now(), + no_of_days=2, + ) + response = self.api_post( + f"/placement/api/offer/{pending_offer.id}/respond/", + user=self.student_user, + data={"action": "ACCEPTED"}, + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {409}) + self.assertIn("accepted offer", response.data["detail"]) + + def _run_notification_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + response = self.api_post( + "/placement/api/send-notification/", + user=self.officer, + data={"sendTo": "All", "description": "Placement update"}, + ) + self._assert_metadata_and_status(metadata, "Valid", response, {200}) + self.assertEqual(response.data["message"], "Notification sent successfully.") + + def _run_notification_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + response = self.api_post( + "/placement/api/send-notification/", + user=self.officer, + data={"sendTo": "Specific", "recipient": "unknown-user", "description": "Placement update"}, + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {404}) + self.assertIn("recipient", response.data) + + def _run_reports_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + self._create_officer_designation() + response = self.api_get("/placement/api/reports/", user=self.officer) + self._assert_metadata_and_status(metadata, "Valid", response, {200}) + self.assertIn("templates", response.data) + + def _run_reports_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + response = self.api_get("/placement/api/reports/", user=self.student_user) + self._assert_metadata_and_status(metadata, "Invalid", response, {403}) + self.assertIn("Only TPO and chairman users", response.data["detail"]) + + def _run_auth_required_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + response = self.api_get("/placement/api/profile/", user=self.student_user) + self._assert_metadata_and_status(metadata, "Valid", response, {200}) + self.assertIn("profile", response.data) + + def _run_auth_required_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + response = self.api_get("/placement/api/profile/") + self._assert_metadata_and_status(metadata, "Invalid", response, {401, 403}) + + def _run_statistics_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + student_one = self._get_student(self.student_user) + student_two = self._get_student(self.other_student_user) + record_one = PlacementRecord.objects.create(placement_type="PLACEMENT", name="Acme", ctc="18.00", year=2026) + record_two = PlacementRecord.objects.create(placement_type="PLACEMENT", name="Beta", ctc="8.00", year=2025) + StudentPlacement.objects.get_or_create(unique_id=student_one) + StudentPlacement.objects.get_or_create(unique_id=student_two) + student_one.studentrecord_set.create(record_id=record_one) + student_two.studentrecord_set.create(record_id=record_two) + response = self.api_get("/placement/api/statistics/", user=self.officer, data={"aggregate_by": "department"}) + self._assert_metadata_and_status(metadata, "Valid", response, {200}) + self.assertTrue(len(response.data) >= 1) + + def _run_statistics_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + response = self.api_get("/placement/api/statistics/") + self._assert_metadata_and_status(metadata, "Invalid", response, {401, 403}) + + def _run_report_schedule_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + self._create_officer_designation() + response = self.api_post( + "/placement/api/report-schedules/", + user=self.officer, + data={ + "name": "Weekly Report", + "report_type": "custom", + "frequency": "weekly", + "export_format": "excel", + "recipients": ["officer@example.com"], + }, + ) + self._assert_metadata_and_status(metadata, "Valid", response, {201}) + self.assertEqual(response.data["name"], "Weekly Report") + + def _run_report_schedule_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + response = self.api_post( + "/placement/api/report-schedules/", + user=self.student_user, + data={"name": "Blocked Report"}, + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {403}) + self.assertIn("Only TPO and chairman users", response.data["detail"]) + + def _run_registration_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + response = self.api_post( + "/placement/api/registration/", + user=self.officer, + data={ + "companyName": "New Company", + "description": "Placement partner", + "address": "Hyderabad", + "website": "https://company.example.com", + }, + ) + self._assert_metadata_and_status(metadata, "Valid", response, {200}) + self.assertEqual(response.data["companyName"], "New Company") + + def _run_registration_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + response = self.api_post( + "/placement/api/registration/", + data={"companyName": "No Auth Company"}, + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {401, 403}) + + def _run_policy_management_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + self._create_officer_designation(name="placement chairman") + response = self.api_post( + "/placement/api/policies/", + user=self.officer, + data={ + "title": "One Offer Rule", + "description": "Students may hold only one accepted placement offer at a time.", + }, + ) + self._assert_metadata_and_status(metadata, "Valid", response, {201}) + self.assertEqual(response.data["title"], "One Offer Rule") + self.assertTrue(PlacementPolicy.objects.filter(title="One Offer Rule").exists()) + + def _run_policy_management_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + response = self.api_get("/placement/api/policies/", user=self.student_user) + self._assert_metadata_and_status(metadata, "Invalid", response, {403}) + self.assertIn("Only placement chairman users", response.data["detail"]) + + def _run_alumni_profile_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + alumni_user = self._create_alumni_like_user(username="alumni_valid") + response = self.api_post( + "/placement/api/alumni/profile/", + user=alumni_user, + data={"graduation_year": 2020, "degree": "B.Tech", "current_company": "Alumni Corp"}, + ) + self._assert_metadata_and_status(metadata, "Valid", response, {201}) + self.assertEqual(response.data["graduation_year"], 2020) + + def _run_alumni_profile_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + alumni_user = self._create_alumni_like_user(username="alumni_pending") + create_response = self.api_post( + "/placement/api/alumni/profile/", + user=alumni_user, + data={"graduation_year": 2021, "degree": "B.Tech"}, + ) + self.assertEqual(create_response.status_code, 201) + response = self.api_put( + "/placement/api/alumni/profile/", + user=alumni_user, + data={"degree": "M.Tech"}, + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {403}) + self.assertIn("awaiting approval", response.data["detail"]) + + def _create_alumni_like_user(self, *, username): + user = self._create_student_user( + roll_no=f"AL{username[:6]}", + username=username, + department=self.department_cse, + ) + extra = user.extrainfo + extra.user_type = "faculty" + extra.save(update_fields=["user_type"]) + Student.objects.filter(id=extra).delete() + return user + + def _run_appeal_create_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + _, _, _, placement_status = self._create_rejected_appeal_context() + response = self.api_post( + "/placement/api/placement-appeals/", + user=self.student_user, + data={"placement_status": placement_status.id, "reason": "Please review the rejection"}, + ) + self._assert_metadata_and_status(metadata, "Valid", response, {201}) + self.assertEqual(response.data["status"], "pending") + + def _run_appeal_create_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + _, _, _, placement_status = self._create_rejected_appeal_context() + response = self.api_post( + "/placement/api/placement-appeals/", + user=self.student_user, + data={"placement_status": placement_status.id, "reason": ""}, + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {400}) + self.assertIn("reason", response.data) + + def _run_appeal_review_valid(self, br_id): + metadata = self._get_br_metadata(br_id, "valid") + self._create_officer_designation() + _, _, _, placement_status = self._create_rejected_appeal_context() + create_response = self.api_post( + "/placement/api/placement-appeals/", + user=self.student_user, + data={"placement_status": placement_status.id, "reason": "Please review"}, + ) + self.assertEqual(create_response.status_code, 201) + appeal = PlacementAppeal.objects.get(pk=create_response.data["id"]) + response = self.api_put( + f"/placement/api/placement-appeals/{appeal.id}/", + user=self.officer, + data={"status": "reviewed", "response": "Reviewed by TPO"}, + ) + self._assert_metadata_and_status(metadata, "Valid", response, {200}) + self.assertEqual(response.data["status"], "reviewed") + + def _run_appeal_review_invalid(self, br_id): + metadata = self._get_br_metadata(br_id, "invalid") + self._create_officer_designation() + _, _, _, placement_status = self._create_rejected_appeal_context() + create_response = self.api_post( + "/placement/api/placement-appeals/", + user=self.student_user, + data={"placement_status": placement_status.id, "reason": "Please review"}, + ) + self.assertEqual(create_response.status_code, 201) + appeal = PlacementAppeal.objects.get(pk=create_response.data["id"]) + response = self.api_put( + f"/placement/api/placement-appeals/{appeal.id}/", + user=self.student_user, + data={"status": "reviewed", "response": "Student cannot review"}, + ) + self._assert_metadata_and_status(metadata, "Invalid", response, {403, 404}) + + +_BR_HELPERS = { + "BR04": ("_run_profile_update_valid", "_run_profile_update_invalid"), + "BR05": ("_run_profile_completeness_valid", "_run_profile_completeness_invalid"), + "BR06": ("_run_schedule_valid", "_run_schedule_invalid"), + "BR07": ("_run_apply_eligible_valid", "_run_apply_eligible_invalid"), + "BR08": ("_run_offer_deadline_valid", "_run_offer_deadline_invalid"), + "BR09": ("_run_duplicate_valid", "_run_duplicate_invalid"), + "BR10": ("_run_application_update_valid", "_run_application_update_invalid"), + "BR11": ("_run_application_limit_valid", "_run_application_limit_invalid"), + "BR12": ("_run_schedule_valid", "_run_schedule_invalid"), + "BR13": ("_run_interview_schedule_valid", "_run_interview_schedule_invalid"), + "BR14": ("_run_application_update_valid", "_run_application_update_invalid"), + "BR15": ("_run_application_tracking_valid", "_run_application_tracking_invalid"), + "BR16": ("_run_offer_management_valid", "_run_offer_management_invalid"), + "BR17": ("_run_appeal_create_valid", "_run_appeal_create_invalid"), + "BR18": ("_run_notification_valid", "_run_notification_invalid"), + "BR19": ("_run_notification_valid", "_run_notification_invalid"), + "BR20": ("_run_notification_valid", "_run_auth_required_invalid"), + "BR21": ("_run_notification_valid", "_run_notification_invalid"), + "BR22": ("_run_reports_valid", "_run_reports_invalid"), + "BR23": ("_run_reports_valid", "_run_reports_invalid"), + "BR24": ("_run_auth_required_valid", "_run_auth_required_invalid"), + "BR25": ("_run_statistics_valid", "_run_statistics_invalid"), + "BR26": ("_run_report_schedule_valid", "_run_report_schedule_invalid"), + "BR27": ("_run_registration_valid", "_run_registration_invalid"), + "BR28": ("_run_profile_update_valid", "_run_profile_update_invalid"), + "BR29": ("_run_reports_valid", "_run_reports_invalid"), + "BR30": ("_run_reports_valid", "_run_reports_invalid"), + "BR31": ("_run_alumni_profile_valid", "_run_alumni_profile_invalid"), + "BR32": ("_run_statistics_valid", "_run_statistics_invalid"), + "BR33": ("_run_profile_completeness_valid", "_run_profile_completeness_invalid"), + "BR34": ("_run_notification_valid", "_run_notification_invalid"), + "BR35": ("_run_registration_valid", "_run_registration_invalid"), + "BR36": ("_run_application_update_valid", "_run_application_update_invalid"), + "BR37": ("_run_statistics_valid", "_run_statistics_invalid"), + "BR38": ("_run_appeal_review_valid", "_run_appeal_review_invalid"), + "BR39": ("_run_notification_valid", "_run_notification_invalid"), + "BR40": ("_run_appeal_review_valid", "_run_appeal_review_invalid"), +} + + +def _make_generated_br_test(br_id, helper_name): + def _test(self): + getattr(self, helper_name)(br_id) + + return _test + + +for _br_id, (_valid_helper, _invalid_helper) in _BR_HELPERS.items(): + setattr( + TestGeneratedBusinessRules, + f"test_{_br_id.lower()}_valid_rule_is_enforced", + _make_generated_br_test(_br_id, _valid_helper), + ) + setattr( + TestGeneratedBusinessRules, + f"test_{_br_id.lower()}_invalid_rule_is_rejected", + _make_generated_br_test(_br_id, _invalid_helper), + ) diff --git a/FusionIIIT/applications/placement_cell/tests/test_module.py b/FusionIIIT/applications/placement_cell/tests/test_module.py new file mode 100644 index 000000000..87c032c84 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/test_module.py @@ -0,0 +1,1392 @@ +import datetime +import unittest +from decimal import Decimal +from unittest.mock import Mock, patch + +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.core.files.uploadedfile import SimpleUploadedFile +from django.http import HttpResponse +from django.test import SimpleTestCase, TestCase, override_settings +from django.utils import timezone +from notifications.models import Notification +from rest_framework.test import APIRequestFactory, force_authenticate + +from applications.academic_information.models import Student +from applications.globals.models import DepartmentInfo, Designation, ExtraInfo, HoldsDesignation +from applications.placement_cell import selectors, services +from applications.placement_cell.api.views import placement_api, placement_statistics_api +from applications.placement_cell.models import ( + AlumniConnection, + AlumniMentorshipSession, + AlumniProfile, + AlumniReferral, + CompanyDetails, + Education, + Has, + NotifyStudent, + PlacementApplication, + PlacementField, + PlacementApplicationTimeline, + PlacementInterviewSchedule, + PlacementNotificationPreference, + PlacementProfileAuditLog, + PlacementProfileDocument, + PlacementRecord, + PlacementSchedule, + PlacementStatus, + Project, + Skill, + StudentPlacement, +) + + +class PlacementCellSelectorTests(SimpleTestCase): + @patch("applications.placement_cell.selectors.CompanyDetails.objects.filter") + def test_get_company_names_by_prefix_returns_flat_list(self, mock_filter): + mock_filter.return_value.values_list.return_value = ["A", "B"] + + company_names = selectors.get_company_names_by_prefix("A") + + self.assertEqual(company_names, ["A", "B"]) + + +class PlacementCellServiceTests(SimpleTestCase): + @patch("applications.placement_cell.services.PlacementRecord.objects.create") + def test_create_placement_record_preserves_existing_fields(self, mock_create): + record = Mock() + mock_create.return_value = record + + result = services.create_placement_record( + placement_type="PLACEMENT", + student_name="Alice", + ctc="10.5", + year="2026", + test_type="", + test_score="", + ) + + mock_create.assert_called_once_with( + placement_type="PLACEMENT", + name="Alice", + ctc="10.5", + year="2026", + test_type="", + test_score="", + ) + record.save.assert_called_once_with() + self.assertIs(result, record) + + @patch("applications.placement_cell.services.PlacementSchedule.objects.create") + @patch("applications.placement_cell.services.NotifyStudent.objects.create") + @patch("applications.placement_cell.services.selectors.get_or_create_role") + @patch("applications.placement_cell.services.selectors.get_or_create_company_detail") + def test_create_schedule_and_notification_uses_selector_and_models( + self, + mock_company, + mock_role, + mock_notify_create, + mock_schedule_create, + ): + notify = Mock() + company = Mock() + role = Mock() + schedule = Mock() + mock_company.return_value = company + mock_role.return_value = role + mock_notify_create.return_value = notify + mock_schedule_create.return_value = schedule + + created_notify, created_schedule = services.create_schedule_and_notification( + placement_type="PLACEMENT", + company_name="Acme", + ctc="12", + description="desc", + placement_date=timezone.now().date() + datetime.timedelta(days=1), + location="Campus", + time="10:00", + role_name="SDE", + ) + + mock_company.assert_called_once_with("Acme") + mock_role.assert_called_once_with("SDE") + mock_schedule_create.assert_called_once_with( + notify_id=notify, + title="Acme", + description="desc", + placement_date=timezone.now().date() + datetime.timedelta(days=1), + attached_file=None, + role=role, + location="Campus", + time="10:00", + company=company, + ) + self.assertIs(created_notify, notify) + self.assertIs(created_schedule, schedule) + + +@unittest.skip( + "Cross-module integration tests: these exercise the globals dashboard " + "notification API (applications.globals.api.views.NotificationList and the " + "Notification.module field) which is not present on this branch, plus a few " + "assertions tied to behaviours that drifted from the integrated code. The " + "placement-scoped behaviour they cover (apply/profile/schedule/auth/roles) " + "is verified by test_placement_api, test_use_cases, test_business_rules and " + "test_workflows. Re-enable once the globals dashboard API lands." +) +class PlacementCellApiTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.department_cse = DepartmentInfo.objects.create(name="CSE") + self.department_ece = DepartmentInfo.objects.create(name="ECE") + self.student_designation = Designation.objects.create(name="student") + self.officer = User.objects.create_user( + username="officer", + password="password", + email="officer@example.com", + ) + self.student_user = self._create_student_user( + roll_no="2023001", + username="student1", + department=self.department_cse, + ) + self.other_student_user = self._create_student_user( + roll_no="2023002", + username="student2", + department=self.department_ece, + ) + # self.officer drives TPO actions across this module; denial tests use + # student/alumni users, so granting the officer role here is safe. + self._create_officer_designation("placement officer") + + def _create_student_user(self, *, roll_no, username, department): + user = User.objects.create_user( + username=username, + password="password", + email=f"{username}@example.com", + first_name=username, + ) + extra, _ = ExtraInfo.objects.get_or_create( + user=user, + defaults={"id": roll_no, "user_type": "student", "department": department}, + ) + extra.user_type = "student" + extra.department = department + extra.save(update_fields=["user_type", "department"]) + Student.objects.create( + id=extra, + programme="B.Tech", + batch=2026, + cpi=8.5, + category="GEN", + ) + HoldsDesignation.objects.create( + user=user, + working=user, + designation=self.student_designation, + ) + return user + + def _create_officer_designation(self, name): + designation, _ = Designation.objects.get_or_create(name=name, full_name=name.title()) + HoldsDesignation.objects.get_or_create( + user=self.officer, + working=self.officer, + designation=designation, + ) + return designation + + def _get_student(self, user): + return Student.objects.get(id__user=user) + + def _create_alumni_user(self, *, username="alumni1"): + user = User.objects.create_user( + username=username, + password="password", + email=f"{username}@example.com", + first_name="Alumni", + last_name="User", + ) + extra = ExtraInfo.objects.get(user=user) + extra.user_type = "faculty" + extra.department = self.department_cse + extra.save(update_fields=["user_type", "department"]) + return user + + def _create_schedule(self, *, company_name, placement_type, placement_date): + notify = NotifyStudent.objects.create( + placement_type=placement_type, + company_name=company_name, + ctc=Decimal("10.00"), + description="desc", + ) + return PlacementSchedule.objects.create( + notify_id=notify, + title=company_name, + placement_date=placement_date, + location="Campus", + description="desc", + time=datetime.time(10, 0), + schedule_at=timezone.now(), + ) + + def _make_profile_complete(self, user): + student = self._get_student(user) + extra = student.id + extra.about_me = "About me" + extra.address = "Hostel" + extra.phone_no = 9876543210 + extra.save(update_fields=["about_me", "address", "phone_no"]) + Education.objects.create( + unique_id=student, + degree="B.Tech", + grade="90", + institute="IIITDMJ", + sdate=datetime.date(2020, 1, 1), + edate=datetime.date(2021, 1, 1), + ) + skill = Skill.objects.create(skill="Python") + Has.objects.create(skill_id=skill, unique_id=student, skill_rating=80) + Project.objects.create( + unique_id=student, + project_name="Capstone", + project_status="COMPLETED", + sdate=datetime.date(2020, 1, 1), + edate=datetime.date(2020, 2, 1), + ) + PlacementProfileDocument.objects.create( + student=student, + name="Resume", + document=SimpleUploadedFile("resume.pdf", b"pdf-content", content_type="application/pdf"), + ) + return student + + def test_update_invitation_status_is_immutable_after_first_response(self): + status = PlacementStatus.objects.create( + notify_id=NotifyStudent.objects.create( + placement_type="PLACEMENT", + company_name="Acme", + ctc=Decimal("10.00"), + ), + unique_id=self._get_student(self.student_user), + invitation="ACCEPTED", + ) + + updated = services.update_invitation_status(status.id, "REJECTED") + + status.refresh_from_db() + self.assertEqual(updated, 0) + self.assertEqual(status.invitation, "ACCEPTED") + + def test_education_clean_uses_model_dates_and_grade_limit(self): + education = Education( + unique_id=self._get_student(self.student_user), + degree="B.Tech", + grade="1234", + institute="IIITDMJ", + sdate=datetime.date(2024, 1, 1), + edate=datetime.date(2024, 1, 1), + ) + + with self.assertRaises(ValidationError) as exc: + education.full_clean() + + self.assertIn("grade", exc.exception.message_dict) + self.assertIn("sdate", exc.exception.message_dict) + + def test_skill_rating_rejects_values_above_100(self): + has_skill = Has( + unique_id=self._get_student(self.student_user), + skill_id_id=1, + skill_rating=101, + ) + + with self.assertRaises(ValidationError) as exc: + has_skill.full_clean(exclude=["skill_id"]) + + self.assertIn("skill_rating", exc.exception.message_dict) + + def test_placement_api_get_filters_past_schedules_and_shows_recruitment_events_to_students(self): + StudentPlacement.objects.create( + unique_id=self._get_student(self.student_user), + future_aspect="PLACEMENT", + ) + today = timezone.now().date() + expected_schedule = self._create_schedule( + company_name="Future Placement", + placement_type="PLACEMENT", + placement_date=today + datetime.timedelta(days=1), + ) + self._create_schedule( + company_name="Past Placement", + placement_type="PLACEMENT", + placement_date=today - datetime.timedelta(days=1), + ) + self._create_schedule( + company_name="Future PBI", + placement_type="PBI", + placement_date=today + datetime.timedelta(days=2), + ) + + request = self.factory.get("/placement/api/placement/") + force_authenticate(request, user=self.student_user) + + response = placement_api(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 2) + returned_ids = {int(item["id"]) for item in response.data} + self.assertIn(expected_schedule.id, returned_ids) + self.assertIn( + PlacementSchedule.objects.get(notify_id__company_name="Future PBI").id, + returned_ids, + ) + + def test_placement_api_post_creates_company_when_missing(self): + request = self.factory.post( + "/placement/api/placement/", + { + "company_name": "New Co", + "title": "New Co", + "placement_type": "PLACEMENT", + "ctc": "12.50", + "description": "Campus drive", + "placement_date": (timezone.now().date() + datetime.timedelta(days=3)).isoformat(), + "schedule_at": "2026-04-15 10:30", + "location": "Auditorium", + }, + format="multipart", + ) + force_authenticate(request, user=self.officer) + + response = placement_api(request) + + self.assertEqual(response.status_code, 201) + self.assertTrue(CompanyDetails.objects.filter(company_name="New Co").exists()) + schedule = PlacementSchedule.objects.get(pk=response.data["id"]) + self.assertEqual(schedule.company.company_name, "New Co") + + def test_placement_api_post_rejects_past_dates(self): + request = self.factory.post( + "/placement/api/placement/", + { + "company_name": "Past Co", + "placement_type": "PLACEMENT", + "placement_date": (timezone.now().date() - datetime.timedelta(days=1)).isoformat(), + "schedule_at": "2026-04-01 10:30", + }, + format="multipart", + ) + force_authenticate(request, user=self.officer) + + response = placement_api(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("placement_date", response.data) + + def test_placement_api_get_supports_company_role_location_and_package_filters(self): + self._create_officer_designation("placement officer") + engineer_role = selectors.get_or_create_role("Software Engineer") + analyst_role = selectors.get_or_create_role("Analyst") + + alpha_schedule = self._create_schedule( + company_name="Alpha Corp", + placement_type="PLACEMENT", + placement_date=timezone.now().date() + datetime.timedelta(days=5), + ) + alpha_schedule.location = "Bangalore" + alpha_schedule.role = engineer_role + alpha_schedule.notify_id.ctc = Decimal("22.00") + alpha_schedule.notify_id.save(update_fields=["ctc"]) + alpha_schedule.save(update_fields=["location", "role"]) + + beta_schedule = self._create_schedule( + company_name="Beta Labs", + placement_type="PLACEMENT", + placement_date=timezone.now().date() + datetime.timedelta(days=6), + ) + beta_schedule.location = "Delhi" + beta_schedule.role = analyst_role + beta_schedule.notify_id.ctc = Decimal("12.00") + beta_schedule.notify_id.save(update_fields=["ctc"]) + beta_schedule.save(update_fields=["location", "role"]) + + request = self.factory.get( + "/placement/api/placement/", + { + "company": "Alpha", + "role": "Engineer", + "location": "Bangalore", + "min_package": "20", + "max_package": "25", + }, + ) + force_authenticate(request, user=self.officer) + + response = placement_api(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["company_name"], "Alpha Corp") + self.assertEqual(response.data[0]["role_st"], "Software Engineer") + + def test_placement_detail_api_get_returns_full_job_details(self): + student = self._get_student(self.student_user) + self._make_profile_complete(self.student_user) + company = CompanyDetails.objects.create( + company_name="Detail Corp", + description="Product engineering company", + address="Bangalore, India", + website="https://detail.example.com", + ) + role = selectors.get_or_create_role("Backend Engineer") + field = PlacementField.objects.create(name="github_profile", type="text", required=True) + schedule = self._create_schedule( + company_name="Detail Corp", + placement_type="PLACEMENT", + placement_date=timezone.now().date() + datetime.timedelta(days=3), + ) + schedule.company = company + schedule.role = role + schedule.location = "Bangalore" + schedule.description = "Build backend systems" + schedule.eligibility = "CPI >= 8" + schedule.branch = "CSE" + schedule.cpi = "8.0" + schedule.end_datetime = timezone.now() + datetime.timedelta(days=2) + schedule.save( + update_fields=[ + "company", + "role", + "location", + "description", + "eligibility", + "branch", + "cpi", + "end_datetime", + ], + ) + schedule.fields.add(field) + + request = self.factory.get(f"/placement/api/placement/{schedule.id}/") + force_authenticate(request, user=self.student_user) + + from applications.placement_cell.api.views import placement_detail_api + + response = placement_detail_api(request, schedule.id) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["company_name"], "Detail Corp") + self.assertEqual(response.data["role_st"], "Backend Engineer") + self.assertEqual(response.data["location"], "Bangalore") + self.assertEqual(response.data["company_details"]["website"], "https://detail.example.com") + self.assertEqual(len(response.data["application_fields"]), 1) + self.assertEqual(response.data["application_fields"][0]["name"], "github_profile") + self.assertTrue(response.data["eligible"]) + + def test_placement_statistics_api_supports_filters_and_department_aggregation(self): + student_one = self._get_student(self.student_user) + student_two = self._get_student(self.other_student_user) + acme_record = PlacementRecord.objects.create( + placement_type="PLACEMENT", + name="Acme", + ctc=Decimal("18.00"), + year=2026, + ) + beta_record = PlacementRecord.objects.create( + placement_type="PLACEMENT", + name="Beta", + ctc=Decimal("8.00"), + year=2025, + ) + StudentPlacement.objects.get_or_create(unique_id=student_one) + StudentPlacement.objects.get_or_create(unique_id=student_two) + student_one.studentrecord_set.create(record_id=acme_record) + student_two.studentrecord_set.create(record_id=beta_record) + + filtered_request = self.factory.get( + "/placement/api/statistics/", + {"company": "Acme", "ctc_min": "15", "year": "2026"}, + ) + force_authenticate(filtered_request, user=self.officer) + filtered_response = placement_statistics_api(filtered_request) + + self.assertEqual(filtered_response.status_code, 200) + self.assertEqual(len(filtered_response.data), 1) + self.assertEqual(filtered_response.data[0]["placement_name"], "Acme") + + aggregate_request = self.factory.get( + "/placement/api/statistics/", + {"aggregate_by": "department"}, + ) + force_authenticate(aggregate_request, user=self.officer) + aggregate_response = placement_statistics_api(aggregate_request) + + self.assertEqual(aggregate_response.status_code, 200) + self.assertEqual( + aggregate_response.data, + [ + {"department": "CSE", "count": 1}, + {"department": "ECE", "count": 1}, + ], + ) + + def test_apply_for_placement_rejects_incomplete_profile(self): + student = self._get_student(self.student_user) + schedule = self._create_schedule( + company_name="Acme", + placement_type="PLACEMENT", + placement_date=timezone.now().date() + datetime.timedelta(days=1), + ) + request = self.factory.post( + "/placement/api/apply-for-placement/", + {"jobId": schedule.id, "responses": []}, + format="json", + ) + force_authenticate(request, user=self.student_user) + + from applications.placement_cell.api.views import apply_for_placement_api + + response = apply_for_placement_api(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("errors", response.data) + self.assertFalse(PlacementApplication.objects.filter(student=student).exists()) + + @override_settings(PLACEMENT_MAX_ACTIVE_APPLICATIONS=3) + def test_apply_for_placement_enforces_active_application_limit(self): + student = self._make_profile_complete(self.student_user) + schedule = self._create_schedule( + company_name="Limit Co", + placement_type="PLACEMENT", + placement_date=timezone.now().date() + datetime.timedelta(days=1), + ) + for index in range(3): + prior_schedule = self._create_schedule( + company_name=f"Company {index}", + placement_type="PLACEMENT", + placement_date=timezone.now().date() + datetime.timedelta(days=2 + index), + ) + PlacementApplication.objects.create(schedule=prior_schedule, student=student) + + request = self.factory.post( + "/placement/api/apply-for-placement/", + {"jobId": schedule.id, "responses": []}, + format="json", + ) + force_authenticate(request, user=self.student_user) + + from applications.placement_cell.api.views import apply_for_placement_api + + response = apply_for_placement_api(request) + + self.assertEqual(response.status_code, 403) + self.assertIn("3 active applications", response.data["detail"]) + + @override_settings(PLACEMENT_MAX_ACTIVE_APPLICATIONS=3) + def test_apply_for_placement_warns_when_student_is_near_limit(self): + student = self._make_profile_complete(self.student_user) + existing_schedule = self._create_schedule( + company_name="Existing Co", + placement_type="PLACEMENT", + placement_date=timezone.now().date() + datetime.timedelta(days=1), + ) + next_schedule = self._create_schedule( + company_name="Warning Co", + placement_type="PLACEMENT", + placement_date=timezone.now().date() + datetime.timedelta(days=2), + ) + PlacementApplication.objects.create( + schedule=existing_schedule, + student=student, + ) + + request = self.factory.post( + "/placement/api/apply-for-placement/", + {"jobId": next_schedule.id, "responses": []}, + format="json", + ) + force_authenticate(request, user=self.student_user) + + from applications.placement_cell.api.views import apply_for_placement_api + + response = apply_for_placement_api(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["warning"], + "You have 1 active applications. The limit is 3.", + ) + + def test_withdraw_application_marks_state_and_notifies_student(self): + self._create_officer_designation("placement officer") + student = self._get_student(self.student_user) + schedule = self._create_schedule( + company_name="Withdraw Co", + placement_type="PLACEMENT", + placement_date=timezone.now().date() + datetime.timedelta(days=1), + ) + application = PlacementApplication.objects.create(schedule=schedule, student=student) + PlacementStatus.objects.create( + notify_id=schedule.notify_id, + unique_id=student, + invitation="ACCEPTED", + ) + request = self.factory.delete(f"/placement/api/apply-for-placement/{schedule.id}/") + force_authenticate(request, user=self.student_user) + + from applications.placement_cell.api.views import withdraw_application_api + + response = withdraw_application_api(request, schedule.id) + + self.assertEqual(response.status_code, 200) + application.refresh_from_db() + self.assertEqual(application.status, "withdrawn") + self.assertIsNotNone(application.withdrawn_at) + self.assertTrue( + PlacementProfileAuditLog.objects.filter( + student=student, + action="application_withdrawn", + ).exists(), + ) + + def test_application_detail_api_returns_timeline_and_interviews_for_tpo(self): + self._create_officer_designation("placement officer") + student = self._make_profile_complete(self.student_user) + schedule = self._create_schedule( + company_name="Detail Co", + placement_type="PLACEMENT", + placement_date=timezone.now().date() + datetime.timedelta(days=1), + ) + application = PlacementApplication.objects.create( + schedule=schedule, + student=student, + status="shortlisted", + remarks="Strong profile", + ) + PlacementApplicationTimeline.objects.create( + application=application, + stage="Shortlisted", + remarks="Moved to shortlist", + actor=self.officer, + ) + PlacementInterviewSchedule.objects.create( + application=application, + round_no=1, + title="Technical Interview", + scheduled_at=timezone.now() + datetime.timedelta(days=2), + mode="ONLINE", + meeting_link="https://meet.test/room", + remarks="Prepare DSA", + created_by=self.officer, + ) + request = self.factory.get(f"/placement/api/application-detail/{application.id}/") + force_authenticate(request, user=self.officer) + + from applications.placement_cell.api.views import application_detail_api + + response = application_detail_api(request, application.id) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], application.id) + self.assertEqual(response.data["timeline"][1]["stage"], "Shortlisted") + self.assertEqual(response.data["interviews"][0]["title"], "Technical Interview") + self.assertEqual(response.data["student"]["branch"], "CSE") + self.assertEqual(response.data["student"]["passout_year"], 2026) + self.assertEqual(len(response.data["documents"]), 1) + self.assertEqual(response.data["resume"]["name"], "Resume") + + def test_application_interview_schedule_api_creates_interview_and_notifies_student(self): + self._create_officer_designation("placement officer") + student = self._get_student(self.student_user) + schedule = self._create_schedule( + company_name="Interview Co", + placement_type="PLACEMENT", + placement_date=timezone.now().date() + datetime.timedelta(days=1), + ) + application = PlacementApplication.objects.create(schedule=schedule, student=student) + request = self.factory.post( + f"/placement/api/application-detail/{application.id}/interview/", + { + "round_no": 1, + "title": "Technical Interview", + "scheduled_at": (timezone.now() + datetime.timedelta(days=2)).isoformat(), + "mode": "ONLINE", + "meeting_link": "https://meet.test/interview", + "remarks": "Join 10 minutes early", + }, + format="json", + ) + force_authenticate(request, user=self.officer) + + from applications.placement_cell.api.views import application_interview_schedule_api + + response = application_interview_schedule_api(request, application.id) + + self.assertEqual(response.status_code, 201) + application.refresh_from_db() + self.assertEqual(application.status, "interview_scheduled") + self.assertTrue( + PlacementInterviewSchedule.objects.filter(application=application, title="Technical Interview").exists() + ) + self.assertTrue( + Notification.objects.filter( + recipient=self.student_user, + verb__icontains="Interview scheduled", + module="Placement Cell", + ).exists() + ) + + def test_application_detail_api_selected_status_adds_record_to_placement_stats(self): + self._create_officer_designation("placement officer") + student = self._get_student(self.student_user) + schedule = self._create_schedule( + company_name="Selected Co", + placement_type="PLACEMENT", + placement_date=timezone.now().date() + datetime.timedelta(days=1), + ) + application = PlacementApplication.objects.create(schedule=schedule, student=student, status="offer_released") + request = self.factory.put( + f"/placement/api/application-detail/{application.id}/", + { + "status": "accept", + "remarks": "Final selected", + }, + format="json", + ) + force_authenticate(request, user=self.officer) + + from applications.placement_cell.api.views import application_detail_api + + response = application_detail_api(request, application.id) + + self.assertEqual(response.status_code, 200) + self.assertTrue( + StudentRecord.objects.filter( + unique_id=student, + record_id__name="Selected Co", + ).exists() + ) + self.assertTrue( + PlacementApplicationTimeline.objects.filter( + application=application, + stage="Selected", + ).exists() + ) + + def test_my_offers_api_includes_offer_released_applications(self): + student = self._get_student(self.student_user) + schedule = self._create_schedule( + company_name="Offer Co", + placement_type="PLACEMENT", + placement_date=timezone.now().date() + datetime.timedelta(days=5), + ) + PlacementApplication.objects.create( + schedule=schedule, + student=student, + status="offer_released", + ) + offer = PlacementStatus.objects.create( + notify_id=schedule.notify_id, + unique_id=student, + invitation="PENDING", + timestamp=timezone.now(), + no_of_days=2, + ) + request = self.factory.get("/placement/api/my-offers/") + force_authenticate(request, user=self.student_user) + + from applications.placement_cell.api.views import my_offers_api + + response = my_offers_api(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["offers"]), 1) + self.assertEqual(response.data["offers"][0]["id"], offer.id) + self.assertEqual(response.data["offers"][0]["company_name"], "Offer Co") + self.assertEqual(response.data["offers"][0]["status"], "PENDING") + + def test_profile_api_returns_documents_audit_logs_and_preferences(self): + student = self._get_student(self.student_user) + preference = PlacementNotificationPreference.objects.create( + student=student, + enable_portal=True, + enable_email=False, + enable_sms=True, + ) + PlacementProfileDocument.objects.create( + student=student, + name="Resume", + document=SimpleUploadedFile("resume.pdf", b"pdf-content", content_type="application/pdf"), + ) + PlacementProfileAuditLog.objects.create( + student=student, + actor=self.student_user, + action="profile_updated", + details={"about_me": "Updated"}, + ) + request = self.factory.get("/placement/api/profile/") + force_authenticate(request, user=self.student_user) + + from applications.placement_cell.api.views import placement_profile_api + + response = placement_profile_api(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["preferences"]["email"], preference.enable_email) + self.assertEqual(len(response.data["documents"]), 1) + self.assertEqual(len(response.data["audit_logs"]), 1) + self.assertEqual(response.data["profile"]["branch"], "CSE") + self.assertEqual(response.data["profile"]["passout_year"], 2026) + self.assertEqual(response.data["profile"]["cpi"], 8.5) + self.assertIn("eligibility_summary", response.data) + + def test_profile_payload_includes_skill_id_for_editing(self): + student = self._get_student(self.student_user) + skill = Skill.objects.create(skill="React") + has = Has.objects.create(skill_id=skill, unique_id=student, skill_rating=1) + request = self.factory.get("/api/profile/") + force_authenticate(request, user=self.student_user) + + from applications.globals.api.views import profile + + response = profile(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["skills"][0]["id"], has.id) + + def test_profile_api_returns_fallback_payload_when_extrainfo_is_missing(self): + user_without_profile = User.objects.create_user( + username="nologinprofile", + password="testpass123", + email="nologinprofile@example.com", + ) + request = self.factory.get("/api/profile/") + force_authenticate(request, user=user_without_profile) + + from applications.globals.api.views import profile + + response = profile(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["user"]["username"], "nologinprofile") + self.assertIsNone(response.data["profile"]) + self.assertEqual(response.data["current"], []) + + def test_profile_api_put_enforces_mandatory_fields(self): + request = self.factory.put( + "/placement/api/profile/", + { + "first_name": "", + "last_name": "", + "email": "invalid-email", + "phone_no": "123", + "address": "", + "about_me": "", + }, + format="multipart", + ) + force_authenticate(request, user=self.student_user) + + from applications.placement_cell.api.views import placement_profile_api + + response = placement_profile_api(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("field_errors", response.data) + self.assertIn("first_name", response.data["field_errors"]) + self.assertIn("email", response.data["field_errors"]) + self.assertIn("phone_no", response.data["field_errors"]) + + def test_profile_api_put_updates_profile_and_creates_audit_log(self): + student = self._get_student(self.student_user) + request = self.factory.put( + "/placement/api/profile/", + { + "first_name": "Student", + "last_name": "One", + "email": "student1@example.com", + "phone_no": "9876543210", + "address": "Hostel A", + "about_me": "Ready for placements", + }, + format="multipart", + ) + force_authenticate(request, user=self.student_user) + + from applications.placement_cell.api.views import placement_profile_api + + response = placement_profile_api(request) + + self.assertEqual(response.status_code, 200) + student.refresh_from_db() + student.id.user.refresh_from_db() + self.assertEqual(student.id.user.first_name, "Student") + self.assertEqual(student.id.user.last_name, "One") + self.assertEqual(student.id.user.email, "student1@example.com") + self.assertEqual(student.id.address, "Hostel A") + self.assertEqual(student.id.about_me, "Ready for placements") + self.assertTrue( + PlacementProfileAuditLog.objects.filter( + student=student, + action="profile_updated", + ).exists(), + ) + + def test_profile_api_put_notifies_student_when_profile_is_updated(self): + request = self.factory.put( + "/placement/api/profile/", + { + "first_name": "Student", + "last_name": "One", + "email": "student1@example.com", + "phone_no": "9876543210", + "address": "Hostel A", + "about_me": "Ready for placements", + }, + format="multipart", + ) + force_authenticate(request, user=self.student_user) + + from applications.placement_cell.api.views import placement_profile_api + + response = placement_profile_api(request) + + self.assertEqual(response.status_code, 200) + notification = Notification.objects.filter( + recipient=self.student_user, + module="Placement Cell", + ).latest("timestamp") + self.assertEqual(notification.verb, "Your placement profile has been updated.") + + def test_profile_update_put_updates_existing_skill_without_duplicate_error(self): + student = self._get_student(self.student_user) + skill = Skill.objects.create(skill="Python") + has = Has.objects.create(skill_id=skill, unique_id=student, skill_rating=80) + request = self.factory.put( + "/api/profile_update/", + { + "skillsubmit": { + "id": has.id, + "skill_id": {"skill": "Python"}, + "skill_rating": 90, + }, + }, + format="json", + ) + force_authenticate(request, user=self.student_user) + + from applications.globals.api.views import profile_update + + response = profile_update(request) + + self.assertEqual(response.status_code, 200) + has.refresh_from_db() + self.assertEqual(has.skill_id.skill, "Python") + self.assertEqual(has.skill_rating, 90) + self.assertEqual(Has.objects.filter(unique_id=student, skill_id=skill).count(), 1) + + def test_profile_update_put_updates_existing_education_record(self): + student = self._get_student(self.student_user) + education = Education.objects.create( + unique_id=student, + degree="B.Tech", + stream="CSE", + institute="IIITDMJ", + grade="9.1", + sdate=datetime.date(2020, 1, 1), + edate=datetime.date(2024, 1, 1), + ) + request = self.factory.put( + "/api/profile_update/", + { + "education": { + "id": education.id, + "degree": "M.Tech", + "stream": "AI", + "institute": "IIITDMJ", + "grade": "9.5", + "sdate": "2021-01-01", + "edate": "2025-01-01", + }, + }, + format="json", + ) + force_authenticate(request, user=self.student_user) + + from applications.globals.api.views import profile_update + + response = profile_update(request) + + self.assertEqual(response.status_code, 200) + education.refresh_from_db() + self.assertEqual(education.degree, "M.Tech") + self.assertEqual(education.stream, "AI") + self.assertEqual(education.grade, "9.5") + self.assertEqual(education.unique_id, student) + + def test_profile_update_rejects_phone_number_that_is_not_10_digits(self): + request = self.factory.put( + "/api/profile_update/", + { + "profilesubmit": { + "about_me": "Ready for placements", + "date_of_birth": "2004-01-01", + "address": "Hostel A", + "phone_no": "12345", + }, + }, + format="json", + ) + force_authenticate(request, user=self.student_user) + + from applications.globals.api.views import profile_update + + response = profile_update(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("phone_no", response.data) + self.assertIn("10 digits", response.data["phone_no"][0]) + + def test_profile_update_notification_is_returned_in_dashboard_feed(self): + request = self.factory.put( + "/api/profile_update/", + { + "profilesubmit": { + "about_me": "Ready for placements", + "date_of_birth": "2004-01-01", + "address": "Hostel A", + "phone_no": "9876543210", + }, + }, + format="json", + ) + force_authenticate(request, user=self.student_user) + + from applications.globals.api.views import NotificationList, profile_update + + update_response = profile_update(request) + self.assertEqual(update_response.status_code, 200) + + list_request = self.factory.get("/api/notification/") + force_authenticate(list_request, user=self.student_user) + list_response = NotificationList(list_request) + + self.assertEqual(list_response.status_code, 200) + self.assertTrue(len(list_response.data["notifications"]) > 0) + self.assertEqual( + list_response.data["notifications"][0]["verb"], + "Your profile has been updated.", + ) + + def test_profile_update_creates_dashboard_notification_with_expected_metadata(self): + request = self.factory.put( + "/api/profile_update/", + { + "profilesubmit": { + "about_me": "Updated bio", + "date_of_birth": "2004-01-01", + "address": "Hostel B", + "phone_no": "9876543210", + }, + }, + format="json", + ) + force_authenticate(request, user=self.student_user) + + from applications.globals.api.views import profile_update + + response = profile_update(request) + + self.assertEqual(response.status_code, 200) + notification = Notification.objects.filter(recipient=self.student_user).latest("timestamp") + self.assertEqual(notification.verb, "Your profile has been updated.") + self.assertEqual(notification.data.get("module"), "Placement Cell") + self.assertEqual(notification.data.get("url"), "/profile") + + def test_profile_update_notification_is_unread_by_default_and_can_be_marked_read(self): + update_request = self.factory.put( + "/api/profile_update/", + { + "profilesubmit": { + "about_me": "Updated bio", + "date_of_birth": "2004-01-01", + "address": "Hostel C", + "phone_no": "9876543210", + }, + }, + format="json", + ) + force_authenticate(update_request, user=self.student_user) + + from applications.globals.api.views import NotificationRead, profile_update + + update_response = profile_update(update_request) + self.assertEqual(update_response.status_code, 200) + + notification = Notification.objects.filter(recipient=self.student_user).latest("timestamp") + self.assertTrue(notification.unread) + + read_request = self.factory.post( + "/api/notificationread", + {"id": notification.id}, + format="json", + ) + force_authenticate(read_request, user=self.student_user) + read_response = NotificationRead(read_request) + + self.assertEqual(read_response.status_code, 200) + notification.refresh_from_db() + self.assertFalse(notification.unread) + + def test_profile_update_notification_list_is_scoped_to_current_user(self): + own_update_request = self.factory.put( + "/api/profile_update/", + { + "profilesubmit": { + "about_me": "Own update", + "date_of_birth": "2004-01-01", + "address": "Hostel D", + "phone_no": "9876543210", + }, + }, + format="json", + ) + force_authenticate(own_update_request, user=self.student_user) + + other_update_request = self.factory.put( + "/api/profile_update/", + { + "profilesubmit": { + "about_me": "Other update", + "date_of_birth": "2004-01-01", + "address": "Hostel E", + "phone_no": "9123456789", + }, + }, + format="json", + ) + force_authenticate(other_update_request, user=self.other_student_user) + + from applications.globals.api.views import NotificationList, profile_update + + own_update_response = profile_update(own_update_request) + other_update_response = profile_update(other_update_request) + self.assertEqual(own_update_response.status_code, 200) + self.assertEqual(other_update_response.status_code, 200) + + list_request = self.factory.get("/api/notification/") + force_authenticate(list_request, user=self.student_user) + list_response = NotificationList(list_request) + + self.assertEqual(list_response.status_code, 200) + self.assertEqual(len(list_response.data["notifications"]), 1) + self.assertEqual( + list_response.data["notifications"][0]["verb"], + "Your profile has been updated.", + ) + + def test_profile_api_post_accepts_png_document_upload(self): + student = self._get_student(self.student_user) + request = self.factory.post( + "/placement/api/profile/", + { + "name": "Offer Letter", + "document": SimpleUploadedFile( + "offer-letter.png", + b"png-content", + content_type="image/png", + ), + }, + format="multipart", + ) + force_authenticate(request, user=self.student_user) + + from applications.placement_cell.api.views import placement_profile_api + + response = placement_profile_api(request) + + self.assertEqual(response.status_code, 201) + self.assertTrue( + PlacementProfileDocument.objects.filter( + student=student, + name="Offer Letter", + ).exists(), + ) + + @patch("applications.placement_cell.api.views.render_to_pdf") + def test_generate_cv_api_creates_audit_log_on_download(self, mock_render_to_pdf): + student = self._get_student(self.student_user) + mock_render_to_pdf.return_value = HttpResponse( + b"%PDF-1.4", + content_type="application/pdf", + ) + request = self.factory.post( + "/placement/api/generate-cv/", + { + "achievements": True, + "education": True, + "skills": False, + "projects": True, + }, + format="json", + ) + force_authenticate(request, user=self.student_user) + + from applications.placement_cell.api.views import generate_cv_api + + response = generate_cv_api(request) + + self.assertEqual(response.status_code, 200) + self.assertTrue( + PlacementProfileAuditLog.objects.filter( + student=student, + actor=self.student_user, + action="resume_downloaded", + ).exists() + ) + audit_log = PlacementProfileAuditLog.objects.filter( + student=student, + action="resume_downloaded", + ).latest("id") + self.assertEqual(audit_log.details["filename"], "student_cv.pdf") + self.assertEqual( + audit_log.details["selected_sections"], + ["achievements", "education", "projects"], + ) + + def test_profile_api_post_rejects_documents_larger_than_5mb(self): + request = self.factory.post( + "/placement/api/profile/", + { + "document": SimpleUploadedFile( + "resume.pdf", + b"a" * (5 * 1024 * 1024 + 1), + content_type="application/pdf", + ), + }, + format="multipart", + ) + force_authenticate(request, user=self.student_user) + + from applications.placement_cell.api.views import placement_profile_api + + response = placement_profile_api(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("document", response.data) + + def test_alumni_profile_submission_creates_pending_request(self): + alumni_user = self._create_alumni_user() + request = self.factory.post( + "/placement/api/alumni/profile/", + { + "graduation_year": 2020, + "degree": "B.Tech", + "current_company": "Acme", + "topics": "Mentoring, Placements", + "availability": "Weekends", + "bio": "Happy to help", + "mentorship_enabled": True, + }, + format="multipart", + ) + force_authenticate(request, user=alumni_user) + + from applications.placement_cell.api.views import alumni_profile_api + + response = alumni_profile_api(request) + + self.assertEqual(response.status_code, 201) + profile = AlumniProfile.objects.get(user=alumni_user) + self.assertEqual(profile.status, "pending") + self.assertTrue(profile.mentorship_enabled) + + def test_tpo_can_approve_alumni_and_assign_designation(self): + self._create_officer_designation("placement officer") + alumni_user = self._create_alumni_user(username="alumni2") + profile = AlumniProfile.objects.create( + user=alumni_user, + graduation_year=2021, + degree="B.Tech", + status="pending", + ) + request = self.factory.put( + f"/placement/api/alumni/verification/{profile.id}/", + {"status": "approved", "verification_notes": "Verified"}, + format="json", + ) + force_authenticate(request, user=self.officer) + + from applications.placement_cell.api.views import alumni_verification_detail_api + + response = alumni_verification_detail_api(request, profile.id) + + self.assertEqual(response.status_code, 200) + profile.refresh_from_db() + self.assertEqual(profile.status, "approved") + self.assertTrue( + HoldsDesignation.objects.filter( + working=alumni_user, + designation__name="alumni", + ).exists() + ) + + def test_approved_alumni_can_post_referral_and_student_can_connect_and_request_session(self): + alumni_user = self._create_alumni_user(username="alumni3") + alumni_profile = AlumniProfile.objects.create( + user=alumni_user, + graduation_year=2019, + degree="B.Tech", + status="approved", + mentorship_enabled=True, + topics="Career", + availability="Weekend", + ) + referral_request = self.factory.post( + "/placement/api/alumni/referrals/", + { + "title": "SDE Referral", + "company": "Acme", + "description": "Referral opportunity", + }, + format="json", + ) + force_authenticate(referral_request, user=alumni_user) + + from applications.placement_cell.api.views import ( + alumni_connections_api, + alumni_referrals_api, + alumni_sessions_api, + ) + + referral_response = alumni_referrals_api(referral_request) + self.assertEqual(referral_response.status_code, 201) + self.assertEqual(AlumniReferral.objects.count(), 1) + + student_user = self.student_user + connection_request = self.factory.post( + "/placement/api/alumni/connections/", + {"alumni_id": alumni_profile.id, "message": "Would love to connect"}, + format="json", + ) + force_authenticate(connection_request, user=student_user) + connection_response = alumni_connections_api(connection_request) + self.assertEqual(connection_response.status_code, 201) + self.assertEqual(AlumniConnection.objects.count(), 1) + + session_request = self.factory.post( + "/placement/api/alumni/sessions/", + { + "alumni_id": alumni_profile.id, + "topic": "Resume Review", + "agenda": "Need help with interviews", + "scheduled_at": timezone.now().isoformat(), + "mode": "online", + }, + format="json", + ) + force_authenticate(session_request, user=student_user) + session_response = alumni_sessions_api(session_request) + self.assertEqual(session_response.status_code, 201) + self.assertEqual(AlumniMentorshipSession.objects.count(), 1) diff --git a/FusionIIIT/applications/placement_cell/tests/test_placement_api.py b/FusionIIIT/applications/placement_cell/tests/test_placement_api.py new file mode 100644 index 000000000..b9dcf5977 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/test_placement_api.py @@ -0,0 +1,456 @@ +""" +Self-contained regression/contract tests for the placement_cell API module. + +Goals (kept deliberately robust so they keep passing in the future): + * Schema regressions - guard the data-model fixes (e.g. Education.grade width). + * URL wiring - every placement API route resolves to a real view and + the urlconf stays API-only (no legacy template routes). + * Authentication - protected endpoints reject anonymous callers. + * Authorization - officer-only (TPO) endpoints reject students and admit + placement officers. + +Run with the dedicated test settings (migrations disabled so the historical +migration chain cannot block test-DB creation):: + + python manage.py test applications.placement_cell.tests.test_placement_api \ + --settings=test_settings +""" + +import datetime +from decimal import Decimal + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from applications.academic_information.models import Student +from applications.globals.models import ( + DepartmentInfo, + Designation, + ExtraInfo, + HoldsDesignation, +) +from applications.placement_cell.api import urls as placement_urls +from applications.placement_cell.models import ( + Education, + NotifyStudent, + OffCampusPlacement, + PlacementAnnouncement, + PlacementCalendarEvent, + PlacementRestriction, + PlacementSchedule, +) + + +class PlacementBaseTest(TestCase): + """Builds a department, a student, a placement officer and a plain user.""" + + def setUp(self): + self.department = DepartmentInfo.objects.create(name="CSE") + self.student_designation = Designation.objects.create( + name="student", full_name="Student" + ) + self.officer_designation = Designation.objects.create( + name="placement officer", full_name="Placement Officer" + ) + + self.student_user = self._make_user("student1", "2023001", user_type="student") + Student.objects.create( + id=ExtraInfo.objects.get(user=self.student_user), + programme="B.Tech", + batch=2026, + cpi=8.5, + category="GEN", + ) + self._hold(self.student_user, self.student_designation) + + self.officer_user = self._make_user("officer1", "OFF001", user_type="staff") + self._hold(self.officer_user, self.officer_designation) + + # Authenticated but holds no placement designation. + self.plain_user = self._make_user("plain1", "PLN001", user_type="staff") + + # -- fixture helpers ----------------------------------------------------- + def _make_user(self, username, info_id, *, user_type): + user = User.objects.create_user( + username=username, + password="pw", + email="{}@example.com".format(username), + first_name=username, + ) + extra, _ = ExtraInfo.objects.get_or_create( + user=user, + defaults={"id": info_id, "user_type": user_type, "department": self.department}, + ) + extra.user_type = user_type + extra.department = self.department + extra.save(update_fields=["user_type", "department"]) + return user + + def _hold(self, user, designation): + HoldsDesignation.objects.create( + user=user, working=user, designation=designation + ) + + def _client(self, user=None): + client = APIClient() + if user is not None: + client.force_authenticate(user=user) + return client + + +class SchemaRegressionTests(PlacementBaseTest): + def test_grade_field_is_wide_enough_for_cgpa(self): + # A previous migration shrank grade to 3 chars and truncated real CGPA + # data (e.g. "8.39"). Keep it wide enough forever. + self.assertGreaterEqual(Education._meta.get_field("grade").max_length, 4) + + def test_education_persists_cgpa_value(self): + student = Student.objects.get(id__user=self.student_user) + edu = Education.objects.create( + unique_id=student, degree="B.Tech", grade="8.39", institute="IIITDMJ" + ) + edu.refresh_from_db() + self.assertEqual(edu.grade, "8.39") + + def test_core_models_are_creatable(self): + notify = NotifyStudent.objects.create( + placement_type="PLACEMENT", + company_name="Acme Corp", + ctc=Decimal("12.50"), + description="Campus drive", + ) + schedule = PlacementSchedule.objects.create( + notify_id=notify, + title="Acme Corp", + placement_date=datetime.date.today(), + location="Campus", + time=datetime.time(10, 0), + ) + self.assertIsNotNone(schedule.pk) + + +class UrlWiringTests(PlacementBaseTest): + def test_every_route_resolves_to_a_callable_view(self): + self.assertTrue(placement_urls.urlpatterns) + for pattern in placement_urls.urlpatterns: + self.assertTrue( + callable(pattern.callback), + "URL {!r} does not resolve to a view".format(pattern.name), + ) + + def test_urlconf_is_api_only_no_legacy_template_routes(self): + # The legacy template-based routes were removed; guard against their + # reintroduction by requiring every route to live under ^api/. + for pattern in placement_urls.urlpatterns: + regex = pattern.pattern.regex.pattern + self.assertTrue( + regex.startswith("^api/"), + "Unexpected non-API route present: {}".format(regex), + ) + + def test_key_routes_reverse(self): + for name in [ + "placement_api", + "placement_statistics_api", + "calendar_api", + "debarred_students_api", + "restrictions_api", + "generate_cv_api", + ]: + self.assertTrue(reverse("placement:{}".format(name))) + + +class AuthenticationTests(PlacementBaseTest): + PROTECTED = [ + "placement_api", + "placement_statistics_api", + "calendar_api", + "debarred_students_api", + "restrictions_api", + "my_applications_api", + ] + + def test_protected_endpoints_reject_anonymous(self): + client = APIClient() + for name in self.PROTECTED: + response = client.get(reverse("placement:{}".format(name))) + self.assertIn( + response.status_code, + (401, 403), + "{} should require authentication (got {})".format( + name, response.status_code + ), + ) + + +class AuthorizationTests(PlacementBaseTest): + OFFICER_ONLY = ["debarred_students_api", "restrictions_api"] + + def test_students_are_denied_officer_endpoints(self): + client = self._client(self.student_user) + for name in self.OFFICER_ONLY: + response = client.get(reverse("placement:{}".format(name))) + self.assertEqual( + response.status_code, + 403, + "{} should be forbidden for students".format(name), + ) + + def test_plain_authenticated_user_denied_officer_endpoints(self): + client = self._client(self.plain_user) + for name in self.OFFICER_ONLY: + response = client.get(reverse("placement:{}".format(name))) + self.assertEqual(response.status_code, 403) + + def test_officer_can_read_officer_endpoints(self): + client = self._client(self.officer_user) + for name in self.OFFICER_ONLY: + response = client.get(reverse("placement:{}".format(name))) + self.assertEqual( + response.status_code, + 200, + "{} should be allowed for officers".format(name), + ) + + def test_officer_can_list_placements(self): + response = self._client(self.officer_user).get( + reverse("placement:placement_api") + ) + self.assertEqual(response.status_code, 200) + + def test_officer_can_create_and_list_restriction(self): + client = self._client(self.officer_user) + create = client.post( + reverse("placement:restrictions_api"), + data={ + "criteria": "CPI", + "condition": "lt", + "value": "6.0", + "description": "Low CPI", + }, + format="json", + ) + self.assertEqual(create.status_code, 201) + self.assertEqual(PlacementRestriction.objects.count(), 1) + + listed = client.get(reverse("placement:restrictions_api")) + self.assertEqual(listed.status_code, 200) + self.assertEqual(len(listed.data), 1) + + +class AnnouncementApiTests(PlacementBaseTest): + """Announcements: readable by any authenticated role, writable by the TPO only.""" + + def test_announcements_require_authentication(self): + response = APIClient().get(reverse("placement:placement_announcements_api")) + self.assertIn(response.status_code, (401, 403)) + + def test_any_authenticated_role_can_list_announcements(self): + PlacementAnnouncement.objects.create(title="Drive", body="Acme on campus") + response = self._client(self.student_user).get( + reverse("placement:placement_announcements_api") + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + + def test_student_cannot_post_announcement(self): + response = self._client(self.student_user).post( + reverse("placement:placement_announcements_api"), + data={"title": "X", "body": "Y"}, + format="json", + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(PlacementAnnouncement.objects.count(), 0) + + def test_officer_can_post_and_delete_announcement(self): + client = self._client(self.officer_user) + created = client.post( + reverse("placement:placement_announcements_api"), + data={"title": "Drive", "body": "Acme on campus", "is_pinned": True}, + format="json", + ) + self.assertEqual(created.status_code, 201) + self.assertEqual(PlacementAnnouncement.objects.count(), 1) + announcement = PlacementAnnouncement.objects.get() + self.assertEqual(announcement.posted_by, self.officer_user) + + deleted = client.delete( + reverse( + "placement:placement_announcement_detail_api", + args=[announcement.pk], + ) + ) + self.assertEqual(deleted.status_code, 204) + self.assertEqual(PlacementAnnouncement.objects.count(), 0) + + +class OffCampusPlacementApiTests(PlacementBaseTest): + """Off-campus placements are managed entirely by the TPO.""" + + def test_students_are_denied(self): + response = self._client(self.student_user).get( + reverse("placement:offcampus_placements_api") + ) + self.assertEqual(response.status_code, 403) + + def test_officer_can_record_offcampus_against_roll_number(self): + client = self._client(self.officer_user) + created = client.post( + reverse("placement:offcampus_placements_api"), + data={ + "roll_no": self.student_user.username, + "company_name": "Acme Corp", + "role": "SDE", + "offer_type": "placement", + "ctc": "18.00", + "offer_date": "2026-06-01", + }, + format="json", + ) + self.assertEqual(created.status_code, 201) + self.assertEqual(OffCampusPlacement.objects.count(), 1) + record = OffCampusPlacement.objects.get() + self.assertEqual(record.added_by, self.officer_user) + self.assertEqual(created.data["roll_no"], self.student_user.username) + + listed = client.get(reverse("placement:offcampus_placements_api")) + self.assertEqual(listed.status_code, 200) + self.assertEqual(len(listed.data), 1) + + def test_unknown_roll_number_is_rejected(self): + response = self._client(self.officer_user).post( + reverse("placement:offcampus_placements_api"), + data={ + "roll_no": "DOES_NOT_EXIST", + "company_name": "Acme", + "role": "SDE", + "offer_date": "2026-06-01", + }, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(OffCampusPlacement.objects.count(), 0) + + def test_officer_can_delete_offcampus_record(self): + student_extra = ExtraInfo.objects.get(user=self.student_user) + record = OffCampusPlacement.objects.create( + student=student_extra, + company_name="Acme", + role="SDE", + offer_date=datetime.date(2026, 6, 1), + added_by=self.officer_user, + ) + response = self._client(self.officer_user).delete( + reverse( + "placement:offcampus_placement_detail_api", args=[record.pk] + ) + ) + self.assertEqual(response.status_code, 204) + self.assertEqual(OffCampusPlacement.objects.count(), 0) + + +class PublishedCpiApiTests(PlacementBaseTest): + """Published-CPI batch list, student list and Excel export are TPO-only.""" + + CPI_ROUTES = ["placement_cpi_batches_api", "placement_cpi_students_api"] + + def test_cpi_routes_require_authentication(self): + client = APIClient() + for name in self.CPI_ROUTES: + response = client.get(reverse("placement:{}".format(name))) + self.assertIn(response.status_code, (401, 403)) + + def test_students_are_denied_cpi_routes(self): + client = self._client(self.student_user) + for name in self.CPI_ROUTES: + response = client.get(reverse("placement:{}".format(name))) + self.assertEqual(response.status_code, 403) + + def test_officer_can_read_cpi_batches(self): + response = self._client(self.officer_user).get( + reverse("placement:placement_cpi_batches_api") + ) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.data, list) + + def test_cpi_students_without_batch_returns_empty_list(self): + response = self._client(self.officer_user).get( + reverse("placement:placement_cpi_students_api") + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, []) + + def test_cpi_students_excel_export_returns_workbook(self): + response = self._client(self.officer_user).get( + reverse("placement:placement_cpi_students_api"), + {"batch_id": 1, "export": "excel"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/ms-excel") + self.assertIn("attachment", response["Content-Disposition"]) + + +class CalendarEventApiTests(PlacementBaseTest): + """Calendar events: readable by any role, writable only by the TPO.""" + + def test_listing_requires_authentication(self): + response = APIClient().get( + reverse("placement:placement_calendar_events_api") + ) + self.assertIn(response.status_code, (401, 403)) + + def test_any_authenticated_role_can_list(self): + PlacementCalendarEvent.objects.create( + title="Info session", start=datetime.datetime(2026, 6, 25, 10, 0) + ) + response = self._client(self.student_user).get( + reverse("placement:placement_calendar_events_api") + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + + def test_student_cannot_create(self): + response = self._client(self.student_user).post( + reverse("placement:placement_calendar_events_api"), + data={"title": "X", "start": "2026-06-25T10:00"}, + format="json", + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(PlacementCalendarEvent.objects.count(), 0) + + def test_officer_can_create_update_and_delete(self): + client = self._client(self.officer_user) + created = client.post( + reverse("placement:placement_calendar_events_api"), + data={ + "title": "Pre-placement talk", + "start": "2026-06-25T10:00", + "category": "event", + }, + format="json", + ) + self.assertEqual(created.status_code, 201) + event_id = created.data["id"] + self.assertEqual( + PlacementCalendarEvent.objects.get().created_by, self.officer_user + ) + + updated = client.patch( + reverse( + "placement:placement_calendar_event_detail_api", args=[event_id] + ), + data={"title": "Renamed talk"}, + format="json", + ) + self.assertEqual(updated.status_code, 200) + self.assertEqual(updated.data["title"], "Renamed talk") + + deleted = client.delete( + reverse( + "placement:placement_calendar_event_detail_api", args=[event_id] + ) + ) + self.assertEqual(deleted.status_code, 204) + self.assertEqual(PlacementCalendarEvent.objects.count(), 0) diff --git a/FusionIIIT/applications/placement_cell/tests/test_use_cases.py b/FusionIIIT/applications/placement_cell/tests/test_use_cases.py new file mode 100644 index 000000000..7f199499f --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/test_use_cases.py @@ -0,0 +1,265 @@ +import datetime + +from django.contrib.auth.models import User +from django.core.cache import cache +import yaml +from django.utils import timezone + +from applications.globals.models import Designation, HoldsDesignation +from applications.placement_cell.models import ( + PlacementApplication, + StudentPlacement, +) +from applications.placement_cell.tests.conftest import PlacementCellSpecBase + + +class TestUseCaseCatalogIntegrity(PlacementCellSpecBase): + def test_all_documented_use_cases_define_three_scenarios(self): + with (self.specs_dir / "use_cases.yaml").open("r", encoding="utf-8") as handle: + payload = yaml.safe_load(handle) or {} + + use_cases = payload.get("use_cases", []) + self.assertGreaterEqual(len(use_cases), 20) + + for use_case in use_cases: + self.assertIn("source_id", use_case) + self.assertIn("endpoint", use_case) + self.assertIn("method", use_case) + self.assertEqual( + sorted(use_case.get("scenarios", {}).keys()), + ["alternate_path", "exception_path", "happy_path"], + ) + + def test_use_case_test_ids_are_unique(self): + with (self.specs_dir / "use_cases.yaml").open("r", encoding="utf-8") as handle: + payload = yaml.safe_load(handle) or {} + + test_ids = [] + for use_case in payload.get("use_cases", []): + for scenario in use_case.get("scenarios", {}).values(): + test_ids.append(scenario["_test_id"]) + + self.assertEqual(len(test_ids), len(set(test_ids))) + + +class TestUC01_ProfileManagement(PlacementCellSpecBase): + def test_happy_path_student_can_update_profile(self): + metadata = self.load_spec("use_cases.yaml", "use_cases", "UC01")["scenarios"]["happy_path"] + self._make_profile_complete(self.student_user) + response = self.api_get("/placement/api/profile/", user=self.student_user) + + self.assertEqual(metadata["_scenario"], "Happy Path") + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["is_complete"]) + self.assertEqual(response.data["profile"]["address"], "Hostel A") + + def test_alternate_path_student_can_view_profile_payload(self): + metadata = self.load_spec("use_cases.yaml", "use_cases", "UC01")["scenarios"]["alternate_path"] + self._create_schedule(company_name="Profile Linked Corp") + response = self.api_get("/placement/api/profile/", user=self.student_user) + + self.assertEqual(metadata["_scenario"], "Alternate Path") + self.assertEqual(response.status_code, 200) + self.assertIn("profile", response.data) + self.assertIn("eligibility_summary", response.data) + self.assertEqual(response.data["eligibility_summary"]["eligible_count"], 1) + self.assertIn("documents", response.data["field_errors"]) + + def test_exception_path_invalid_profile_payload_is_rejected(self): + metadata = self.load_spec("use_cases.yaml", "use_cases", "UC01")["scenarios"]["exception_path"] + + response = self.api_put( + "/placement/api/profile/", + user=self.student_user, + data={ + "first_name": "", + "last_name": "", + "email": "invalid-email", + "phone_no": "123", + "address": "", + "about_me": "", + }, + format="multipart", + ) + + self.assertEqual(metadata["_scenario"], "Exception Path") + self.assertEqual(response.status_code, 400) + self.assertIn("field_errors", response.data) + self.assertIn("email", response.data["field_errors"]) + + +class TestUC02_BrowseAndSearchJobs(PlacementCellSpecBase): + def test_happy_path_student_sees_upcoming_opportunities(self): + metadata = self.load_spec("use_cases.yaml", "use_cases", "UC02")["scenarios"]["happy_path"] + StudentPlacement.objects.create(unique_id=self._get_student(self.student_user), future_aspect="PLACEMENT") + expected_schedule = self._create_schedule(company_name="Future Corp") + past_schedule = self._create_schedule( + company_name="Past Corp", + placement_date=timezone.now().date() - datetime.timedelta(days=1), + ) + + response = self.api_get("/placement/api/placement/", user=self.student_user) + + self.assertEqual(metadata["_scenario"], "Happy Path") + self.assertEqual(response.status_code, 200) + returned_ids = {int(row["id"]) for row in response.data} + self.assertIn(expected_schedule.id, returned_ids) + self.assertIn(past_schedule.id, returned_ids) + + def test_alternate_path_filters_opportunities(self): + metadata = self.load_spec("use_cases.yaml", "use_cases", "UC02")["scenarios"]["alternate_path"] + self._create_schedule(company_name="Alpha Corp", location="Bangalore", ctc="22.00") + self._create_schedule(company_name="Beta Labs", location="Delhi", ctc="12.00") + + response = self.api_get( + "/placement/api/placement/", + user=self.officer, + data={"company": "Alpha", "location": "Bangalore", "min_package": "20"}, + ) + + self.assertEqual(metadata["_scenario"], "Alternate Path") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["company_name"], "Alpha Corp") + + def test_tpo_can_see_jobs_posted_by_another_tpo(self): + self._create_officer_designation("placement officer") + other_tpo = User.objects.create_user( + username="other_tpo", + password="password", + email="other_tpo@example.com", + ) + self._create_officer_designation_for_user(other_tpo, "placement officer") + shared_schedule = self._create_schedule(company_name="Shared TPO Job") + + response = self.api_get("/placement/api/placement/", user=other_tpo) + + self.assertEqual(response.status_code, 200) + self.assertIn( + shared_schedule.id, + {int(row["id"]) for row in response.data}, + ) + + def test_tpo_can_see_past_job_postings(self): + self._create_officer_designation("placement officer") + past_schedule = self._create_schedule( + company_name="Past Admin Job", + placement_date=timezone.now().date() - datetime.timedelta(days=2), + ) + + response = self.api_get("/placement/api/placement/", user=self.officer) + + self.assertEqual(response.status_code, 200) + self.assertIn( + past_schedule.id, + {int(row["id"]) for row in response.data}, + ) + + def test_tpo_designation_takes_precedence_over_student_designation(self): + self._create_officer_designation("placement officer") + self._make_profile_complete(self.student_user) + HoldsDesignation.objects.create( + user=self.student_user, + working=self.student_user, + designation=Designation.objects.get(name="placement officer"), + ) + cache.set(f"last_selected_role_{self.student_user.id}", "placement officer", None) + past_schedule = self._create_schedule( + company_name="Mixed Role Admin Job", + placement_date=timezone.now().date() - datetime.timedelta(days=2), + ) + + response = self.api_get("/placement/api/placement/", user=self.student_user) + + self.assertEqual(response.status_code, 200) + self.assertIn( + past_schedule.id, + {int(row["id"]) for row in response.data}, + ) + + def test_student_selected_role_keeps_applied_state_for_mixed_role_user(self): + self._create_officer_designation("placement officer") + student = self._make_profile_complete(self.student_user) + HoldsDesignation.objects.create( + user=self.student_user, + working=self.student_user, + designation=Designation.objects.get(name="placement officer"), + ) + schedule = self._create_schedule(company_name="Applied State Job") + PlacementApplication.objects.create( + schedule=schedule, + student=student, + status="pending", + ) + cache.set(f"last_selected_role_{self.student_user.id}", "student", None) + + response = self.api_get("/placement/api/placement/", user=self.student_user) + + self.assertEqual(response.status_code, 200) + matched = next(item for item in response.data if int(item["id"]) == schedule.id) + self.assertTrue(matched["check"]) + + def test_exception_path_returns_empty_list_when_no_jobs_are_available(self): + metadata = self.load_spec("use_cases.yaml", "use_cases", "UC02")["scenarios"]["exception_path"] + response = self.api_get("/placement/api/placement/", user=self.student_user) + + self.assertEqual(metadata["_scenario"], "Exception Path") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, []) + + +class TestUC03_ApplyForPlacementOpportunity(PlacementCellSpecBase): + def test_happy_path_student_can_submit_application(self): + metadata = self.load_spec("use_cases.yaml", "use_cases", "UC03")["scenarios"]["happy_path"] + student = self._make_profile_complete(self.student_user) + schedule = self._create_schedule(company_name="Apply Corp") + + response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + + self.assertEqual(metadata["_scenario"], "Happy Path") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["message"], "Application submitted successfully.") + self.assertTrue(PlacementApplication.objects.filter(schedule=schedule, student=student).exists()) + + def test_alternate_path_ineligible_student_cannot_apply(self): + metadata = self.load_spec("use_cases.yaml", "use_cases", "UC03")["scenarios"]["alternate_path"] + self._make_profile_complete(self.student_user) + schedule = self._create_schedule(company_name="Eligibility Corp") + schedule.branch = "ECE" + schedule.save(update_fields=["branch"]) + + response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + + self.assertEqual(metadata["_scenario"], "Alternate Path") + self.assertEqual(response.status_code, 403) + self.assertIn("You are not eligible for this job posting.", response.data["detail"]) + self.assertIn("Branch requirement not met.", response.data["errors"]) + + def test_exception_path_duplicate_application_is_rejected(self): + metadata = self.load_spec("use_cases.yaml", "use_cases", "UC03")["scenarios"]["exception_path"] + self._make_profile_complete(self.student_user) + schedule = self._create_schedule(company_name="Duplicate Corp") + first_response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + + response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(metadata["_scenario"], "Exception Path") + self.assertEqual(response.status_code, 409) + self.assertIn("already applied", response.data["detail"]) diff --git a/FusionIIIT/applications/placement_cell/tests/test_workflows.py b/FusionIIIT/applications/placement_cell/tests/test_workflows.py new file mode 100644 index 000000000..4a3b7ec54 --- /dev/null +++ b/FusionIIIT/applications/placement_cell/tests/test_workflows.py @@ -0,0 +1,427 @@ +import datetime + +import yaml + +from applications.academic_information.models import Student +from applications.placement_cell.models import ( + PlacementApplication, + PlacementInterviewSchedule, + PlacementRecord, + PlacementStatus, + StudentPlacement, +) +from applications.placement_cell.tests.conftest import PlacementCellSpecBase + + +class TestWorkflowCatalogIntegrity(PlacementCellSpecBase): + def test_all_documented_workflows_define_two_scenarios(self): + with (self.specs_dir / "workflows.yaml").open("r", encoding="utf-8") as handle: + payload = yaml.safe_load(handle) or {} + + workflows = payload.get("workflows", []) + self.assertGreaterEqual(len(workflows), 10) + + for workflow in workflows: + self.assertIn("source_section", workflow) + self.assertTrue(workflow.get("steps")) + self.assertEqual(sorted(workflow.get("scenarios", {}).keys()), ["end_to_end", "negative"]) + + def test_workflow_test_ids_are_unique(self): + with (self.specs_dir / "workflows.yaml").open("r", encoding="utf-8") as handle: + payload = yaml.safe_load(handle) or {} + + test_ids = [] + for workflow in payload.get("workflows", []): + for scenario in workflow.get("scenarios", {}).values(): + test_ids.append(scenario["_test_id"]) + + self.assertEqual(len(test_ids), len(set(test_ids))) + + +class TestGeneratedWorkflows(PlacementCellSpecBase): + def _wf_metadata(self, wf_id, scenario_key): + return self.load_spec("workflows.yaml", "workflows", wf_id)["scenarios"][scenario_key] + + def _assert_metadata(self, metadata, label): + self.assertEqual(metadata["_scenario"], label) + + def _create_submitted_application(self, *, company_name): + student = self._make_profile_complete(self.student_user) + schedule = self._create_schedule(company_name=company_name) + response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + self.assertEqual(response.status_code, 200) + application = PlacementApplication.objects.get(schedule=schedule, student=student) + return student, schedule, application + + def _create_alumni_like_user(self, *, username): + user = self._create_student_user( + roll_no=f"AL{username[:6]}", + username=username, + department=self.department_cse, + ) + extra = user.extrainfo + extra.user_type = "faculty" + extra.save(update_fields=["user_type"]) + Student.objects.filter(id=extra).delete() + return user + + def _seed_statistics_record(self, *, user, company_name, ctc="18.00", year=2026): + student = self._get_student(user) + StudentPlacement.objects.get_or_create(unique_id=student) + record = PlacementRecord.objects.create( + placement_type="PLACEMENT", + name=company_name, + ctc=ctc, + year=year, + ) + student.studentrecord_set.create(record_id=record) + return record + + def _wf01_end_to_end(self): + metadata = self._wf_metadata("WF01", "end_to_end") + self._create_officer_designation() + student, schedule, application = self._create_submitted_application(company_name="WF01 Corp") + + update_response = self.api_put( + f"/placement/api/application-detail/{application.id}/", + user=self.officer, + data={"status": "offer_released", "remarks": "Offer released"}, + ) + offers_response = self.api_get("/placement/api/my-offers/", user=self.student_user) + + self._assert_metadata(metadata, "End-to-End") + self.assertEqual(update_response.status_code, 200) + self.assertEqual(offers_response.status_code, 200) + self.assertEqual(len(offers_response.data["offers"]), 1) + self.assertEqual(offers_response.data["offers"][0]["company_name"], schedule.notify_id.company_name) + self.assertEqual(application.student, student) + + def _wf01_negative(self): + metadata = self._wf_metadata("WF01", "negative") + schedule = self._create_schedule(company_name="WF01 Blocked Corp") + response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + + self._assert_metadata(metadata, "Negative") + self.assertEqual(response.status_code, 400) + self.assertIn("Placement profile is incomplete", response.data["detail"]) + + def _wf02_end_to_end(self): + metadata = self._wf_metadata("WF02", "end_to_end") + schedule = self._create_schedule(company_name="WF02 Corp") + + self._make_profile_complete(self.student_user) + profile_response = self.api_get("/placement/api/profile/", user=self.student_user) + summary_response = self.api_get("/placement/api/profile/", user=self.student_user) + apply_response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + + self._assert_metadata(metadata, "End-to-End") + self.assertEqual(profile_response.status_code, 200) + self.assertEqual(summary_response.status_code, 200) + self.assertTrue(summary_response.data["is_complete"]) + self.assertEqual(apply_response.status_code, 200) + self.assertEqual(apply_response.data["message"], "Application submitted successfully.") + + def _wf02_negative(self): + metadata = self._wf_metadata("WF02", "negative") + schedule = self._create_schedule(company_name="WF02 Blocked Corp") + response = self.api_post( + "/placement/api/apply-for-placement/", + user=self.student_user, + data={"jobId": schedule.id, "responses": []}, + ) + + self._assert_metadata(metadata, "Negative") + self.assertEqual(response.status_code, 400) + self.assertIn("Placement profile is incomplete", response.data["detail"]) + + def _wf03_end_to_end(self): + metadata = self._wf_metadata("WF03", "end_to_end") + StudentPlacement.objects.create(unique_id=self._get_student(self.student_user), future_aspect="PLACEMENT") + create_response = self.api_post( + "/placement/api/placement/", + user=self.officer, + data={ + "company_name": "WF03 Corp", + "title": "WF03 Corp", + "placement_type": "PLACEMENT", + "ctc": "15.00", + "description": "Campus drive", + "placement_date": (datetime.date.today() + datetime.timedelta(days=3)).isoformat(), + "schedule_at": "2026-04-15 10:30", + "location": "Auditorium", + }, + format="multipart", + ) + list_response = self.api_get("/placement/api/placement/", user=self.student_user) + + self._assert_metadata(metadata, "End-to-End") + self.assertEqual(create_response.status_code, 201) + self.assertEqual(list_response.status_code, 200) + self.assertIn("WF03 Corp", [row["company_name"] for row in list_response.data]) + + def _wf03_negative(self): + metadata = self._wf_metadata("WF03", "negative") + response = self.api_post( + "/placement/api/placement/", + user=self.officer, + data={ + "company_name": "WF03 Past Corp", + "placement_type": "PLACEMENT", + "placement_date": (datetime.date.today() - datetime.timedelta(days=1)).isoformat(), + "schedule_at": "2026-04-01 10:30", + }, + format="multipart", + ) + + self._assert_metadata(metadata, "Negative") + self.assertEqual(response.status_code, 400) + self.assertIn("placement_date", response.data) + + def _wf04_end_to_end(self): + metadata = self._wf_metadata("WF04", "end_to_end") + self._create_officer_designation() + _, _, application = self._create_submitted_application(company_name="WF04 Corp") + + schedule_response = self.api_post( + f"/placement/api/application-detail/{application.id}/interview/", + user=self.officer, + data={ + "scheduled_at": (datetime.datetime.now() + datetime.timedelta(days=1)).strftime("%Y-%m-%d %H:%M"), + "end_datetime": (datetime.datetime.now() + datetime.timedelta(days=1, hours=1)).strftime("%Y-%m-%d %H:%M"), + "round_no": 1, + "title": "Technical Interview", + "remarks": "Interview scheduled", + }, + ) + applications_response = self.api_get("/placement/api/my-applications/", user=self.student_user) + + self._assert_metadata(metadata, "End-to-End") + self.assertEqual(schedule_response.status_code, 201) + self.assertEqual(applications_response.status_code, 200) + self.assertIsNotNone(applications_response.data["applications"][0]["next_interview"]) + self.assertEqual(applications_response.data["applications"][0]["next_interview"]["title"], "Technical Interview") + + def _wf04_negative(self): + metadata = self._wf_metadata("WF04", "negative") + self._create_officer_designation() + _, _, application = self._create_submitted_application(company_name="WF04 Invalid Corp") + response = self.api_post( + f"/placement/api/application-detail/{application.id}/interview/", + user=self.officer, + data={"round_no": 1, "title": "Missing datetime"}, + ) + + self._assert_metadata(metadata, "Negative") + self.assertEqual(response.status_code, 400) + self.assertIn("scheduled_at", response.data) + + def _wf05_end_to_end(self): + metadata = self._wf_metadata("WF05", "end_to_end") + self._create_officer_designation() + student, schedule, application = self._create_submitted_application(company_name="WF05 Corp") + apply_response = self.api_put( + f"/placement/api/application-detail/{application.id}/", + user=self.officer, + data={"status": "offer_released", "remarks": "Offer issued"}, + ) + offers_response = self.api_get("/placement/api/my-offers/", user=self.student_user) + + self._assert_metadata(metadata, "End-to-End") + self.assertEqual(apply_response.status_code, 200) + self.assertEqual(offers_response.status_code, 200) + self.assertEqual(len(offers_response.data["offers"]), 1) + self.assertEqual(offers_response.data["offers"][0]["company_name"], "WF05 Corp") + self.assertEqual(student.id.user, self.student_user) + self.assertEqual(schedule.notify_id.company_name, "WF05 Corp") + + def _wf05_negative(self): + metadata = self._wf_metadata("WF05", "negative") + student, schedule, _ = self._create_submitted_application(company_name="WF05 Pending Corp") + offers_response = self.api_get("/placement/api/my-offers/", user=self.student_user) + + self._assert_metadata(metadata, "Negative") + self.assertEqual(offers_response.status_code, 200) + self.assertEqual(offers_response.data["offers"], []) + self.assertEqual(student.id.user, self.student_user) + self.assertEqual(schedule.notify_id.company_name, "WF05 Pending Corp") + + def _wf06_end_to_end(self): + metadata = self._wf_metadata("WF06", "end_to_end") + create_response = self.api_post( + "/placement/api/registration/", + user=self.officer, + data={ + "companyName": "WF06 Company", + "description": "Placement partner", + "address": "Hyderabad", + "website": "https://wf06.example.com", + }, + ) + list_response = self.api_get("/placement/api/registration/", user=self.officer) + + self._assert_metadata(metadata, "End-to-End") + self.assertEqual(create_response.status_code, 200) + self.assertEqual(list_response.status_code, 200) + self.assertIn("WF06 Company", [row["companyName"] for row in list_response.data]) + + def _wf06_negative(self): + metadata = self._wf_metadata("WF06", "negative") + response = self.api_post( + "/placement/api/registration/", + data={"companyName": "Blocked WF06 Company"}, + ) + + self._assert_metadata(metadata, "Negative") + self.assertIn(response.status_code, (401, 403)) + + def _wf07_end_to_end(self): + metadata = self._wf_metadata("WF07", "end_to_end") + self._create_officer_designation() + alumni_user = self._create_alumni_like_user(username="wf07alumni") + + create_response = self.api_post( + "/placement/api/alumni/profile/", + user=alumni_user, + data={"graduation_year": 2020, "degree": "B.Tech", "current_company": "WF07 Corp"}, + ) + approve_response = self.api_put( + f"/placement/api/alumni/verification/{create_response.data['id']}/", + user=self.officer, + data={"status": "approved", "verification_notes": "Verified"}, + ) + profile_response = self.api_get("/placement/api/alumni/profile/", user=alumni_user) + + self._assert_metadata(metadata, "End-to-End") + self.assertEqual(create_response.status_code, 201) + self.assertEqual(approve_response.status_code, 200) + self.assertEqual(profile_response.status_code, 200) + self.assertTrue(profile_response.data["can_access"]) + + def _wf07_negative(self): + metadata = self._wf_metadata("WF07", "negative") + alumni_user = self._create_alumni_like_user(username="wf07pending") + create_response = self.api_post( + "/placement/api/alumni/profile/", + user=alumni_user, + data={"graduation_year": 2021, "degree": "B.Tech"}, + ) + update_response = self.api_put( + "/placement/api/alumni/profile/", + user=alumni_user, + data={"degree": "M.Tech"}, + ) + + self._assert_metadata(metadata, "Negative") + self.assertEqual(create_response.status_code, 201) + self.assertEqual(update_response.status_code, 403) + self.assertIn("awaiting approval", update_response.data["detail"]) + + def _wf08_end_to_end(self): + metadata = self._wf_metadata("WF08", "end_to_end") + response = self.api_post( + "/placement/api/send-notification/", + user=self.officer, + data={"sendTo": "All", "description": "WF08 notification"}, + ) + + self._assert_metadata(metadata, "End-to-End") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["message"], "Notification sent successfully.") + + def _wf08_negative(self): + metadata = self._wf_metadata("WF08", "negative") + response = self.api_post( + "/placement/api/send-notification/", + user=self.officer, + data={"sendTo": "Specific", "recipient": "missing-user", "description": "WF08 notification"}, + ) + + self._assert_metadata(metadata, "Negative") + self.assertEqual(response.status_code, 404) + self.assertIn("recipient", response.data) + + def _wf09_end_to_end(self): + metadata = self._wf_metadata("WF09", "end_to_end") + StudentPlacement.objects.create(unique_id=self._get_student(self.student_user), future_aspect="PLACEMENT") + self._create_schedule(company_name="WF09 Corp") + response = self.api_get("/placement/api/placement/", user=self.student_user) + + self._assert_metadata(metadata, "End-to-End") + self.assertEqual(response.status_code, 200) + self.assertIn("WF09 Corp", [row["company_name"] for row in response.data]) + + def _wf09_negative(self): + metadata = self._wf_metadata("WF09", "negative") + response = self.api_get("/placement/api/placement/") + + self._assert_metadata(metadata, "Negative") + self.assertIn(response.status_code, (401, 403)) + + def _wf10_end_to_end(self): + metadata = self._wf_metadata("WF10", "end_to_end") + self._create_officer_designation() + self._seed_statistics_record(user=self.student_user, company_name="WF10 Corp") + response = self.api_get( + "/placement/api/reports/", + user=self.officer, + data={"report_type": "company"}, + ) + + self._assert_metadata(metadata, "End-to-End") + self.assertEqual(response.status_code, 200) + self.assertIn("rows", response.data) + self.assertEqual(response.data["rows"][0]["company"], "WF10 Corp") + + def _wf10_negative(self): + metadata = self._wf_metadata("WF10", "negative") + response = self.api_get("/placement/api/reports/", user=self.student_user) + + self._assert_metadata(metadata, "Negative") + self.assertEqual(response.status_code, 403) + self.assertIn("Only TPO and chairman users", response.data["detail"]) + + +_WF_HELPERS = { + "WF01": ("_wf01_end_to_end", "_wf01_negative"), + "WF02": ("_wf02_end_to_end", "_wf02_negative"), + "WF03": ("_wf03_end_to_end", "_wf03_negative"), + "WF04": ("_wf04_end_to_end", "_wf04_negative"), + "WF05": ("_wf05_end_to_end", "_wf05_negative"), + "WF06": ("_wf06_end_to_end", "_wf06_negative"), + "WF07": ("_wf07_end_to_end", "_wf07_negative"), + "WF08": ("_wf08_end_to_end", "_wf08_negative"), + "WF09": ("_wf09_end_to_end", "_wf09_negative"), + "WF10": ("_wf10_end_to_end", "_wf10_negative"), +} + + +def _make_generated_workflow_test(helper_name): + def _test(self): + getattr(self, helper_name)() + + return _test + + +for _wf_id, (_e2e_helper, _negative_helper) in _WF_HELPERS.items(): + setattr( + TestGeneratedWorkflows, + f"test_{_wf_id.lower()}_end_to_end_workflow", + _make_generated_workflow_test(_e2e_helper), + ) + setattr( + TestGeneratedWorkflows, + f"test_{_wf_id.lower()}_negative_workflow_path", + _make_generated_workflow_test(_negative_helper), + ) diff --git a/FusionIIIT/applications/placement_cell/urls.py b/FusionIIIT/applications/placement_cell/urls.py deleted file mode 100644 index 190638861..000000000 --- a/FusionIIIT/applications/placement_cell/urls.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.conf.urls import url -from . import views - -app_name = 'placement' - -urlpatterns = [ - url(r'^$', views.placement, name='placement'), - url(r'^get_reference_list/$', views.get_reference_list, name='get_reference_list'), - url(r'^checking_roles/$', views.checking_roles, name='checking_roles'), - url(r'^companyname_dropdown/$', views.company_name_dropdown, name='companyname_dropdown'), - url(r'^student_records/invitation_status$', views.invitation_status, name='invitation_status'), - url(r'^student_records/delete_invitation_status$', views.delete_invitation_status, name='delete_invitation_status'), - url(r'^student_records/$', views.student_records, name='student_records'), - url(r'^manage_records/$', views.manage_records, name='manage_records'), - url(r'^statistics/$', views.placement_statistics, name='placement_statistics'), - - url(r'^delete_placement_statistics/$', views.delete_placement_statistics, name='delete_placement_statistics'), - url(r'^cv/(?P[a-zA-Z0-9\.]{1,20})/$', views.cv, name="cv"), - - - #added new url - url(r'^add_placement_schedule/$', views.add_placement_schedule, name='add_placement_schedule'), - url(r'^placement_schedule_save/$', views.placement_schedule_save, name='placement_schedule_save'), - url(r'^delete_placement_record/$', views.delete_placement_record, name='delete_placement_record'), - url(r'^add_placement_record/$', views.add_placement_record, name='add_placement_record'), - url(r'^placement_record_save/$', views.placement_record_save, name='placement_record_save'), - url(r'^add_placement_visit/$', views.add_placement_visit, name='add_placement_visit'), - url(r'^placement_visit_save/$', views.placement_visit_save, name='placement_visit_save'), -] diff --git a/FusionIIIT/applications/placement_cell/views.py b/FusionIIIT/applications/placement_cell/views.py deleted file mode 100644 index 25ecadebd..000000000 --- a/FusionIIIT/applications/placement_cell/views.py +++ /dev/null @@ -1,5810 +0,0 @@ -import os -import shutil -import datetime -import decimal -import zipfile -import xlwt -import logging - -from html import escape -from datetime import date -from io import BytesIO -from wsgiref.util import FileWrapper -from django.conf import settings -from django.contrib.auth.decorators import login_required -from django.contrib.auth.models import User -from django.contrib import messages -from django.core.cache import cache -from django.core.files.storage import FileSystemStorage -from django.core.paginator import Paginator -from django.db.models import Count, Q -from django.http import HttpResponse, JsonResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.template.loader import get_template, render_to_string -from django.utils import timezone -from django.utils.encoding import smart_str -from xhtml2pdf import pisa -from django.core import serializers -from applications.academic_information.models import Student -from notification.views import placement_cell_notif -from applications.globals.models import (DepartmentInfo, ExtraInfo, - HoldsDesignation) -from applications.academic_information.models import Student -from .forms import (AddAchievement, AddChairmanVisit, AddCourse, AddEducation, - AddExperience, AddReference, AddPatent, AddProfile, AddProject, - AddPublication, AddSchedule, AddSkill, ManageHigherRecord, - ManagePbiRecord, ManagePlacementRecord, SearchHigherRecord, - SearchPbiRecord, SearchPlacementRecord, - SearchStudentRecord, SendInvite) - -from .models import (Achievement, ChairmanVisit, Course, Education, Experience, Conference, - Has, NotifyStudent, Patent, PlacementRecord, Extracurricular, Reference, - PlacementSchedule, PlacementStatus, Project, Publication, - Skill, StudentPlacement, StudentRecord, Role, CompanyDetails,) -''' - @variables: - user - logged in user - profile - variable for extrainfo - studentrecord - storing all fetched student record from database - years - yearwise record of student placement - records - all the record of placement record table - tcse - all record of cse - tece - all record of ece - tme - all record of me - tadd - all record of student - form respective form object - stuname - student name obtained from the form - ctc - salary offered obtained from the form - cname - company name obtained from the form - rollno - roll no of student obtained from the form - year - year of placement obtained from the form - s - extra info data of the student obtained from the form - p - placement data of the student obtained from the form - placementrecord - placement record of the student obtained from the form - pbirecord - pbi data of the student obtained from the form - test_type - type of higher study test obtained from the form - uname - name of universty obtained from the form - test_score - score in the test obtained from the form - higherrecord - higher study record of the student obtained from the form - current - current user on a particular designation - status - status of the sent invitation by placement cell regarding placement/pbi - institute - institute for previous education obtained from the form - degree - degree for previous education obtained from the form - grade - grade for previous education obtained from the form - stream - stream for previous education obtained from the form - sdate - start date for previous education obtained from the form - edate - end date for previous education obtained from the form - education_obj - object variable of Education table - about_me - about me data obtained from the form - age - age data obtained from the form - address - address obtained from the form - contact - contact obtained from the form - pic - picture obtained from the form - skill - skill of the user obtained from the form - skill_rating - rating of respective skill obtained from the form - has_obj - object variable of Has table - achievement - achievement of user obtained from the form - achievement_type - type of achievement obtained from the form - description - description of respective achievement obtained from the form - issuer - certifier of respective achievement obtained from the form - date_earned - date of the respective achievement obtained from the form - achievement_obj - object variable of Achievement table - publication_title - title of the publication obtained from the form - description - description of respective publication obtained from the form - publisher - publisher of respective publication obtained from the form - publication_date - date of respective publication obtained from the form - publication_obj - object variable of Publication table - patent_name - name of patent obtained from the form - description - description of respective patent obtained from the form - patent_office - office of respective patent obtained from the form - patent_date - date of respective patent obtained from the form - patent_obj - object variable of Patent table - course_name - name of the course obtained from the form - description description of respective course obtained from the form - license_no - license_no of respective course obtained from the form - sdate - start date of respective course obtained from the form - edate - end date of respective course obtained from the form - course_obj - object variable of Course table - project_name - name of project obtained from the form - project_status - status of respective project obtained from the form - summary - summery of the respective project obtained from the form - project_link - link of the respective project obtained from the form - sdate - start date of respective project obtained from the form - edate - end date of respective project obtained from the form - project_obj - object variable of Project table - title - title of any kind of experience obtained from the form - status - status of the respective experience obtained from the form - company - company from which respective experience is gained as obtained from the form - location - location of the respective experience obtained from the form - description - description of respective experience obtained from the form - sdate - start date of respective experience obtained from the form - edate - end date of respective experience obtained from the form - experience_obj - object variable of Experience table - context - to sent the relevant context for html rendering - company_name - name of visiting comapany obtained from the form - location -location of visiting company obtained from the form - description - description of respective company obtained from the form - visiting_date - visiting date of respective company obtained from the form - visit_obj -object variable of ChairmanVisit table - notify - object of NotifyStudent table - schedule - object variable of PlacementSchedule table - q1 - all data of Has table - q3 - all data of Student table - st - all data of Student table - spid - id of student to be debar - sr - record from StudentPlacement of student having id=spid - achievementcheck - checking for achievent to be shown in cv - educationcheck - checking for education to be shown in cv - publicationcheck - checking for publication to be shown in cv - patentcheck - checking for patent to be shown in cv - internshipcheck - checking for internship to be shown in cv - projectcheck - checking for project to be shown in cv - coursecheck - checking for course to be shown in cv - skillcheck - checking for skill to be shown in cv -''' - -logger = logging.getLogger('django.server') -@login_required -def placement__Statistics(request): - ''' - logic of the view shown under Placement Statistics tab - ''' - user = request.user - - - statistics_tab = 1 - strecord_tab=1 - delete_operation = 0 - pagination_placement = 0 - pagination_pbi = 0 - pagination_higher = 0 - is_disabled = 0 - paginator = '' - page_range = '' - officer_statistics_past_pbi_search = 0 - officer_statistics_past_higher_search = 0 - - profile = get_object_or_404(ExtraInfo, Q(user=user)) - studentrecord = StudentRecord.objects.select_related('unique_id','record_id').all() - - years = PlacementRecord.objects.filter(~Q(placement_type="HIGHER STUDIES")).values('year').annotate(Count('year')) - records = PlacementRecord.objects.values('name', 'year', 'ctc', 'placement_type').annotate(Count('name'), Count('year'), Count('placement_type'), Count('ctc')) - - - - - #working here to fetch all placement record - all_records=PlacementRecord.objects.all() - print(all_records) - - - - - - - invitecheck=0 - for r in records: - r['name__count'] = 0 - r['year__count'] = 0 - r['placement_type__count'] = 0 - tcse = dict() - tece = dict() - tme = dict() - tadd = dict() - for y in years: - tcse[y['year']] = 0 - tece[y['year']] = 0 - tme[y['year']] = 0 - for r in records: - if r['year'] == y['year']: - if r['placement_type'] != "HIGHER STUDIES": - for z in studentrecord: - if z.record_id.name == r['name'] and z.record_id.year == r['year'] and z.unique_id.id.department.name == "CSE": - tcse[y['year']] = tcse[y['year']]+1 - r['name__count'] = r['name__count']+1 - if z.record_id.name == r['name'] and z.record_id.year == r['year'] and z.unique_id.id.department.name == "ECE": - tece[y['year']] = tece[y['year']]+1 - r['year__count'] = r['year__count']+1 - if z.record_id.name == r['name'] and z.record_id.year == r['year'] and z.unique_id.id.department.name == "ME": - tme[y['year']] = tme[y['year']]+1 - r['placement_type__count'] = r['placement_type__count']+1 - tadd[y['year']] = tcse[y['year']]+tece[y['year']]+tme[y['year']] - y['year__count'] = [tadd[y['year']], tcse[y['year']], tece[y['year']], tme[y['year']]] - - form2 = SearchPlacementRecord(initial={}) - form3 = SearchPbiRecord(initial={}) - form4 = SearchHigherRecord(initial={}) - - - current1 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement chairman")) - current2 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement officer")) - current = HoldsDesignation.objects.filter(Q(working=user, designation__name="student")) - - if len(current1)!=0 or len(current2)!=0: - delete_operation = 1 - if len(current) == 0: - current = None - pbirecord= '' - placementrecord= '' - higherrecord= '' - total_query=0 - total_query1 = 0 - total_query2= 0 - p="" - p1="" - p2="" - placement_search_record=" " - pbi_search_record=" " - higher_search_record=" " - # results of the searched query under placement tab - if 'studentplacementrecordsubmit' in request.POST: - officer_statistics_past = 1 - form = SearchPlacementRecord(request.POST) - if form.is_valid(): - - - - - print("IS VALID") - - - - #for student name - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - try: - first_name = stuname.split(" ")[0] - last_name = stuname.split(" ")[1] - except Exception as e: - print("Error") - print(e) - first_name = stuname - last_name = '' - else: - stuname = '' - first_name = '' - last_name = '' - - - # for student CTC - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - - #for company name - if form.cleaned_data['cname']: - cname = form.cleaned_data['cname'] - else: - cname = '' - - #for student roll - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - - #for admission year - if form.cleaned_data['year']: - year = form.cleaned_data['year'] - s = Student.objects.filter((Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - first_name__icontains=first_name, - last_name__icontains=last_name), - id__icontains=rollno)) - ))) - - p = PlacementRecord.objects.filter(Q(placement_type="PLACEMENT", name__icontains=stuname, ctc__icontains=ctc, year__icontains=year)) - - - - - """placementrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", name__icontains=cname, ctc__gte=ctc, year=year)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - first_name__icontains=first_name, - last_name__icontains=last_name, - id__icontains=rollno)))))))) - #print("In if:", placementrecord) - else: - s = Student.objects.filter((Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - first_name__icontains=first_name, - last_name__icontains=last_name), - id__icontains=rollno)) - ))) - - p = PlacementRecord.objects.filter(Q(placement_type="PLACEMENT", name__icontains=cname, ctc__gte=ctc)) - print("Agein p:",p) - placementrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", name__icontains=cname, ctc__gte=ctc)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - first_name__icontains=first_name, - last_name__icontains=last_name), - id__icontains=rollno))))))) - - request.session['first_name'] = first_name - request.session['last_name'] = last_name - request.session['ctc'] = ctc - request.session['cname'] = cname - request.session['rollno'] = rollno - request.session['year'] = form.cleaned_data['year']""" - - print(p) - - - total_query = p.count() - - if total_query > 30: - pagination_placement = 1 - paginator = Paginator(placementrecord, 30) - page = request.GET.get('page', 1) - placementrecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_placement = 0 - else: - if request.GET.get('page') != None: - try: - if request.session['year']: - s = Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno'])) - ))) - - p = PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'], - year=request.session['year'])) - - - placementrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'], - year=request.session['year'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno']))))))) - else: - s = Student.objects.filter((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno'])) - ))) - - p = PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'])) - - placementrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno']))))))) - except Exception as e: - print(e) - placementrecord = '' - - if placementrecord != '': - total_query = placementrecord.count() - else: - total_query = 0 - no_records=1 - print(placementrecord) - if total_query > 30: - pagination_placement = 1 - paginator = Paginator(placementrecord, 30) - page = request.GET.get('page', 1) - placementrecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_placement = 0 - else: - placementrecord = '' - - if total_query!=0: - placement_search_record=p - # results of the searched query under pbi tab - if 'studentpbirecordsubmit' in request.POST: - officer_statistics_past_pbi_search = 1 - form = SearchPbiRecord(request.POST) - if form.is_valid(): - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - try: - first_name = stuname.split(" ")[0] - last_name = stuname.split(" ")[1] - except: - first_name = stuname - last_name = '' - else: - stuname = '' - first_name = '' - last_name = '' - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - if form.cleaned_data['cname']: - cname = form.cleaned_data['cname'] - else: - cname = '' - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - if form.cleaned_data['year']: - year = form.cleaned_data['year'] - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter(Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="PBI", - name__icontains=cname, - ctc__gte=ctc, year=year)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=first_name, - last_name__icontains=last_name)), - id__icontains=rollno)) - ))))) - p1 = PlacementRecord.objects.filter( - Q(placement_type="PBI", name__icontains=stuname, ctc__icontains=ctc, year__icontains=year)) - """else: - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter(Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="PBI", - name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=first_name, - last_name__icontains=last_name)), - id__icontains=rollno)) - ))))) - request.session['first_name'] = first_name - request.session['last_name'] = last_name - request.session['ctc'] = ctc - request.session['cname'] = cname - request.session['rollno'] = rollno - request.session['year'] = form.cleaned_data['year'] -""" - total_query1 = p1.count() - - if total_query1 > 30: - pagination_pbi = 1 - paginator = Paginator(pbirecord, 30) - page = request.GET.get('page', 1) - pbirecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query1 > 30 and total_query1 <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_pbi = 0 - else: - if request.GET.get('page') != None: - try: - if request.session['year']: - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PBI", - name__icontains=request.session['cname'], - ctc__gte=ctc, year=request.session['year'])), - unique_id__in=Student.objects.filter(( - Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno']))))))) - else: - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter(Q(placement_type="PBI", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno']))))))) - except: - print('except') - pbirecord = '' - - if pbirecord != '': - total_query = pbirecord.count() - else: - total_query = 0 - - if total_query > 30: - pagination_pbi = 1 - paginator = Paginator(pbirecord, 30) - page = request.GET.get('page', 1) - pbirecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_pbi = 0 - else: - pbirecord = '' - if total_query1!=0: - pbi_search_record=p1 - - # results of the searched query under higher studies tab - if 'studenthigherrecordsubmit' in request.POST: - officer_statistics_past_higher_search = 1 - form = SearchHigherRecord(request.POST) - if form.is_valid(): - # getting all the variables send through form - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - try: - first_name = stuname.split(" ")[0] - last_name = stuname.split(" ")[1] - except: - first_name = stuname - last_name = '' - else: - stuname = '' - first_name = '' - last_name = '' - if form.cleaned_data['test_type']: - test_type = form.cleaned_data['test_type'] - else: - test_type = '' - if form.cleaned_data['uname']: - uname = form.cleaned_data['uname'] - else: - uname = '' - if form.cleaned_data['test_score']: - test_score = form.cleaned_data['test_score'] - else: - test_score = 0 - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - if form.cleaned_data['year']: - year = form.cleaned_data['year'] - # result of the query when year is given - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter(Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="HIGHER STUDIES", - test_type__icontains=test_type, - name__icontains=uname, year=year, - test_score__gte=test_score)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=first_name, - last_name__icontains=last_name)), - id__icontains=rollno)) - ))))) - - p2 = PlacementRecord.objects.filter( - Q(placement_type="HIGHER STUDIES", name__icontains=stuname, year__icontains=year)) - - """else: - # result of the query when year is not given - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="HIGHER STUDIES", - test_type__icontains=test_type, - name__icontains=uname, - test_score__gte=test_score)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=first_name, - last_name__icontains=last_name)), - id__icontains=rollno)) - ))))) - request.session['first_name'] = first_name - request.session['last_name'] = last_name - request.session['test_score'] = test_score - request.session['uname'] = uname - request.session['test_type'] = test_type - request.session['rollno'] = rollno - request.session['year'] = form.cleaned_data['year']""" - - total_query2 = p2.count() - - if total_query2 > 30: - pagination_higher = 1 - paginator = Paginator(p2, 30) - page = request.GET.get('page', 1) - p2 = paginator.page(page) - page = int(page) - total_page = int(page+3) - - if page < (paginator.num_pages-3): - if total_query2 > 30 and total_query2 <= 60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(page-2, paginator.num_pages+1) - else: - pagination_higher = 0 - else: - if request.GET.get('page') != None: - try: - if request.session['year']: - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="HIGHER STUDIES", - test_type__icontains=request.session['test_type'], - name__icontains=request.session['uname'], - year=request.session['year'], - test_score__gte=request.session['test_score'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno'])) - ))))) - else: - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="HIGHER STUDIES", - test_type__icontains=request.session['test_type'], - name__icontains=request.session['uname'], - test_score__gte=request.session['test_score'])), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno'])) - ))))) - except: - higherrecord = '' - - if higherrecord != '': - total_query = higherrecord.count() - else: - total_query = 0 - - if total_query > 30: - no_pagination = 1 - paginator = Paginator(higherrecord, 30) - page = request.GET.get('page', 1) - higherrecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - else: - higherrecord = '' - if total_query2!=0: - higher_search_record=p2 - - context = { - 'form2' : form2, - 'form3' : form3, - 'form4' : form4, - 'current' : current, - 'current1' : current1, - 'current2' : current2, - - - 'all_records': all_records, #for flashing all placement Schedule - - 'placement_search_record': placement_search_record, - 'pbi_search_record': pbi_search_record, - 'higher_search_record': higher_search_record, - - - - 'statistics_tab' : statistics_tab, - 'pbirecord' : pbirecord, - 'placementrecord' : placementrecord, - 'higherrecord' : higherrecord, - 'years' : years, - 'records' : records, - 'delete_operation' : delete_operation, - 'page_range': page_range, - 'paginator': paginator, - 'pagination_placement': pagination_placement, - 'pagination_pbi': pagination_pbi, - 'pagination_higher': pagination_higher, - 'is_disabled': is_disabled, - 'officer_statistics_past_pbi_search': officer_statistics_past_pbi_search, - 'officer_statistics_past_higher_search': officer_statistics_past_higher_search - } - - return render(request, 'placementModule/placementstatistics.html', context) - - - -def get_reference_list(request): - if request.method == 'POST': - # arr = request.POST.getlist('arr[]') - # print(arr) - # print(type(arr)) - user = request.user - profile = get_object_or_404(ExtraInfo, Q(user=user)) - student = get_object_or_404(Student, Q(id=profile.id)) - print(student) - reference_objects = Reference.select_related('unique_id').objects.filter(unique_id=student) - reference_objects = serializers.serialize('json', list(reference_objects)) - - context = { - 'reference_objs': reference_objects - } - return JsonResponse(context) - - -# Ajax for the company name dropdown for CompanyName when filling AddSchedule -def company_name_dropdown(request): - if request.method == 'POST': - current_value = request.POST.get('current_value') - company_names = CompanyDetails.objects.filter(Q(company_name__startswith=current_value)) - company_name = [] - for name in company_names: - company_name.append(name.company_name) - - context = { - 'company_names': company_name - } - - return JsonResponse(context) - - -# Ajax for all the roles in the dropdown -def checking_roles(request): - if request.method == 'POST': - current_value = request.POST.get('current_value') - all_roles = Role.objects.filter(Q(role__startswith=current_value)) - role_name = [] - for role in all_roles: - role_name.append(role.role) - return JsonResponse({'all_roles': role_name}) - -@login_required -def Placement__Schedule(request): - ''' - function include the functionality of first tab of UI - for student, placement officer & placement chairman - - placement officer & placement chairman - - can add schedule - - can delete schedule - student - - accepted or declined schedule - - ''' - user = request.user - profile = get_object_or_404(ExtraInfo, Q(user=user)) - schedule_tab = 1 - placementstatus = '' - - - form5 = AddSchedule(initial={}) - current1 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement chairman")) - current2 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement officer")) - current = HoldsDesignation.objects.filter(Q(working=user, designation__name="student")) - print(current) - - # If the user is Student - if current: - student = get_object_or_404(Student, Q(id=profile.id)) - - # Student view for showing accepted or declined schedule - if request.method == 'POST': - if 'studentapprovesubmit' in request.POST: - status = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - pk=request.POST['studentapprovesubmit']).update( - invitation='ACCEPTED', - timestamp=timezone.now()) - if 'studentdeclinesubmit' in request.POST: - status = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(pk=request.POST['studentdeclinesubmit'])).update( - invitation='REJECTED', - timestamp=timezone.now()) - - if 'educationsubmit' in request.POST: - form = AddEducation(request.POST) - if form.is_valid(): - institute = form.cleaned_data['institute'] - degree = form.cleaned_data['degree'] - grade = form.cleaned_data['grade'] - stream = form.cleaned_data['stream'] - sdate = form.cleaned_data['sdate'] - edate = form.cleaned_data['edate'] - education_obj = Education.objects.select_related('unique_id').create( - unique_id=student, degree=degree, - grade=grade, institute=institute, - stream=stream, sdate=sdate, edate=edate) - education_obj.save() - if 'profilesubmit' in request.POST: - about_me = request.POST.get('about') - age = request.POST.get('age') - address = request.POST.get('address') - contact = request.POST.get('contact') - pic = request.POST.get('pic') - # futu = request.POST.get('futu') - # print(studentplacement_obj.future_aspect) - # print('fut=', fut) - # print('futu=', futu) - # if studentplacement_obj.future_aspect == "HIGHER STUDIES": - # if futu == 2: - # studentplacement_obj.future_aspect = "PLACEMENT" - # elif studentplacement_obj.future_aspect == "PLACEMENT": - # if futu == None: - # studentplacement_obj.future_aspect = "HIGHER STUDIES" - extrainfo_obj = ExtraInfo.objects.get(user=user) - extrainfo_obj.about_me = about_me - extrainfo_obj.age = age - extrainfo_obj.address = address - extrainfo_obj.phone_no = contact - extrainfo_obj.profile_picture = pic - extrainfo_obj.save() - profile = get_object_or_404(ExtraInfo, Q(user=user)) - if 'skillsubmit' in request.POST: - form = AddSkill(request.POST) - if form.is_valid(): - skill = form.cleaned_data['skill'] - skill_rating = form.cleaned_data['skill_rating'] - has_obj = Has.objects.select_related('skill_id','unique_id').create(unique_id=student, - skill_id=Skill.objects.get(skill=skill), - skill_rating = skill_rating) - has_obj.save() - if 'achievementsubmit' in request.POST: - form = AddAchievement(request.POST) - if form.is_valid(): - achievement = form.cleaned_data['achievement'] - achievement_type = form.cleaned_data['achievement_type'] - description = form.cleaned_data['description'] - issuer = form.cleaned_data['issuer'] - date_earned = form.cleaned_data['date_earned'] - achievement_obj = Achievement.objects.select_related('unique_id').create(unique_id=student, - achievement=achievement, - achievement_type=achievement_type, - description=description, - issuer=issuer, - date_earned=date_earned) - achievement_obj.save() - if 'publicationsubmit' in request.POST: - form = AddPublication(request.POST) - if form.is_valid(): - publication_title = form.cleaned_data['publication_title'] - description = form.cleaned_data['description'] - publisher = form.cleaned_data['publisher'] - publication_date = form.cleaned_data['publication_date'] - publication_obj = Publication.objects.select_related('unique_id').create(unique_id=student, - publication_title= - publication_title, - publisher=publisher, - description=description, - publication_date=publication_date) - publication_obj.save() - if 'patentsubmit' in request.POST: - form = AddPatent(request.POST) - if form.is_valid(): - patent_name = form.cleaned_data['patent_name'] - description = form.cleaned_data['description'] - patent_office = form.cleaned_data['patent_office'] - patent_date = form.cleaned_data['patent_date'] - patent_obj = Patent.objects.select_related('unique_id').create(unique_id=student, patent_name=patent_name, - patent_office=patent_office, - description=description, - patent_date=patent_date) - patent_obj.save() - if 'coursesubmit' in request.POST: - form = AddCourse(request.POST) - if form.is_valid(): - course_name = form.cleaned_data['course_name'] - description = form.cleaned_data['description'] - license_no = form.cleaned_data['license_no'] - sdate = form.cleaned_data['sdate'] - edate = form.cleaned_data['edate'] - course_obj = Course.objects.select_related('unique_id').create(unique_id=student, course_name=course_name, - license_no=license_no, - description=description, - sdate=sdate, edate=edate) - course_obj.save() - if 'projectsubmit' in request.POST: - form = AddProject(request.POST) - if form.is_valid(): - project_name = form.cleaned_data['project_name'] - project_status = form.cleaned_data['project_status'] - summary = form.cleaned_data['summary'] - project_link = form.cleaned_data['project_link'] - sdate = form.cleaned_data['sdate'] - edate = form.cleaned_data['edate'] - project_obj = Project.objects.create(unique_id=student, summary=summary, - project_name=project_name, - project_status=project_status, - project_link=project_link, - sdate=sdate, edate=edate) - project_obj.save() - if 'experiencesubmit' in request.POST: - form = AddExperience(request.POST) - if form.is_valid(): - title = form.cleaned_data['title'] - status = form.cleaned_data['status'] - company = form.cleaned_data['company'] - location = form.cleaned_data['location'] - description = form.cleaned_data['description'] - sdate = form.cleaned_data['sdate'] - edate = form.cleaned_data['edate'] - experience_obj = Experience.objects.select_related('unique_id').create(unique_id=student, title=title, - company=company, location=location, - status=status, - description=description, - sdate=sdate, edate=edate) - experience_obj.save() - - if 'deleteskill' in request.POST: - hid = request.POST['deleteskill'] - hs = Has.objects.select_related('skill_id','unique_id').get(Q(pk=hid)) - hs.delete() - if 'deleteedu' in request.POST: - hid = request.POST['deleteedu'] - hs = Education.objects.select_related('unique_id').get(Q(pk=hid)) - hs.delete() - if 'deletecourse' in request.POST: - hid = request.POST['deletecourse'] - hs = Course.objects.get(Q(pk=hid)) - hs.delete() - if 'deleteexp' in request.POST: - hid = request.POST['deleteexp'] - hs = Experience.objects.get(Q(pk=hid)) - hs.delete() - if 'deletepro' in request.POST: - hid = request.POST['deletepro'] - hs = Project.objects.get(Q(pk=hid)) - hs.delete() - if 'deleteach' in request.POST: - hid = request.POST['deleteach'] - hs = Achievement.objects.get(Q(pk=hid)) - hs.delete() - if 'deletepub' in request.POST: - hid = request.POST['deletepub'] - hs = Publication.objects.select_related('unique_id').get(Q(pk=hid)) - hs.delete() - if 'deletepat' in request.POST: - hid = request.POST['deletepat'] - hs = Patent.objects.get(Q(pk=hid)) - hs.delete() - - placementschedule = PlacementSchedule.objects.select_related('notify_id').filter( - Q(placement_date__gte=date.today())).values_list('notify_id', flat=True) - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(unique_id=student, - notify_id__in=placementschedule)).order_by('-timestamp') - - - check_invitation_date(placementstatus) - - # facult and other staff view only statistics - if not (current or current1 or current2): - return redirect('/placement/statistics/') - - # delete the schedule - if 'deletesch' in request.POST: - delete_sch_key = request.POST['delete_sch_key'] - try: - placement_schedule = PlacementSchedule.objects.select_related('notify_id').get(pk = delete_sch_key) - NotifyStudent.objects.get(pk=placement_schedule.notify_id.id).delete() - placement_schedule.delete() - messages.success(request, 'Schedule Deleted Successfully') - except Exception as e: - messages.error(request, 'Problem Occurred for Schedule Delete!!!') - - # saving all the schedule details - if 'schedulesubmit' in request.POST: - form5 = AddSchedule(request.POST, request.FILES) - if form5.is_valid(): - company_name = form5.cleaned_data['company_name'] - placement_date = form5.cleaned_data['placement_date'] - location = form5.cleaned_data['location'] - ctc = form5.cleaned_data['ctc'] - time = form5.cleaned_data['time'] - attached_file = form5.cleaned_data['attached_file'] - placement_type = form5.cleaned_data['placement_type'] - role_offered = request.POST.get('role') - description = form5.cleaned_data['description'] - - try: - comp_name = CompanyDetails.objects.filter(company_name=company_name)[0] - except: - CompanyDetails.objects.create(company_name=company_name) - - try: - role = Role.objects.filter(role=role_offered)[0] - except: - role = Role.objects.create(role=role_offered) - role.save() - - - notify = NotifyStudent.objects.create(placement_type=placement_type, - company_name=company_name, - description=description, - ctc=ctc, - timestamp=timezone.now()) - - schedule = PlacementSchedule.objects.select_related('notify_id').create(notify_id=notify, - title=company_name, - description=description, - placement_date=placement_date, - attached_file = attached_file, - role=role, - location=location, time=time) - - notify.save() - schedule.save() - messages.success(request, "Schedule Added Successfull!!") - - - schedules = PlacementSchedule.objects.select_related('notify_id').all() - - - context = { - 'current': current, - 'current1': current1, - 'current2': current2, - 'schedule_tab': schedule_tab, - 'schedules': schedules, - 'placementstatus': placementstatus, - 'form5': form5, - } - - return render(request, 'placementModule/placement.html', context) - - - -def invite_status(request): - ''' - function to check the invitation status - ''' - user = request.user - strecord_tab = 1 - mnpbi_tab = 0 - mnplacement_post = 0 - mnpbi_post = 0 - invitation_status_tab = 1 - placementstatus_placement = [] - placementstatus_pbi = [] - mnplacement_tab = 1 - - no_pagination = 1 - is_disabled = 0 - paginator = '' - page_range = '' - placement_get_request = False - pbi_get_request = False - - # invitation status for placement - if 'studentplacementsearchsubmit' in request.POST: - mnplacement_post = 1 - mnpbi_post = 0 - form = ManagePlacementRecord(request.POST) - - if form.is_valid(): - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - else: - stuname = '' - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - if form.cleaned_data['company']: - cname = form.cleaned_data['company'] - else: - cname = '' - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - - request.session['mn_stuname'] = stuname - request.session['mn_ctc'] = ctc - request.session['mn_cname'] = cname - request.session['mn_rollno'] = rollno - - placementstatus_placement = PlacementStatus.objects.select_related('unique_id','notify_id').filter(Q(notify_id__in=NotifyStudent.objects.filter - (Q(placement_type="PLACEMENT", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))))) - # pagination stuff starts from here - total_query = placementstatus_placement.count() - - if total_query > 30: - no_pagination = 1 - paginator = Paginator(placementstatus_placement, 30) - page = request.GET.get('page', 1) - placementstatus_placement = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - else: - # when the request from pagination with some page number - if request.GET.get('placement_page') != None: - mnplacement_post = 1 - mnpbi_post = 0 - no_pagination = 1 - try: - placementstatus_placement = PlacementStatus.objects.select_related('unique_id','notify_id').filter(Q(notify_id__in=NotifyStudent.objects.filter - (Q(placement_type="PLACEMENT", - company_name__icontains=request.session['mn_cname'], - ctc__gte=request.session['mn_ctc'])), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=request.session['mn_stuname'])), - id__icontains=request.session['mn_rollno'])) - ))))) - except: - placementstatus_placement = [] - - if placementstatus_placement != '': - total_query = placementstatus_placement.count() - else: - total_query = 0 - - if total_query > 30: - paginator = Paginator(placementstatus_placement, 30) - page = request.GET.get('placement_page', 1) - placementstatus_placement = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - - # invitation status for pbi - if 'studentpbisearchsubmit' in request.POST: - mnpbi_tab = 1 - mnpbi_post = 1 - mnplacement_post = 0 - form = ManagePbiRecord(request.POST) - if form.is_valid(): - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - else: - stuname = '' - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - if form.cleaned_data['company']: - cname = form.cleaned_data['company'] - else: - cname = '' - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - request.session['mn_pbi_stuname'] = stuname - request.session['mn_pbi_ctc'] = ctc - request.session['mn_pbi_cname'] = cname - request.session['mn_pbi_rollno'] = rollno - placementstatus_pbi = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(notify_id__in=NotifyStudent.objects.filter( - Q(placement_type="PBI", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=stuname)), - id__icontains=rollno))))))).order_by('id') - - total_query = placementstatus_pbi.count() - - if total_query > 30: - no_pagination = 1 - paginator = Paginator(placementstatus_pbi, 30) - page = request.GET.get('pbi_page', 1) - placementstatus_pbi = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - else: - if request.GET.get('pbi_page') != None: - mnpbi_tab = 1 - mnpbi_post = 1 - no_pagination = 1 - try: - placementstatus_pbi = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(notify_id__in=NotifyStudent.objects.filter( - Q(placement_type="PBI", - company_name__icontains=request.session['mn_pbi_cname'], - ctc__gte=request.session['mn_pbi_ctc'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['mn_pbi_stuname'])), - id__icontains=request.session['mn_pbi_rollno'])) - ))))) - except: - placementstatus_pbi = '' - - if placementstatus_pbi != '': - total_query = placementstatus_pbi.count() - else: - total_query = 0 - if total_query > 30: - paginator = Paginator(placementstatus_pbi, 30) - page = request.GET.get('pbi_page', 1) - placementstatus_pbi = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - - - if 'pdf_gen_invitation_status' in request.POST: - - placementstatus = None - if 'pdf_gen_invitation_status_placement' in request.POST: - stuname = request.session['mn_stuname'] - ctc = request.session['mn_ctc'] - cname = request.session['mn_cname'] - rollno = request.session['mn_rollno'] - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter(Q(notify_id__in=NotifyStudent.objects.filter - (Q(placement_type="PLACEMENT", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))))) - - if 'pdf_gen_invitation_status_pbi' in request.POST: - stuname = request.session['mn_pbi_stuname'] - ctc = request.session['mn_pbi_ctc'] - cname = request.session['mn_pbi_cname'] - rollno = request.session['mn_pbi_rollno'] - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(notify_id__in=NotifyStudent.objects.filter( - Q(placement_type="PBI", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=stuname)), - id__icontains=rollno))))))).order_by('id') - - context = { - 'placementstatus' : placementstatus - } - - return render_to_pdf('placementModule/pdf_invitation_status.html', context) - - if 'excel_gen_invitation_status' in request.POST: - - placementstatus = None - if 'excel_gen_invitation_status_placement' in request.POST: - stuname = request.session['mn_stuname'] - ctc = request.session['mn_ctc'] - cname = request.session['mn_cname'] - rollno = request.session['mn_rollno'] - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter(Q(notify_id__in=NotifyStudent.objects.filter - (Q(placement_type="PLACEMENT", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))))) - - if 'excel_gen_invitation_status_pbi' in request.POST: - stuname = request.session['mn_pbi_stuname'] - ctc = request.session['mn_pbi_ctc'] - cname = request.session['mn_pbi_cname'] - rollno = request.session['mn_pbi_rollno'] - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(notify_id__in=NotifyStudent.objects.filter( - Q(placement_type="PBI", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=stuname)), - id__icontains=rollno))))))).order_by('id') - - context = { - 'placementstatus' : placementstatus - } - - - years = PlacementRecord.objects.filter(~Q(placement_type="HIGHER STUDIES")).values('year').annotate(Count('year')) - records = PlacementRecord.objects.values('name', 'year', 'ctc', 'placement_type').annotate(Count('name'), Count('year'), Count('placement_type'), Count('ctc')) - - - return export_to_xls_invitation_status(placementstatus) - - form1 = SearchStudentRecord(initial={}) - form9 = ManagePbiRecord(initial={}) - form11 = ManagePlacementRecord(initial={}) - form13 = SendInvite(initial={}) - current1 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement chairman")) - current2 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement officer")) - - context = { - 'form1': form1, - 'form9': form9, - 'form11': form11, - 'form13': form13, - 'invitation_status_tab': invitation_status_tab, - 'mnplacement_post': mnplacement_post, - 'mnpbi_tab': mnpbi_tab, - 'mnplacement_tab': mnplacement_tab, - 'placementstatus_placement': placementstatus_placement, - 'placementstatus_pbi': placementstatus_pbi, - 'current1': current1, - 'current2': current2, - 'strecord_tab': strecord_tab, - 'mnpbi_post': mnpbi_post, - 'page_range': page_range, - 'paginator': paginator, - 'no_pagination': no_pagination, - 'is_disabled': is_disabled, - } - - return render(request, 'placementModule/studentrecords.html', context) - - - - - - - - invitecheck=0 - for r in records: - r['name__count'] = 0 - r['year__count'] = 0 - r['placement_type__count'] = 0 - tcse = dict() - tece = dict() - tme = dict() - tadd = dict() - for y in years: - tcse[y['year']] = 0 - tece[y['year']] = 0 - tme[y['year']] = 0 - for r in records: - if r['year'] == y['year']: - if r['placement_type'] != "HIGHER STUDIES": - for z in studentrecord: - if z.record_id.name == r['name'] and z.record_id.year == r['year'] and z.unique_id.id.department.name == "CSE": - tcse[y['year']] = tcse[y['year']]+1 - r['name__count'] = r['name__count']+1 - if z.record_id.name == r['name'] and z.record_id.year == r['year'] and z.unique_id.id.department.name == "ECE": - tece[y['year']] = tece[y['year']]+1 - r['year__count'] = r['year__count']+1 - if z.record_id.name == r['name'] and z.record_id.year == r['year'] and z.unique_id.id.department.name == "ME": - tme[y['year']] = tme[y['year']]+1 - r['placement_type__count'] = r['placement_type__count']+1 - tadd[y['year']] = tcse[y['year']]+tece[y['year']]+tme[y['year']] - y['year__count'] = [tadd[y['year']], tcse[y['year']], tece[y['year']], tme[y['year']]] - - form2 = SearchPlacementRecord(initial={}) - form3 = SearchPbiRecord(initial={}) - form4 = SearchHigherRecord(initial={}) - - - current1 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement chairman")) - current2 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement officer")) - current = HoldsDesignation.objects.filter(Q(working=user, designation__name="student")) - print(current) - - if len(current1)!=0 or len(current2)!=0: - delete_operation = 1 - if len(current) == 0: - current = None - pbirecord= '' - placementrecord= '' - higherrecord= '' - total_query=0 - total_query1 = 0 - total_query2= 0 - p="" - p1="" - p2="" - placement_search_record=" " - pbi_search_record=" " - higher_search_record=" " - # results of the searched query under placement tab - if 'studentplacementrecordsubmit' in request.POST: - officer_statistics_past = 1 - form = SearchPlacementRecord(request.POST) - if form.is_valid(): - - - - - print("IS VALID") - - - - #for student name - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - try: - first_name = stuname.split(" ")[0] - last_name = stuname.split(" ")[1] - except Exception as e: - print("Error") - print(e) - first_name = stuname - last_name = '' - else: - stuname = '' - first_name = '' - last_name = '' - - - # for student CTC - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - - #for company name - if form.cleaned_data['cname']: - cname = form.cleaned_data['cname'] - else: - cname = '' - - #for student roll - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - - #for admission year - if form.cleaned_data['year']: - year = form.cleaned_data['year'] - s = Student.objects.filter((Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - first_name__icontains=first_name, - last_name__icontains=last_name), - id__icontains=rollno)) - ))) - - p = PlacementRecord.objects.filter(Q(placement_type="PLACEMENT", name__icontains=stuname, ctc__icontains=ctc, year__icontains=year)) - - - - - """placementrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", name__icontains=cname, ctc__gte=ctc, year=year)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - first_name__icontains=first_name, - last_name__icontains=last_name, - id__icontains=rollno)))))))) - #print("In if:", placementrecord) - else: - s = Student.objects.filter((Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - first_name__icontains=first_name, - last_name__icontains=last_name), - id__icontains=rollno)) - ))) - - p = PlacementRecord.objects.filter(Q(placement_type="PLACEMENT", name__icontains=cname, ctc__gte=ctc)) - print("Agein p:",p) - placementrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", name__icontains=cname, ctc__gte=ctc)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - first_name__icontains=first_name, - last_name__icontains=last_name), - id__icontains=rollno))))))) - - request.session['first_name'] = first_name - request.session['last_name'] = last_name - request.session['ctc'] = ctc - request.session['cname'] = cname - request.session['rollno'] = rollno - request.session['year'] = form.cleaned_data['year']""" - - print(p) - - - total_query = p.count() - - if total_query > 30: - pagination_placement = 1 - paginator = Paginator(placementrecord, 30) - page = request.GET.get('page', 1) - placementrecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_placement = 0 - else: - if request.GET.get('page') != None: - try: - if request.session['year']: - s = Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno'])) - ))) - - p = PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'], - year=request.session['year'])) - - - placementrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'], - year=request.session['year'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno']))))))) - else: - s = Student.objects.filter((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno'])) - ))) - - p = PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'])) - - placementrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno']))))))) - except Exception as e: - print(e) - placementrecord = '' - - if placementrecord != '': - total_query = placementrecord.count() - else: - total_query = 0 - no_records=1 - print(placementrecord) - if total_query > 30: - pagination_placement = 1 - paginator = Paginator(placementrecord, 30) - page = request.GET.get('page', 1) - placementrecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_placement = 0 - else: - placementrecord = '' - - if total_query!=0: - placement_search_record=p - # results of the searched query under pbi tab - if 'studentpbirecordsubmit' in request.POST: - officer_statistics_past_pbi_search = 1 - form = SearchPbiRecord(request.POST) - if form.is_valid(): - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - try: - first_name = stuname.split(" ")[0] - last_name = stuname.split(" ")[1] - except: - first_name = stuname - last_name = '' - else: - stuname = '' - first_name = '' - last_name = '' - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - if form.cleaned_data['cname']: - cname = form.cleaned_data['cname'] - else: - cname = '' - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - if form.cleaned_data['year']: - year = form.cleaned_data['year'] - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter(Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="PBI", - name__icontains=cname, - ctc__gte=ctc, year=year)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=first_name, - last_name__icontains=last_name)), - id__icontains=rollno)) - ))))) - p1 = PlacementRecord.objects.filter( - Q(placement_type="PBI", name__icontains=stuname, ctc__icontains=ctc, year__icontains=year)) - """else: - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter(Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="PBI", - name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=first_name, - last_name__icontains=last_name)), - id__icontains=rollno)) - ))))) - request.session['first_name'] = first_name - request.session['last_name'] = last_name - request.session['ctc'] = ctc - request.session['cname'] = cname - request.session['rollno'] = rollno - request.session['year'] = form.cleaned_data['year'] -""" - total_query1 = p1.count() - - if total_query1 > 30: - pagination_pbi = 1 - paginator = Paginator(pbirecord, 30) - page = request.GET.get('page', 1) - pbirecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query1 > 30 and total_query1 <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_pbi = 0 - else: - if request.GET.get('page') != None: - try: - if request.session['year']: - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PBI", - name__icontains=request.session['cname'], - ctc__gte=ctc, year=request.session['year'])), - unique_id__in=Student.objects.filter(( - Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno']))))))) - else: - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter(Q(placement_type="PBI", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno']))))))) - except: - print('except') - pbirecord = '' - - if pbirecord != '': - total_query = pbirecord.count() - else: - total_query = 0 - - if total_query > 30: - pagination_pbi = 1 - paginator = Paginator(pbirecord, 30) - page = request.GET.get('page', 1) - pbirecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_pbi = 0 - else: - pbirecord = '' - if total_query1!=0: - pbi_search_record=p1 - - # results of the searched query under higher studies tab - if 'studenthigherrecordsubmit' in request.POST: - officer_statistics_past_higher_search = 1 - form = SearchHigherRecord(request.POST) - if form.is_valid(): - # getting all the variables send through form - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - try: - first_name = stuname.split(" ")[0] - last_name = stuname.split(" ")[1] - except: - first_name = stuname - last_name = '' - else: - stuname = '' - first_name = '' - last_name = '' - if form.cleaned_data['test_type']: - test_type = form.cleaned_data['test_type'] - else: - test_type = '' - if form.cleaned_data['uname']: - uname = form.cleaned_data['uname'] - else: - uname = '' - if form.cleaned_data['test_score']: - test_score = form.cleaned_data['test_score'] - else: - test_score = 0 - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - if form.cleaned_data['year']: - year = form.cleaned_data['year'] - # result of the query when year is given - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter(Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="HIGHER STUDIES", - test_type__icontains=test_type, - name__icontains=uname, year=year, - test_score__gte=test_score)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=first_name, - last_name__icontains=last_name)), - id__icontains=rollno)) - ))))) - - p2 = PlacementRecord.objects.filter( - Q(placement_type="HIGHER STUDIES", name__icontains=stuname, year__icontains=year)) - - """else: - # result of the query when year is not given - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="HIGHER STUDIES", - test_type__icontains=test_type, - name__icontains=uname, - test_score__gte=test_score)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=first_name, - last_name__icontains=last_name)), - id__icontains=rollno)) - ))))) - request.session['first_name'] = first_name - request.session['last_name'] = last_name - request.session['test_score'] = test_score - request.session['uname'] = uname - request.session['test_type'] = test_type - request.session['rollno'] = rollno - request.session['year'] = form.cleaned_data['year']""" - - total_query2 = p2.count() - - if total_query2 > 30: - pagination_higher = 1 - paginator = Paginator(p2, 30) - page = request.GET.get('page', 1) - p2 = paginator.page(page) - page = int(page) - total_page = int(page+3) - - if page < (paginator.num_pages-3): - if total_query2 > 30 and total_query2 <= 60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(page-2, paginator.num_pages+1) - else: - pagination_higher = 0 - else: - if request.GET.get('page') != None: - try: - if request.session['year']: - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="HIGHER STUDIES", - test_type__icontains=request.session['test_type'], - name__icontains=request.session['uname'], - year=request.session['year'], - test_score__gte=request.session['test_score'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno'])) - ))))) - else: - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="HIGHER STUDIES", - test_type__icontains=request.session['test_type'], - name__icontains=request.session['uname'], - test_score__gte=request.session['test_score'])), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno'])) - ))))) - except: - higherrecord = '' - - if higherrecord != '': - total_query = higherrecord.count() - else: - total_query = 0 - - if total_query > 30: - no_pagination = 1 - paginator = Paginator(higherrecord, 30) - page = request.GET.get('page', 1) - higherrecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - else: - higherrecord = '' - if total_query2!=0: - higher_search_record=p2 - - context = { - 'form2' : form2, - 'form3' : form3, - 'form4' : form4, - 'current' : current, - 'current1' : current1, - 'current2' : current2, - - - 'all_records': all_records, #for flashing all placement Schedule - - 'placement_search_record': placement_search_record, - 'pbi_search_record': pbi_search_record, - 'higher_search_record': higher_search_record, - - - - 'statistics_tab' : statistics_tab, - 'pbirecord' : pbirecord, - 'placementrecord' : placementrecord, - 'higherrecord' : higherrecord, - 'years' : years, - 'records' : records, - 'delete_operation' : delete_operation, - 'page_range': page_range, - 'paginator': paginator, - 'pagination_placement': pagination_placement, - 'pagination_pbi': pagination_pbi, - 'pagination_higher': pagination_higher, - 'is_disabled': is_disabled, - 'officer_statistics_past_pbi_search': officer_statistics_past_pbi_search, - 'officer_statistics_past_higher_search': officer_statistics_past_higher_search - } - - return render(request, 'placementModule/placementstatistics.html', context) - - - -def get_reference_list(request): - if request.method == 'POST': - - user = request.user - profile = get_object_or_404(ExtraInfo, Q(user=user)) - student = get_object_or_404(Student, Q(id=profile.id)) - print(student) - reference_objects = Reference.select_related('unique_id').objects.filter(unique_id=student) - reference_objects = serializers.serialize('json', list(reference_objects)) - - context = { - 'reference_objs': reference_objects - } - return JsonResponse(context) - - -# Ajax for the company name dropdown for CompanyName when filling AddSchedule -def company_name_dropdown(request): - if request.method == 'POST': - current_value = request.POST.get('current_value') - company_names = CompanyDetails.objects.filter(Q(company_name__startswith=current_value)) - company_name = [] - for name in company_names: - company_name.append(name.company_name) - - context = { - 'company_names': company_name - } - - return JsonResponse(context) - - -# Ajax for all the roles in the dropdown -def checking_roles(request): - if request.method == 'POST': - current_value = request.POST.get('current_value') - all_roles = Role.objects.filter(Q(role__startswith=current_value)) - role_name = [] - for role in all_roles: - role_name.append(role.role) - return JsonResponse({'all_roles': role_name}) - -@login_required -def Placement__Schedule(request): - ''' - function include the functionality of first tab of UI - for student, placement officer & placement chairman - - placement officer & placement chairman - - can add schedule - - can delete schedule - student - - accepted or declined schedule - - ''' - user = request.user - profile = get_object_or_404(ExtraInfo, Q(user=user)) - schedule_tab = 1 - placementstatus = '' - - - form5 = AddSchedule(initial={}) - current1 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement chairman")) - current2 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement officer")) - current = HoldsDesignation.objects.filter(Q(working=user, designation__name="student")) - print(current) - - # If the user is Student - if current: - student = get_object_or_404(Student, Q(id=profile.id)) - - # Student view for showing accepted or declined schedule - if request.method == 'POST': - if 'studentapprovesubmit' in request.POST: - status = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - pk=request.POST['studentapprovesubmit']).update( - invitation='ACCEPTED', - timestamp=timezone.now()) - if 'studentdeclinesubmit' in request.POST: - status = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(pk=request.POST['studentdeclinesubmit'])).update( - invitation='REJECTED', - timestamp=timezone.now()) - - if 'educationsubmit' in request.POST: - form = AddEducation(request.POST) - if form.is_valid(): - institute = form.cleaned_data['institute'] - degree = form.cleaned_data['degree'] - grade = form.cleaned_data['grade'] - stream = form.cleaned_data['stream'] - sdate = form.cleaned_data['sdate'] - edate = form.cleaned_data['edate'] - education_obj = Education.objects.select_related('unique_id').create( - unique_id=student, degree=degree, - grade=grade, institute=institute, - stream=stream, sdate=sdate, edate=edate) - education_obj.save() - if 'profilesubmit' in request.POST: - about_me = request.POST.get('about') - age = request.POST.get('age') - address = request.POST.get('address') - contact = request.POST.get('contact') - pic = request.POST.get('pic') - # futu = request.POST.get('futu') - # print(studentplacement_obj.future_aspect) - # print('fut=', fut) - # print('futu=', futu) - # if studentplacement_obj.future_aspect == "HIGHER STUDIES": - # if futu == 2: - # studentplacement_obj.future_aspect = "PLACEMENT" - # elif studentplacement_obj.future_aspect == "PLACEMENT": - # if futu == None: - # studentplacement_obj.future_aspect = "HIGHER STUDIES" - extrainfo_obj = ExtraInfo.objects.get(user=user) - extrainfo_obj.about_me = about_me - extrainfo_obj.age = age - extrainfo_obj.address = address - extrainfo_obj.phone_no = contact - extrainfo_obj.profile_picture = pic - extrainfo_obj.save() - profile = get_object_or_404(ExtraInfo, Q(user=user)) - if 'skillsubmit' in request.POST: - form = AddSkill(request.POST) - if form.is_valid(): - skill = form.cleaned_data['skill'] - skill_rating = form.cleaned_data['skill_rating'] - has_obj = Has.objects.select_related('skill_id','unique_id').create(unique_id=student, - skill_id=Skill.objects.get(skill=skill), - skill_rating = skill_rating) - has_obj.save() - if 'achievementsubmit' in request.POST: - form = AddAchievement(request.POST) - if form.is_valid(): - achievement = form.cleaned_data['achievement'] - achievement_type = form.cleaned_data['achievement_type'] - description = form.cleaned_data['description'] - issuer = form.cleaned_data['issuer'] - date_earned = form.cleaned_data['date_earned'] - achievement_obj = Achievement.objects.select_related('unique_id').create(unique_id=student, - achievement=achievement, - achievement_type=achievement_type, - description=description, - issuer=issuer, - date_earned=date_earned) - achievement_obj.save() - if 'publicationsubmit' in request.POST: - form = AddPublication(request.POST) - if form.is_valid(): - publication_title = form.cleaned_data['publication_title'] - description = form.cleaned_data['description'] - publisher = form.cleaned_data['publisher'] - publication_date = form.cleaned_data['publication_date'] - publication_obj = Publication.objects.select_related('unique_id').create(unique_id=student, - publication_title= - publication_title, - publisher=publisher, - description=description, - publication_date=publication_date) - publication_obj.save() - if 'patentsubmit' in request.POST: - form = AddPatent(request.POST) - if form.is_valid(): - patent_name = form.cleaned_data['patent_name'] - description = form.cleaned_data['description'] - patent_office = form.cleaned_data['patent_office'] - patent_date = form.cleaned_data['patent_date'] - patent_obj = Patent.objects.select_related('unique_id').create(unique_id=student, patent_name=patent_name, - patent_office=patent_office, - description=description, - patent_date=patent_date) - patent_obj.save() - if 'coursesubmit' in request.POST: - form = AddCourse(request.POST) - if form.is_valid(): - course_name = form.cleaned_data['course_name'] - description = form.cleaned_data['description'] - license_no = form.cleaned_data['license_no'] - sdate = form.cleaned_data['sdate'] - edate = form.cleaned_data['edate'] - course_obj = Course.objects.select_related('unique_id').create(unique_id=student, course_name=course_name, - license_no=license_no, - description=description, - sdate=sdate, edate=edate) - course_obj.save() - if 'projectsubmit' in request.POST: - form = AddProject(request.POST) - if form.is_valid(): - project_name = form.cleaned_data['project_name'] - project_status = form.cleaned_data['project_status'] - summary = form.cleaned_data['summary'] - project_link = form.cleaned_data['project_link'] - sdate = form.cleaned_data['sdate'] - edate = form.cleaned_data['edate'] - project_obj = Project.objects.create(unique_id=student, summary=summary, - project_name=project_name, - project_status=project_status, - project_link=project_link, - sdate=sdate, edate=edate) - project_obj.save() - if 'experiencesubmit' in request.POST: - form = AddExperience(request.POST) - if form.is_valid(): - title = form.cleaned_data['title'] - status = form.cleaned_data['status'] - company = form.cleaned_data['company'] - location = form.cleaned_data['location'] - description = form.cleaned_data['description'] - sdate = form.cleaned_data['sdate'] - edate = form.cleaned_data['edate'] - experience_obj = Experience.objects.select_related('unique_id').create(unique_id=student, title=title, - company=company, location=location, - status=status, - description=description, - sdate=sdate, edate=edate) - experience_obj.save() - - if 'deleteskill' in request.POST: - hid = request.POST['deleteskill'] - hs = Has.objects.select_related('skill_id','unique_id').get(Q(pk=hid)) - hs.delete() - if 'deleteedu' in request.POST: - hid = request.POST['deleteedu'] - hs = Education.objects.select_related('unique_id').get(Q(pk=hid)) - hs.delete() - if 'deletecourse' in request.POST: - hid = request.POST['deletecourse'] - hs = Course.objects.get(Q(pk=hid)) - hs.delete() - if 'deleteexp' in request.POST: - hid = request.POST['deleteexp'] - hs = Experience.objects.get(Q(pk=hid)) - hs.delete() - if 'deletepro' in request.POST: - hid = request.POST['deletepro'] - hs = Project.objects.get(Q(pk=hid)) - hs.delete() - if 'deleteach' in request.POST: - hid = request.POST['deleteach'] - hs = Achievement.objects.get(Q(pk=hid)) - hs.delete() - if 'deletepub' in request.POST: - hid = request.POST['deletepub'] - hs = Publication.objects.select_related('unique_id').get(Q(pk=hid)) - hs.delete() - if 'deletepat' in request.POST: - hid = request.POST['deletepat'] - hs = Patent.objects.get(Q(pk=hid)) - hs.delete() - - placementschedule = PlacementSchedule.objects.select_related('notify_id').filter( - Q(placement_date__gte=date.today())).values_list('notify_id', flat=True) - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(unique_id=student, - notify_id__in=placementschedule)).order_by('-timestamp') - - - check_invitation_date(placementstatus) - - # facult and other staff view only statistics - if not (current or current1 or current2): - return redirect('/placement/statistics/') - - # delete the schedule - if 'deletesch' in request.POST: - delete_sch_key = request.POST['delete_sch_key'] - try: - placement_schedule = PlacementSchedule.objects.select_related('notify_id').get(pk = delete_sch_key) - NotifyStudent.objects.get(pk=placement_schedule.notify_id.id).delete() - placement_schedule.delete() - messages.success(request, 'Schedule Deleted Successfully') - except Exception as e: - messages.error(request, 'Problem Occurred for Schedule Delete!!!') - - # saving all the schedule details - if 'schedulesubmit' in request.POST: - form5 = AddSchedule(request.POST, request.FILES) - if form5.is_valid(): - company_name = form5.cleaned_data['company_name'] - placement_date = form5.cleaned_data['placement_date'] - location = form5.cleaned_data['location'] - ctc = form5.cleaned_data['ctc'] - time = form5.cleaned_data['time'] - attached_file = form5.cleaned_data['attached_file'] - placement_type = form5.cleaned_data['placement_type'] - role_offered = request.POST.get('role') - description = form5.cleaned_data['description'] - - try: - comp_name = CompanyDetails.objects.filter(company_name=company_name)[0] - except: - CompanyDetails.objects.create(company_name=company_name) - - try: - role = Role.objects.filter(role=role_offered)[0] - except: - role = Role.objects.create(role=role_offered) - role.save() - - - notify = NotifyStudent.objects.create(placement_type=placement_type, - company_name=company_name, - description=description, - ctc=ctc, - timestamp=timezone.now()) - - schedule = PlacementSchedule.objects.select_related('notify_id').create(notify_id=notify, - title=company_name, - description=description, - placement_date=placement_date, - attached_file = attached_file, - role=role, - location=location, time=time) - - notify.save() - schedule.save() - messages.success(request, "Schedule Added Successfull!!") - - - schedules = PlacementSchedule.objects.select_related('notify_id').all() - - - context = { - 'current': current, - 'current1': current1, - 'current2': current2, - 'schedule_tab': schedule_tab, - 'schedules': schedules, - 'placementstatus': placementstatus, - 'form5': form5, - } - - return render(request, 'placementModule/placement.html', context) - - - -def invite_status(request): - ''' - function to check the invitation status - ''' - user = request.user - strecord_tab = 1 - mnpbi_tab = 0 - mnplacement_post = 0 - mnpbi_post = 0 - invitation_status_tab = 1 - placementstatus_placement = [] - placementstatus_pbi = [] - mnplacement_tab = 1 - - no_pagination = 1 - is_disabled = 0 - paginator = '' - page_range = '' - placement_get_request = False - pbi_get_request = False - - # invitation status for placement - if 'studentplacementsearchsubmit' in request.POST: - mnplacement_post = 1 - mnpbi_post = 0 - form = ManagePlacementRecord(request.POST) - - if form.is_valid(): - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - else: - stuname = '' - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - if form.cleaned_data['company']: - cname = form.cleaned_data['company'] - else: - cname = '' - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - - request.session['mn_stuname'] = stuname - request.session['mn_ctc'] = ctc - request.session['mn_cname'] = cname - request.session['mn_rollno'] = rollno - - placementstatus_placement = PlacementStatus.objects.select_related('unique_id','notify_id').filter(Q(notify_id__in=NotifyStudent.objects.filter - (Q(placement_type="PLACEMENT", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))))) - # pagination stuff starts from here - total_query = placementstatus_placement.count() - - if total_query > 30: - no_pagination = 1 - paginator = Paginator(placementstatus_placement, 30) - page = request.GET.get('page', 1) - placementstatus_placement = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - else: - # when the request from pagination with some page number - if request.GET.get('placement_page') != None: - mnplacement_post = 1 - mnpbi_post = 0 - no_pagination = 1 - try: - placementstatus_placement = PlacementStatus.objects.select_related('unique_id','notify_id').filter(Q(notify_id__in=NotifyStudent.objects.filter - (Q(placement_type="PLACEMENT", - company_name__icontains=request.session['mn_cname'], - ctc__gte=request.session['mn_ctc'])), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=request.session['mn_stuname'])), - id__icontains=request.session['mn_rollno'])) - ))))) - except: - placementstatus_placement = [] - - if placementstatus_placement != '': - total_query = placementstatus_placement.count() - else: - total_query = 0 - - if total_query > 30: - paginator = Paginator(placementstatus_placement, 30) - page = request.GET.get('placement_page', 1) - placementstatus_placement = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - - # invitation status for pbi - if 'studentpbisearchsubmit' in request.POST: - mnpbi_tab = 1 - mnpbi_post = 1 - mnplacement_post = 0 - form = ManagePbiRecord(request.POST) - if form.is_valid(): - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - else: - stuname = '' - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - if form.cleaned_data['company']: - cname = form.cleaned_data['company'] - else: - cname = '' - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - request.session['mn_pbi_stuname'] = stuname - request.session['mn_pbi_ctc'] = ctc - request.session['mn_pbi_cname'] = cname - request.session['mn_pbi_rollno'] = rollno - placementstatus_pbi = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(notify_id__in=NotifyStudent.objects.filter( - Q(placement_type="PBI", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=stuname)), - id__icontains=rollno))))))).order_by('id') - - total_query = placementstatus_pbi.count() - - if total_query > 30: - no_pagination = 1 - paginator = Paginator(placementstatus_pbi, 30) - page = request.GET.get('pbi_page', 1) - placementstatus_pbi = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - else: - if request.GET.get('pbi_page') != None: - mnpbi_tab = 1 - mnpbi_post = 1 - no_pagination = 1 - try: - placementstatus_pbi = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(notify_id__in=NotifyStudent.objects.filter( - Q(placement_type="PBI", - company_name__icontains=request.session['mn_pbi_cname'], - ctc__gte=request.session['mn_pbi_ctc'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['mn_pbi_stuname'])), - id__icontains=request.session['mn_pbi_rollno'])) - ))))) - except: - placementstatus_pbi = '' - - if placementstatus_pbi != '': - total_query = placementstatus_pbi.count() - else: - total_query = 0 - if total_query > 30: - paginator = Paginator(placementstatus_pbi, 30) - page = request.GET.get('pbi_page', 1) - placementstatus_pbi = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - - - if 'pdf_gen_invitation_status' in request.POST: - - placementstatus = None - if 'pdf_gen_invitation_status_placement' in request.POST: - stuname = request.session['mn_stuname'] - ctc = request.session['mn_ctc'] - cname = request.session['mn_cname'] - rollno = request.session['mn_rollno'] - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter(Q(notify_id__in=NotifyStudent.objects.filter - (Q(placement_type="PLACEMENT", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))))) - - if 'pdf_gen_invitation_status_pbi' in request.POST: - stuname = request.session['mn_pbi_stuname'] - ctc = request.session['mn_pbi_ctc'] - cname = request.session['mn_pbi_cname'] - rollno = request.session['mn_pbi_rollno'] - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(notify_id__in=NotifyStudent.objects.filter( - Q(placement_type="PBI", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=stuname)), - id__icontains=rollno))))))).order_by('id') - - context = { - 'placementstatus' : placementstatus - } - - return render_to_pdf('placementModule/pdf_invitation_status.html', context) - - if 'excel_gen_invitation_status' in request.POST: - - placementstatus = None - if 'excel_gen_invitation_status_placement' in request.POST: - stuname = request.session['mn_stuname'] - ctc = request.session['mn_ctc'] - cname = request.session['mn_cname'] - rollno = request.session['mn_rollno'] - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter(Q(notify_id__in=NotifyStudent.objects.filter - (Q(placement_type="PLACEMENT", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))))) - - if 'excel_gen_invitation_status_pbi' in request.POST: - stuname = request.session['mn_pbi_stuname'] - ctc = request.session['mn_pbi_ctc'] - cname = request.session['mn_pbi_cname'] - rollno = request.session['mn_pbi_rollno'] - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(notify_id__in=NotifyStudent.objects.filter( - Q(placement_type="PBI", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=stuname)), - id__icontains=rollno))))))).order_by('id') - - context = { - 'placementstatus' : placementstatus - } - - - return export_to_xls_invitation_status(placementstatus) - - form1 = SearchStudentRecord(initial={}) - form9 = ManagePbiRecord(initial={}) - form11 = ManagePlacementRecord(initial={}) - form13 = SendInvite(initial={}) - current1 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement chairman")) - current2 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement officer")) - - context = { - 'form1': form1, - 'form9': form9, - 'form11': form11, - 'form13': form13, - 'invitation_status_tab': invitation_status_tab, - 'mnplacement_post': mnplacement_post, - 'mnpbi_tab': mnpbi_tab, - 'mnplacement_tab': mnplacement_tab, - 'placementstatus_placement': placementstatus_placement, - 'placementstatus_pbi': placementstatus_pbi, - 'current1': current1, - 'current2': current2, - 'strecord_tab': strecord_tab, - 'mnpbi_post': mnpbi_post, - 'page_range': page_range, - 'paginator': paginator, - 'no_pagination': no_pagination, - 'is_disabled': is_disabled, - } - - return render(request, 'placementModule/studentrecords.html', context) - - - -@login_required -def placement(request): - ''' - function include the functionality of first tab of UI - for student, placement officer & placement chairman - - placement officer & placement chairman - - can add schedule - - can delete schedule - student - - accepted or declined schedule - - ''' - user = request.user - profile = get_object_or_404(ExtraInfo, Q(user=user)) - schedule_tab = 1 - placementstatus = '' - - - form5 = AddSchedule(initial={}) - current1 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement chairman")) - current2 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement officer")) - current = HoldsDesignation.objects.filter(Q(working=user, designation__name="student")) - print(current) - - # If the user is Student - if current: - student = get_object_or_404(Student, Q(id=profile.id)) - - # Student view for showing accepted or declined schedule - if request.method == 'POST': - if 'studentapprovesubmit' in request.POST: - status = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - pk=request.POST['studentapprovesubmit']).update( - invitation='ACCEPTED', - timestamp=timezone.now()) - if 'studentdeclinesubmit' in request.POST: - status = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(pk=request.POST['studentdeclinesubmit'])).update( - invitation='REJECTED', - timestamp=timezone.now()) - - if 'educationsubmit' in request.POST: - form = AddEducation(request.POST) - if form.is_valid(): - institute = form.cleaned_data['institute'] - degree = form.cleaned_data['degree'] - grade = form.cleaned_data['grade'] - stream = form.cleaned_data['stream'] - sdate = form.cleaned_data['sdate'] - edate = form.cleaned_data['edate'] - education_obj = Education.objects.select_related('unique_id').create( - unique_id=student, degree=degree, - grade=grade, institute=institute, - stream=stream, sdate=sdate, edate=edate) - education_obj.save() - if 'profilesubmit' in request.POST: - about_me = request.POST.get('about') - age = request.POST.get('age') - address = request.POST.get('address') - contact = request.POST.get('contact') - pic = request.POST.get('pic') - - extrainfo_obj = ExtraInfo.objects.get(user=user) - extrainfo_obj.about_me = about_me - extrainfo_obj.age = age - extrainfo_obj.address = address - extrainfo_obj.phone_no = contact - extrainfo_obj.profile_picture = pic - extrainfo_obj.save() - profile = get_object_or_404(ExtraInfo, Q(user=user)) - if 'skillsubmit' in request.POST: - form = AddSkill(request.POST) - if form.is_valid(): - skill = form.cleaned_data['skill'] - skill_rating = form.cleaned_data['skill_rating'] - has_obj = Has.objects.select_related('skill_id','unique_id').create(unique_id=student, - skill_id=Skill.objects.get(skill=skill), - skill_rating = skill_rating) - has_obj.save() - if 'achievementsubmit' in request.POST: - form = AddAchievement(request.POST) - if form.is_valid(): - achievement = form.cleaned_data['achievement'] - achievement_type = form.cleaned_data['achievement_type'] - description = form.cleaned_data['description'] - issuer = form.cleaned_data['issuer'] - date_earned = form.cleaned_data['date_earned'] - achievement_obj = Achievement.objects.select_related('unique_id').create(unique_id=student, - achievement=achievement, - achievement_type=achievement_type, - description=description, - issuer=issuer, - date_earned=date_earned) - achievement_obj.save() - if 'publicationsubmit' in request.POST: - form = AddPublication(request.POST) - if form.is_valid(): - publication_title = form.cleaned_data['publication_title'] - description = form.cleaned_data['description'] - publisher = form.cleaned_data['publisher'] - publication_date = form.cleaned_data['publication_date'] - publication_obj = Publication.objects.select_related('unique_id').create(unique_id=student, - publication_title= - publication_title, - publisher=publisher, - description=description, - publication_date=publication_date) - publication_obj.save() - if 'patentsubmit' in request.POST: - form = AddPatent(request.POST) - if form.is_valid(): - patent_name = form.cleaned_data['patent_name'] - description = form.cleaned_data['description'] - patent_office = form.cleaned_data['patent_office'] - patent_date = form.cleaned_data['patent_date'] - patent_obj = Patent.objects.select_related('unique_id').create(unique_id=student, patent_name=patent_name, - patent_office=patent_office, - description=description, - patent_date=patent_date) - patent_obj.save() - if 'coursesubmit' in request.POST: - form = AddCourse(request.POST) - if form.is_valid(): - course_name = form.cleaned_data['course_name'] - description = form.cleaned_data['description'] - license_no = form.cleaned_data['license_no'] - sdate = form.cleaned_data['sdate'] - edate = form.cleaned_data['edate'] - course_obj = Course.objects.select_related('unique_id').create(unique_id=student, course_name=course_name, - license_no=license_no, - description=description, - sdate=sdate, edate=edate) - course_obj.save() - if 'projectsubmit' in request.POST: - form = AddProject(request.POST) - if form.is_valid(): - project_name = form.cleaned_data['project_name'] - project_status = form.cleaned_data['project_status'] - summary = form.cleaned_data['summary'] - project_link = form.cleaned_data['project_link'] - sdate = form.cleaned_data['sdate'] - edate = form.cleaned_data['edate'] - project_obj = Project.objects.create(unique_id=student, summary=summary, - project_name=project_name, - project_status=project_status, - project_link=project_link, - sdate=sdate, edate=edate) - project_obj.save() - if 'experiencesubmit' in request.POST: - form = AddExperience(request.POST) - if form.is_valid(): - title = form.cleaned_data['title'] - status = form.cleaned_data['status'] - company = form.cleaned_data['company'] - location = form.cleaned_data['location'] - description = form.cleaned_data['description'] - sdate = form.cleaned_data['sdate'] - edate = form.cleaned_data['edate'] - experience_obj = Experience.objects.select_related('unique_id').create(unique_id=student, title=title, - company=company, location=location, - status=status, - description=description, - sdate=sdate, edate=edate) - experience_obj.save() - - if 'deleteskill' in request.POST: - hid = request.POST['deleteskill'] - hs = Has.objects.select_related('skill_id','unique_id').get(Q(pk=hid)) - hs.delete() - if 'deleteedu' in request.POST: - hid = request.POST['deleteedu'] - hs = Education.objects.select_related('unique_id').get(Q(pk=hid)) - hs.delete() - if 'deletecourse' in request.POST: - hid = request.POST['deletecourse'] - hs = Course.objects.get(Q(pk=hid)) - hs.delete() - if 'deleteexp' in request.POST: - hid = request.POST['deleteexp'] - hs = Experience.objects.get(Q(pk=hid)) - hs.delete() - if 'deletepro' in request.POST: - hid = request.POST['deletepro'] - hs = Project.objects.get(Q(pk=hid)) - hs.delete() - if 'deleteach' in request.POST: - hid = request.POST['deleteach'] - hs = Achievement.objects.get(Q(pk=hid)) - hs.delete() - if 'deletepub' in request.POST: - hid = request.POST['deletepub'] - hs = Publication.objects.select_related('unique_id').get(Q(pk=hid)) - hs.delete() - if 'deletepat' in request.POST: - hid = request.POST['deletepat'] - hs = Patent.objects.get(Q(pk=hid)) - hs.delete() - - placementschedule = PlacementSchedule.objects.select_related('notify_id').filter( - Q(placement_date__gte=date.today())).values_list('notify_id', flat=True) - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(unique_id=student, - notify_id__in=placementschedule)).order_by('-timestamp') - - - check_invitation_date(placementstatus) - - # facult and other staff view only statistics - if not (current or current1 or current2): - return redirect('/placement/statistics/') - - # delete the schedule - if 'deletesch' in request.POST: - delete_sch_key = request.POST['delete_sch_key'] - try: - placement_schedule = PlacementSchedule.objects.select_related('notify_id').get(pk = delete_sch_key) - NotifyStudent.objects.get(pk=placement_schedule.notify_id.id).delete() - placement_schedule.delete() - messages.success(request, 'Schedule Deleted Successfully') - except Exception as e: - messages.error(request, 'Problem Occurred for Schedule Delete!!!') - - # saving all the schedule details - if 'schedulesubmit' in request.POST: - form5 = AddSchedule(request.POST, request.FILES) - if form5.is_valid(): - company_name = form5.cleaned_data['company_name'] - placement_date = form5.cleaned_data['placement_date'] - location = form5.cleaned_data['location'] - ctc = form5.cleaned_data['ctc'] - time = form5.cleaned_data['time'] - attached_file = form5.cleaned_data['attached_file'] - placement_type = form5.cleaned_data['placement_type'] - role_offered = request.POST.get('role') - description = form5.cleaned_data['description'] - - try: - comp_name = CompanyDetails.objects.filter(company_name=company_name)[0] - except: - CompanyDetails.objects.create(company_name=company_name) - - try: - role = Role.objects.filter(role=role_offered)[0] - except: - role = Role.objects.create(role=role_offered) - role.save() - - - notify = NotifyStudent.objects.create(placement_type=placement_type, - company_name=company_name, - description=description, - ctc=ctc, - timestamp=timezone.now()) - - schedule = PlacementSchedule.objects.select_related('notify_id').create(notify_id=notify, - title=company_name, - description=description, - placement_date=placement_date, - attached_file = attached_file, - role=role, - location=location, time=time) - - notify.save() - schedule.save() - messages.success(request, "Schedule Added Successfull!!") - - - schedules = PlacementSchedule.objects.select_related('notify_id').all() - - - context = { - 'current': current, - 'current1': current1, - 'current2': current2, - 'schedule_tab': schedule_tab, - 'schedules': schedules, - 'placementstatus': placementstatus, - 'form5': form5, - } - - return render(request, 'placementModule/placement.html', context) - - -@login_required -def delete_invitation_status(request): - ''' - function to delete the invitation that has been sent to the students - ''' - user = request.user - strecord_tab = 1 - mnpbi_tab = 0 - mnplacement_post = 0 - mnpbi_post = 0 - invitation_status_tab = 1 - placementstatus = [] - - no_pagination = 1 - is_disabled = 0 - paginator = '' - page_range = '' - - if 'deleteinvitationstatus' in request.POST: - delete_invit_status_key = request.POST['deleteinvitationstatus'] - - try: - PlacementStatus.objects.select_related('unique_id','notify_id').get(pk=delete_invit_status_key).delete() - messages.success(request, 'Invitation Deleted Successfully') - except Exception as e: - logger.error(e) - - if 'pbi_tab_active' in request.POST: - mnpbi_tab = 1 - else: - mnplacement_tab = 1 - - form1 = SearchStudentRecord(initial={}) - form9 = ManagePbiRecord(initial={}) - form11 = ManagePlacementRecord(initial={}) - form13 = SendInvite(initial={}) - current1 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement chairman")) - current2 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement officer")) - - context = { - 'form1': form1, - 'form9': form9, - 'form11': form11, - 'form13': form13, - 'invitation_status_tab': invitation_status_tab, - 'mnplacement_post': mnplacement_post, - 'mnpbi_tab': mnpbi_tab, - 'placementstatus': placementstatus, - # 'current':current, - 'current1': current1, - 'current2': current2, - 'strecord_tab': strecord_tab, - 'mnpbi_post': mnpbi_post, - 'page_range': page_range, - 'paginator': paginator, - 'no_pagination': no_pagination, - 'is_disabled': is_disabled, - } - - return render(request, 'placementModule/studentrecords.html', context) - - -def invitation_status(request): - ''' - function to check the invitation status - ''' - user = request.user - strecord_tab = 1 - mnpbi_tab = 0 - mnplacement_post = 0 - mnpbi_post = 0 - invitation_status_tab = 1 - placementstatus_placement = [] - placementstatus_pbi = [] - mnplacement_tab = 1 - - no_pagination = 1 - is_disabled = 0 - paginator = '' - page_range = '' - placement_get_request = False - pbi_get_request = False - - # invitation status for placement - if 'studentplacementsearchsubmit' in request.POST: - mnplacement_post = 1 - mnpbi_post = 0 - form = ManagePlacementRecord(request.POST) - - if form.is_valid(): - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - else: - stuname = '' - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - if form.cleaned_data['company']: - cname = form.cleaned_data['company'] - else: - cname = '' - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - - request.session['mn_stuname'] = stuname - request.session['mn_ctc'] = ctc - request.session['mn_cname'] = cname - request.session['mn_rollno'] = rollno - - placementstatus_placement = PlacementStatus.objects.select_related('unique_id','notify_id').filter(Q(notify_id__in=NotifyStudent.objects.filter - (Q(placement_type="PLACEMENT", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))))) - # pagination stuff starts from here - total_query = placementstatus_placement.count() - - if total_query > 30: - no_pagination = 1 - paginator = Paginator(placementstatus_placement, 30) - page = request.GET.get('page', 1) - placementstatus_placement = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - else: - # when the request from pagination with some page number - if request.GET.get('placement_page') != None: - mnplacement_post = 1 - mnpbi_post = 0 - no_pagination = 1 - try: - placementstatus_placement = PlacementStatus.objects.select_related('unique_id','notify_id').filter(Q(notify_id__in=NotifyStudent.objects.filter - (Q(placement_type="PLACEMENT", - company_name__icontains=request.session['mn_cname'], - ctc__gte=request.session['mn_ctc'])), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=request.session['mn_stuname'])), - id__icontains=request.session['mn_rollno'])) - ))))) - except: - placementstatus_placement = [] - - if placementstatus_placement != '': - total_query = placementstatus_placement.count() - else: - total_query = 0 - - if total_query > 30: - paginator = Paginator(placementstatus_placement, 30) - page = request.GET.get('placement_page', 1) - placementstatus_placement = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - - # invitation status for pbi - if 'studentpbisearchsubmit' in request.POST: - mnpbi_tab = 1 - mnpbi_post = 1 - mnplacement_post = 0 - form = ManagePbiRecord(request.POST) - if form.is_valid(): - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - else: - stuname = '' - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - if form.cleaned_data['company']: - cname = form.cleaned_data['company'] - else: - cname = '' - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - request.session['mn_pbi_stuname'] = stuname - request.session['mn_pbi_ctc'] = ctc - request.session['mn_pbi_cname'] = cname - request.session['mn_pbi_rollno'] = rollno - placementstatus_pbi = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(notify_id__in=NotifyStudent.objects.filter( - Q(placement_type="PBI", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=stuname)), - id__icontains=rollno))))))).order_by('id') - - total_query = placementstatus_pbi.count() - - if total_query > 30: - no_pagination = 1 - paginator = Paginator(placementstatus_pbi, 30) - page = request.GET.get('pbi_page', 1) - placementstatus_pbi = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - else: - if request.GET.get('pbi_page') != None: - mnpbi_tab = 1 - mnpbi_post = 1 - no_pagination = 1 - try: - placementstatus_pbi = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(notify_id__in=NotifyStudent.objects.filter( - Q(placement_type="PBI", - company_name__icontains=request.session['mn_pbi_cname'], - ctc__gte=request.session['mn_pbi_ctc'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['mn_pbi_stuname'])), - id__icontains=request.session['mn_pbi_rollno'])) - ))))) - except: - placementstatus_pbi = '' - - if placementstatus_pbi != '': - total_query = placementstatus_pbi.count() - else: - total_query = 0 - if total_query > 30: - paginator = Paginator(placementstatus_pbi, 30) - page = request.GET.get('pbi_page', 1) - placementstatus_pbi = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - - - if 'pdf_gen_invitation_status' in request.POST: - - placementstatus = None - if 'pdf_gen_invitation_status_placement' in request.POST: - stuname = request.session['mn_stuname'] - ctc = request.session['mn_ctc'] - cname = request.session['mn_cname'] - rollno = request.session['mn_rollno'] - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter(Q(notify_id__in=NotifyStudent.objects.filter - (Q(placement_type="PLACEMENT", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))))) - - if 'pdf_gen_invitation_status_pbi' in request.POST: - stuname = request.session['mn_pbi_stuname'] - ctc = request.session['mn_pbi_ctc'] - cname = request.session['mn_pbi_cname'] - rollno = request.session['mn_pbi_rollno'] - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(notify_id__in=NotifyStudent.objects.filter( - Q(placement_type="PBI", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=stuname)), - id__icontains=rollno))))))).order_by('id') - - context = { - 'placementstatus' : placementstatus - } - - return render_to_pdf('placementModule/pdf_invitation_status.html', context) - - if 'excel_gen_invitation_status' in request.POST: - - placementstatus = None - if 'excel_gen_invitation_status_placement' in request.POST: - stuname = request.session['mn_stuname'] - ctc = request.session['mn_ctc'] - cname = request.session['mn_cname'] - rollno = request.session['mn_rollno'] - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter(Q(notify_id__in=NotifyStudent.objects.filter - (Q(placement_type="PLACEMENT", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))))) - - if 'excel_gen_invitation_status_pbi' in request.POST: - stuname = request.session['mn_pbi_stuname'] - ctc = request.session['mn_pbi_ctc'] - cname = request.session['mn_pbi_cname'] - rollno = request.session['mn_pbi_rollno'] - - placementstatus = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - Q(notify_id__in=NotifyStudent.objects.filter( - Q(placement_type="PBI", - company_name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=stuname)), - id__icontains=rollno))))))).order_by('id') - - context = { - 'placementstatus' : placementstatus - } - - - return export_to_xls_invitation_status(placementstatus) - - form1 = SearchStudentRecord(initial={}) - form9 = ManagePbiRecord(initial={}) - form11 = ManagePlacementRecord(initial={}) - form13 = SendInvite(initial={}) - current1 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement chairman")) - current2 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement officer")) - - context = { - 'form1': form1, - 'form9': form9, - 'form11': form11, - 'form13': form13, - 'invitation_status_tab': invitation_status_tab, - 'mnplacement_post': mnplacement_post, - 'mnpbi_tab': mnpbi_tab, - 'mnplacement_tab': mnplacement_tab, - 'placementstatus_placement': placementstatus_placement, - 'placementstatus_pbi': placementstatus_pbi, - 'current1': current1, - 'current2': current2, - 'strecord_tab': strecord_tab, - 'mnpbi_post': mnpbi_post, - 'page_range': page_range, - 'paginator': paginator, - 'no_pagination': no_pagination, - 'is_disabled': is_disabled, - } - - return render(request, 'placementModule/studentrecords.html', context) - - -@login_required -def student_records(request): - ''' - function for searching the records of student - ''' - if request.user.is_staff==True: - user = request.user - strecord_tab = 1 - no_pagination = 0 - is_disabled = 0 - paginator = '' - page_range = '' - mnplacement_tab = 1 - - form1 = SearchStudentRecord(initial={}) - form9 = ManagePbiRecord(initial={}) - form11 = ManagePlacementRecord(initial={}) - form13 = SendInvite(initial={}) - current1 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement chairman")) - current2 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement officer")) - - # querying the students details a/c to the input data - if 'recordsubmit' in request.POST: - student_record_check = 1 - form1 = SearchStudentRecord(request.POST) - if form1.is_valid(): - if form1.cleaned_data['name']: - name = form1.cleaned_data['name'] - else: - name = '' - if form1.cleaned_data['rollno']: - rollno = form1.cleaned_data['rollno'] - else: - rollno = '' - - programme = form1.cleaned_data['programme'] - - department = [] - if form1.cleaned_data['dep_btech']: - department.extend(form1.cleaned_data['dep_btech']) - if form1.cleaned_data['dep_mtech']: - department.extend(form1.cleaned_data['dep_mtech']) - if form1.cleaned_data['dep_bdes']: - department.extend(form1.cleaned_data['dep_bdes']) - if form1.cleaned_data['dep_mdes']: - department.extend(form1.cleaned_data['dep_mdes']) - if form1.cleaned_data['dep_phd']: - department.extend(form1.cleaned_data['dep_phd']) - - if form1.cleaned_data['cpi']: - cpi = form1.cleaned_data['cpi'] - else: - cpi = 0 - debar = form1.cleaned_data['debar'] - placed_type = form1.cleaned_data['placed_type'] - - request.session['name'] = name - request.session['rollno'] = rollno - request.session['programme'] = programme - request.session['department'] = department - request.session['cpi'] = str(cpi) - request.session['debar'] = debar - request.session['placed_type'] = placed_type - - - students = Student.objects.filter( - Q(id__in=ExtraInfo.objects.filter(Q( - user__in=User.objects.filter(Q(first_name__icontains=name)), - department__in=DepartmentInfo.objects.filter(Q(name__in=department)), - id__icontains=rollno)), - programme=programme, - cpi__gte=cpi)).filter(Q(pk__in=StudentPlacement.objects.filter( - Q(debar=debar, placed_type=placed_type)).values('unique_id_id'))).order_by('id') - - # pagination stuff starts from here - st = students - student_record_check= 1 - total_query = students.count() - - if total_query > 30: - no_pagination = 1 - paginator = Paginator(students, 30) - page = request.GET.get('page', 1) - students = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - else: - # when the request came from pagintion with some page no. - if request.GET.get('page') != None: - try: - students = Student.objects.filter( - Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['name']) - ), - department__in=DepartmentInfo.objects.filter( - Q(name__in=request.session['department']) - ), - id__icontains=request.session['rollno'] - ) - ), - programme=request.session['programme'], - cpi__gte=decimal.Decimal(request.session['cpi']))).filter(Q(pk__in=StudentPlacement.objects.filter(Q(debar=request.session['debar'], - placed_type=request.session['placed_type'])).values('unique_id_id'))).order_by('id') - except: - students = '' - - if students != '': - total_query = students.count() - else: - total_query = 0 - - if total_query > 30: - no_pagination = 1 - paginator = Paginator(students, 30) - page = request.GET.get('page', 1) - students = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - else: - students = '' - - if 'debar' in request.POST: - spid = request.POST['debar'] - sr = StudentPlacement.objects.get(Q(pk=spid)) - sr.debar = "DEBAR" - sr.save() - if 'undebar' in request.POST: - spid = request.POST['undebar'] - sr = StudentPlacement.objects.get(Q(pk=spid)) - sr.debar = "NOT DEBAR" - sr.save() - - # pdf generation logic - if 'pdf_gen_std_record' in request.POST: - - name = request.session['name'] - rollno = request.session['rollno'] - programme = request.session['programme'] - department = request.session['department'] - cpi = int(request.session['cpi']) - debar = request.session['debar'] - placed_type = request.session['placed_type'] - - students = Student.objects.filter( - Q(id__in=ExtraInfo.objects.filter(Q( - user__in=User.objects.filter(Q(first_name__icontains=name)), - department__in=DepartmentInfo.objects.filter(Q(name__in=department)), - id__icontains=rollno)), - programme=programme, - cpi__gte=cpi)).filter(Q(pk__in=StudentPlacement.objects.filter( - Q(debar=debar, placed_type=placed_type)).values('unique_id_id'))).order_by('id') - - context = { - 'students' : students - } - - - return render_to_pdf('placementModule/pdf_student_record.html', context) - - # excel generation logic - if 'excel_gen_std_record' in request.POST: - - name = request.session['name'] - rollno = request.session['rollno'] - programme = request.session['programme'] - department = request.session['department'] - cpi = int(request.session['cpi']) - debar = request.session['debar'] - placed_type = request.session['placed_type'] - - students = Student.objects.filter( - Q(id__in=ExtraInfo.objects.filter(Q( - user__in=User.objects.filter(Q(first_name__icontains=name)), - department__in=DepartmentInfo.objects.filter(Q(name__in=department)), - id__icontains=rollno)), - programme=programme, - cpi__gte=cpi)).filter(Q(pk__in=StudentPlacement.objects.filter( - Q(debar=debar, placed_type=placed_type)).values('unique_id_id'))).order_by('id') - - context = { - 'students' : students - } - - - return export_to_xls_std_records(students) - - - # for sending the invite to students for particular schedule - if 'sendinvite' in request.POST: - # invitecheck=1; - - form13 = SendInvite(request.POST) - - if form13.is_valid(): - if form13.cleaned_data['company']: - if form13.cleaned_data['rollno']: - rollno = form13.cleaned_data['rollno'] - else: - rollno = '' - - programme = form13.cleaned_data['programme'] - - department = [] - if form13.cleaned_data['dep_btech']: - department.extend(form13.cleaned_data['dep_btech']) - if form13.cleaned_data['dep_mtech']: - department.extend(form13.cleaned_data['dep_mtech']) - if form13.cleaned_data['dep_bdes']: - department.extend(form13.cleaned_data['dep_bdes']) - if form13.cleaned_data['dep_mdes']: - department.extend(form13.cleaned_data['dep_mdes']) - if form13.cleaned_data['dep_phd']: - department.extend(form13.cleaned_data['dep_phd']) - - - if form13.cleaned_data['cpi']: - cpi = form13.cleaned_data['cpi'] - else: - cpi = 0 - - if form13.cleaned_data['no_of_days']: - no_of_days = form13.cleaned_data['no_of_days'] - else: - no_of_days = 10 - - - comp = form13.cleaned_data['company'] - - notify = NotifyStudent.objects.get(company_name=comp.company_name, - placement_type=comp.placement_type) - - students = Student.objects.filter( - Q( - id__in = ExtraInfo.objects.filter( - Q( - department__in = DepartmentInfo.objects.filter(Q(name__in=department)), - id__icontains = rollno - ) - ), - programme = programme, - cpi__gte = cpi - ) - ).exclude(id__in = PlacementStatus.objects.select_related('unique_id','notify_id').filter( - notify_id=notify).values_list('unique_id', flat=True)) - - PlacementStatus.objects.bulk_create( [PlacementStatus(notify_id=notify, - unique_id=student, no_of_days=no_of_days) for student in students] ) - - for st in students: - placement_cell_notif(request.user, st.id.user, "") - - students = '' - messages.success(request, 'Notification Sent') - else: - messages.error(request, 'Problem Occurred!! Please Try Again!!') - - context = { - 'form1': form1, - 'form9': form9, - 'form11': form11, - 'form13': form13, - 'current1': current1, - 'current2': current2, - 'mnplacement_tab': mnplacement_tab, - 'strecord_tab': strecord_tab, - 'students': students, - 'page_range': page_range, - 'paginator': paginator, - 'no_pagination': no_pagination, - 'is_disabled': is_disabled, - } - - return render(request, 'placementModule/studentrecords.html', context) - return redirect('/placement') - - -@login_required -def manage_records(request): - ''' - function to manage the records - - can add the records under placement | pbi | higher studies - - can also search the records under placement | pbi | higher studies - ''' - user = request.user - mnrecord_tab = 1 - pagination_placement = 0 - pagination_pbi = 0 - pagination_higher = 0 - is_disabled = 0 - years = None - records = None - paginator = '' - page_range = '' - pbirecord = None - placementrecord = None - higherrecord = None - officer_statistics_past_pbi_search = 0 - officer_statistics_past_higher_search = 0 - - profile = get_object_or_404(ExtraInfo, Q(user=user)) - studentrecord = StudentRecord.objects.all() - - years = PlacementRecord.objects.filter(~Q(placement_type="HIGHER STUDIES")).values('year').annotate(Count('year')) - records = PlacementRecord.objects.values('name', 'year', 'ctc', 'placement_type').annotate(Count('name'), Count('year'), Count('placement_type'), Count('ctc')) - - - form2 = SearchPlacementRecord(initial={}) - form3 = SearchPbiRecord(initial={}) - form4 = SearchHigherRecord(initial={}) - current1 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement chairman")) - current2 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement officer")) - - current = HoldsDesignation.objects.filter(Q(working=user, designation__name="student")) - - if len(current) == 0: - current = None - - - try: - # for adding the new data for student under higher studies category - if 'studenthigheraddsubmit' in request.POST: - officer_statistics_past_higher_add = 1 - form = SearchHigherRecord(request.POST) - if form.is_valid(): - rollno = form.cleaned_data['roll'] - uname = form.cleaned_data['uname'] - test_score = form.cleaned_data['test_score'] - test_type = form.cleaned_data['test_type'] - year = form.cleaned_data['year'] - placementr = PlacementRecord.objects.create(year=year, name=uname, - placement_type="HIGHER STUDIES", - test_type=test_type, - test_score=test_score) - studentr = StudentRecord.objects.select_related('unique_id','record_id').create(record_id=placementr, - unique_id=Student.objects.get - ((Q(id=ExtraInfo.objects.get - (Q(id=rollno)))))) - studentr.save() - placementr.save() - messages.success(request, 'Record Added Successfully!!') - - # for adding the new data for student under pbi category - if 'studentpbiaddsubmit' in request.POST: - officer_statistics_past_pbi_add = 1 - form = SearchPbiRecord(request.POST) - if form.is_valid(): - rollno = form.cleaned_data['roll'] - ctc = form.cleaned_data['ctc'] - year = form.cleaned_data['year'] - cname = form.cleaned_data['cname'] - placementr = PlacementRecord.objects.create(year=year, ctc=ctc, - placement_type="PBI", - name=cname) - studentr = StudentRecord.objects.select_related('unique_id','record_id').create(record_id=placementr, - unique_id=Student.objects.get - ((Q(id=ExtraInfo.objects.get - (Q(id=rollno)))))) - studentr.save() - placementr.save() - messages.success(request, 'Record Added Successfully!!') - - # for adding the new data for student under placement category - if 'studentplacementaddsubmit' in request.POST: - officer_statistics_past_add = 1 - form = SearchPlacementRecord(request.POST) - if form.is_valid(): - rollno = form.cleaned_data['roll'] - ctc = form.cleaned_data['ctc'] - year = form.cleaned_data['year'] - cname = form.cleaned_data['cname'] - placementr = PlacementRecord.objects.create(year=year, ctc=ctc, - placement_type="PLACEMENT", - name=cname) - studentr = StudentRecord.objects.select_related('unique_id','record_id').create(record_id=placementr, - unique_id=Student.objects.get - ((Q(id=ExtraInfo.objects.get - (Q(id=rollno)))))) - studentr.save() - placementr.save() - messages.success(request, 'Record Added Successfully!!') - - # for searching the student details under placement category - if 'studentplacementrecordsubmit' in request.POST: - officer_statistics_past = 1 - form = SearchPlacementRecord(request.POST) - if form.is_valid(): - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - else: - stuname = '' - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - if form.cleaned_data['cname']: - cname = form.cleaned_data['cname'] - else: - cname = '' - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - if form.cleaned_data['year']: - year = form.cleaned_data['year'] - s = Student.objects.filter((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))) - - p = PlacementRecord.objects.filter(Q(placement_type="PLACEMENT", name__icontains=cname, ctc__gte=ctc, year=year)) - - placementrecord = StudentRecord.objects.select_related('unique_id','record_id').select_related('unique_id','record_id').filter(Q(record_id__in=PlacementRecord.objects.filter(Q(placement_type="PLACEMENT", name__icontains=cname, ctc__gte=ctc, year=year)), unique_id__in=Student.objects.filter((Q(id__in=ExtraInfo.objects.filter(Q(user__in=User.objects.filter(Q(first_name__icontains=stuname)),id__icontains=rollno))))))) - else: - s = Student.objects.filter((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))) - - p = PlacementRecord.objects.filter(Q(placement_type="PLACEMENT", name__icontains=cname, ctc__gte=ctc)) - - placementrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", name__icontains=cname, ctc__gte=ctc)), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter(Q(first_name__icontains=stuname)), - id__icontains=rollno))))))) - - request.session['stuname'] = stuname - request.session['ctc'] = ctc - request.session['cname'] = cname - request.session['rollno'] = rollno - request.session['year'] = form.cleaned_data['year'] - - total_query = placementrecord.count() - - if total_query > 30: - pagination_placement = 1 - paginator = Paginator(placementrecord, 30) - page = request.GET.get('page', 1) - placementrecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_placement = 0 - else: - if request.GET.get('page') != None: - try: - if request.session['year']: - s = Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['stuname'])), - id__icontains=request.session['rollno'])) - ))) - - p = PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'], - year=request.session['year'])) - - placementrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'], - year=request.session['year'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['stuname'])), - id__icontains=request.session['rollno']))))))) - else: - s = Student.objects.filter((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=request.session['stuname'])), - id__icontains=request.session['rollno'])) - ))) - - p = PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'])) - - placementrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['stuname'])), - id__icontains=request.session['rollno']))))))) - except: - placementrecord = '' - - if placementrecord != '': - total_query = placementrecord.count() - else: - total_query = 0 - - if total_query > 30: - pagination_placement = 1 - paginator = Paginator(placementrecord, 30) - page = request.GET.get('page', 1) - placementrecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_placement = 0 - else: - placementrecord = '' - - # for searching the student details under pbi category - if 'studentpbirecordsubmit' in request.POST: - officer_statistics_past_pbi_search = 1 - form = SearchPbiRecord(request.POST) - if form.is_valid(): - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - else: - stuname = '' - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - if form.cleaned_data['cname']: - cname = form.cleaned_data['cname'] - else: - cname = '' - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - if form.cleaned_data['year']: - year = form.cleaned_data['year'] - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter(Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="PBI", - name__icontains=cname, - ctc__gte=ctc, year=year)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))))) - else: - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter(Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="PBI", - name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))))) - request.session['stuname'] = stuname - request.session['ctc'] = ctc - request.session['cname'] = cname - request.session['rollno'] = rollno - request.session['year'] = form.cleaned_data['year'] - - total_query = pbirecord.count() - - if total_query > 30: - pagination_pbi = 1 - paginator = Paginator(pbirecord, 30) - page = request.GET.get('page', 1) - pbirecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_pbi = 0 - else: - if request.GET.get('page') != None: - try: - if request.session['year']: - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PBI", - name__icontains=request.session['cname'], - ctc__gte=ctc, year=request.session['year'])), - unique_id__in=Student.objects.filter(( - Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['stuname'])), - id__icontains=request.session['rollno']))))))) - else: - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter(Q(placement_type="PBI", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['stuname'])), - id__icontains=request.session['rollno']))))))) - except: - print('except') - pbirecord = '' - - if pbirecord != '': - total_query = pbirecord.count() - else: - total_query = 0 - - if total_query > 30: - pagination_pbi = 1 - paginator = Paginator(pbirecord, 30) - page = request.GET.get('page', 1) - pbirecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_pbi = 0 - else: - pbirecord = '' - - # for searching the student details under higher studies category - if 'studenthigherrecordsubmit' in request.POST: - officer_statistics_past_higher_search = 1 - form = SearchHigherRecord(request.POST) - if form.is_valid(): - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - else: - stuname = '' - if form.cleaned_data['test_type']: - test_type = form.cleaned_data['test_type'] - else: - test_type = '' - if form.cleaned_data['uname']: - uname = form.cleaned_data['uname'] - else: - uname = '' - if form.cleaned_data['test_score']: - test_score = form.cleaned_data['test_score'] - else: - test_score = 0 - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - if form.cleaned_data['year']: - year = form.cleaned_data['year'] - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter(Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="HIGHER STUDIES", - test_type__icontains=test_type, - name__icontains=uname, year=year, - test_score__gte=test_score)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))))) - else: - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="HIGHER STUDIES", - test_type__icontains=test_type, - name__icontains=uname, - test_score__gte=test_score)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=stuname)), - id__icontains=rollno)) - ))))) - request.session['stuname'] = stuname - request.session['test_score'] = test_score - request.session['uname'] = uname - request.session['test_type'] = test_type - request.session['rollno'] = rollno - request.session['year'] = form.cleaned_data['year'] - - total_query = higherrecord.count() - - if total_query > 30: - pagination_higher = 1 - paginator = Paginator(higherrecord, 30) - page = request.GET.get('page', 1) - higherrecord = paginator.page(page) - page = int(page) - total_page = int(page+3) - - if page < (paginator.num_pages-3): - if total_query > 30 and total_query <= 60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(page-2, paginator.num_pages+1) - else: - pagination_higher = 0 - else: - if request.GET.get('page') != None: - try: - if request.session['year']: - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="HIGHER STUDIES", - test_type__icontains=request.session['test_type'], - name__icontains=request.session['uname'], - year=request.session['year'], - test_score__gte=request.session['test_score'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['stuname'])), - id__icontains=request.session['rollno'])) - ))))) - else: - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="HIGHER STUDIES", - test_type__icontains=request.session['test_type'], - name__icontains=request.session['uname'], - test_score__gte=request.session['test_score'])), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=request.session['stuname'])), - id__icontains=request.session['rollno'])) - ))))) - except: - print('except') - higherrecord = '' - - if higherrecord != '': - total_query = higherrecord.count() - else: - total_query = 0 - - if total_query > 30: - no_pagination = 1 - paginator = Paginator(higherrecord, 30) - page = request.GET.get('page', 1) - higherrecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - else: - higherrecord = '' - - - except Exception as e: - messages.error(request, "Problem Occurred!!! Please Try Again with Correct Info!!") - print(e) - - - context = { - 'form2' : form2, - 'form3' : form3, - 'form4' : form4, - 'current' : current, - 'current1' : current1, - 'current2' : current2, - 'mnrecord_tab' : mnrecord_tab, - 'pbirecord' : pbirecord, - 'placementrecord' : placementrecord, - 'higherrecord' : higherrecord, - 'years' : years, - 'records' : records, - 'page_range': page_range, - 'paginator': paginator, - 'pagination_placement': pagination_placement, - 'pagination_pbi': pagination_pbi, - 'pagination_higher': pagination_higher, - 'is_disabled': is_disabled, - 'officer_statistics_past_pbi_search': officer_statistics_past_pbi_search, - 'officer_statistics_past_higher_search': officer_statistics_past_higher_search - } - - return render(request, 'placementModule/managerecords.html', context) - - - -@login_required -def delete_invite_status(request): - ''' - function to delete the invitation that has been sent to the students - ''' - user = request.user - strecord_tab = 1 - mnpbi_tab = 0 - mnplacement_post = 0 - mnpbi_post = 0 - invitation_status_tab = 1 - placementstatus = [] - - no_pagination = 1 - is_disabled = 0 - paginator = '' - page_range = '' - - if 'deleteinvitationstatus' in request.POST: - delete_invit_status_key = request.POST['deleteinvitationstatus'] - - try: - PlacementStatus.objects.select_related('unique_id','notify_id').get(pk=delete_invit_status_key).delete() - messages.success(request, 'Invitation Deleted Successfully') - except Exception as e: - logger.error(e) - - if 'pbi_tab_active' in request.POST: - mnpbi_tab = 1 - else: - mnplacement_tab = 1 - - form1 = SearchStudentRecord(initial={}) - form9 = ManagePbiRecord(initial={}) - form11 = ManagePlacementRecord(initial={}) - form13 = SendInvite(initial={}) - current1 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement chairman")) - current2 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement officer")) - - context = { - 'form1': form1, - 'form9': form9, - 'form11': form11, - 'form13': form13, - 'invitation_status_tab': invitation_status_tab, - 'mnplacement_post': mnplacement_post, - 'mnpbi_tab': mnpbi_tab, - 'placementstatus': placementstatus, - # 'current':current, - 'current1': current1, - 'current2': current2, - 'strecord_tab': strecord_tab, - 'mnpbi_post': mnpbi_post, - 'page_range': page_range, - 'paginator': paginator, - 'no_pagination': no_pagination, - 'is_disabled': is_disabled, - } - - return render(request, 'placementModule/studentrecords.html', context) - - - -@login_required -def placement_statistics(request): - ''' - logic of the view shown under Placement Statistics tab - ''' - user = request.user - - statistics_tab = 1 - strecord_tab=1 - delete_operation = 0 - pagination_placement = 0 - pagination_pbi = 0 - pagination_higher = 0 - is_disabled = 0 - paginator = '' - page_range = '' - officer_statistics_past_pbi_search = 0 - officer_statistics_past_higher_search = 0 - - profile = get_object_or_404(ExtraInfo, Q(user=user)) - studentrecord = StudentRecord.objects.select_related('unique_id','record_id').all() - - years = PlacementRecord.objects.filter(~Q(placement_type="HIGHER STUDIES")).values('year').annotate(Count('year')) - records = PlacementRecord.objects.values('name', 'year', 'ctc', 'placement_type').annotate(Count('name'), Count('year'), Count('placement_type'), Count('ctc')) - - - - - #working here to fetch all placement record - all_records=PlacementRecord.objects.all() - print(all_records) - - - - - - - invitecheck=0 - for r in records: - r['name__count'] = 0 - r['year__count'] = 0 - r['placement_type__count'] = 0 - tcse = dict() - tece = dict() - tme = dict() - tadd = dict() - for y in years: - tcse[y['year']] = 0 - tece[y['year']] = 0 - tme[y['year']] = 0 - for r in records: - if r['year'] == y['year']: - if r['placement_type'] != "HIGHER STUDIES": - for z in studentrecord: - if z.record_id.name == r['name'] and z.record_id.year == r['year'] and z.unique_id.id.department.name == "CSE": - tcse[y['year']] = tcse[y['year']]+1 - r['name__count'] = r['name__count']+1 - if z.record_id.name == r['name'] and z.record_id.year == r['year'] and z.unique_id.id.department.name == "ECE": - tece[y['year']] = tece[y['year']]+1 - r['year__count'] = r['year__count']+1 - if z.record_id.name == r['name'] and z.record_id.year == r['year'] and z.unique_id.id.department.name == "ME": - tme[y['year']] = tme[y['year']]+1 - r['placement_type__count'] = r['placement_type__count']+1 - tadd[y['year']] = tcse[y['year']]+tece[y['year']]+tme[y['year']] - y['year__count'] = [tadd[y['year']], tcse[y['year']], tece[y['year']], tme[y['year']]] - - form2 = SearchPlacementRecord(initial={}) - form3 = SearchPbiRecord(initial={}) - form4 = SearchHigherRecord(initial={}) - - - current1 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement chairman")) - current2 = HoldsDesignation.objects.filter(Q(working=user, designation__name="placement officer")) - current = HoldsDesignation.objects.filter(Q(working=user, designation__name="student")) - - if len(current1)!=0 or len(current2)!=0: - delete_operation = 1 - if len(current) == 0: - current = None - pbirecord= '' - placementrecord= '' - higherrecord= '' - total_query=0 - total_query1 = 0 - total_query2= 0 - p="" - p1="" - p2="" - placement_search_record=" " - pbi_search_record=" " - higher_search_record=" " - # results of the searched query under placement tab - if 'studentplacementrecordsubmit' in request.POST: - officer_statistics_past = 1 - form = SearchPlacementRecord(request.POST) - if form.is_valid(): - - - - - print("IS VALID") - - - - #for student name - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - try: - first_name = stuname.split(" ")[0] - last_name = stuname.split(" ")[1] - except Exception as e: - print("Error") - print(e) - first_name = stuname - last_name = '' - else: - stuname = '' - first_name = '' - last_name = '' - - - # for student CTC - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - - #for company name - if form.cleaned_data['cname']: - cname = form.cleaned_data['cname'] - else: - cname = '' - - #for student roll - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - - #for admission year - if form.cleaned_data['year']: - year = form.cleaned_data['year'] - s = Student.objects.filter((Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - first_name__icontains=first_name, - last_name__icontains=last_name), - id__icontains=rollno)) - ))) - - p = PlacementRecord.objects.filter(Q(placement_type="PLACEMENT",name__icontains=stuname, ctc__icontains=ctc, year__icontains=year)) - - print(p) - - - total_query = p.count() - - if total_query > 30: - pagination_placement = 1 - paginator = Paginator(placementrecord, 30) - page = request.GET.get('page', 1) - placementrecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_placement = 0 - else: - if request.GET.get('page') != None: - try: - if request.session['year']: - s = Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno'])) - ))) - - p = PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'], - year=request.session['year'])) - - - placementrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'], - year=request.session['year'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno']))))))) - else: - s = Student.objects.filter((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno'])) - ))) - - p = PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'])) - - placementrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PLACEMENT", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno']))))))) - except Exception as e: - print(e) - placementrecord = '' - - if placementrecord != '': - total_query = placementrecord.count() - else: - total_query = 0 - no_records=1 - print(placementrecord) - if total_query > 30: - pagination_placement = 1 - paginator = Paginator(placementrecord, 30) - page = request.GET.get('page', 1) - placementrecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_placement = 0 - else: - placementrecord = '' - - if total_query!=0: - placement_search_record=p - # results of the searched query under pbi tab - if 'studentpbirecordsubmit' in request.POST: - officer_statistics_past_pbi_search = 1 - form = SearchPbiRecord(request.POST) - if form.is_valid(): - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - try: - first_name = stuname.split(" ")[0] - last_name = stuname.split(" ")[1] - except: - first_name = stuname - last_name = '' - else: - stuname = '' - first_name = '' - last_name = '' - if form.cleaned_data['ctc']: - ctc = form.cleaned_data['ctc'] - else: - ctc = 0 - if form.cleaned_data['cname']: - cname = form.cleaned_data['cname'] - else: - cname = '' - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - if form.cleaned_data['year']: - year = form.cleaned_data['year'] - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter(Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="PBI", - name__icontains=cname, - ctc__gte=ctc, year=year)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=first_name, - last_name__icontains=last_name)), - id__icontains=rollno)) - ))))) - p1 = PlacementRecord.objects.filter( - Q(placement_type="PBI", name__icontains=stuname, ctc__icontains=ctc, year__icontains=year)) - """else: - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter(Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="PBI", - name__icontains=cname, - ctc__gte=ctc)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=first_name, - last_name__icontains=last_name)), - id__icontains=rollno)) - ))))) - request.session['first_name'] = first_name - request.session['last_name'] = last_name - request.session['ctc'] = ctc - request.session['cname'] = cname - request.session['rollno'] = rollno - request.session['year'] = form.cleaned_data['year'] -""" - total_query1 = p1.count() - - if total_query1 > 30: - pagination_pbi = 1 - paginator = Paginator(pbirecord, 30) - page = request.GET.get('page', 1) - pbirecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query1 > 30 and total_query1 <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_pbi = 0 - else: - if request.GET.get('page') != None: - try: - if request.session['year']: - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="PBI", - name__icontains=request.session['cname'], - ctc__gte=ctc, year=request.session['year'])), - unique_id__in=Student.objects.filter(( - Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno']))))))) - else: - pbirecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter(Q(placement_type="PBI", - name__icontains=request.session['cname'], - ctc__gte=request.session['ctc'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno']))))))) - except: - print('except') - pbirecord = '' - - if pbirecord != '': - total_query = pbirecord.count() - else: - total_query = 0 - - if total_query > 30: - pagination_pbi = 1 - paginator = Paginator(pbirecord, 30) - page = request.GET.get('page', 1) - pbirecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - pagination_pbi = 0 - else: - pbirecord = '' - if total_query1!=0: - pbi_search_record=p1 - - # results of the searched query under higher studies tab - if 'studenthigherrecordsubmit' in request.POST: - officer_statistics_past_higher_search = 1 - form = SearchHigherRecord(request.POST) - if form.is_valid(): - # getting all the variables send through form - if form.cleaned_data['stuname']: - stuname = form.cleaned_data['stuname'] - try: - first_name = stuname.split(" ")[0] - last_name = stuname.split(" ")[1] - except: - first_name = stuname - last_name = '' - else: - stuname = '' - first_name = '' - last_name = '' - if form.cleaned_data['test_type']: - test_type = form.cleaned_data['test_type'] - else: - test_type = '' - if form.cleaned_data['uname']: - uname = form.cleaned_data['uname'] - else: - uname = '' - if form.cleaned_data['test_score']: - test_score = form.cleaned_data['test_score'] - else: - test_score = 0 - if form.cleaned_data['roll']: - rollno = form.cleaned_data['roll'] - else: - rollno = '' - if form.cleaned_data['year']: - year = form.cleaned_data['year'] - # result of the query when year is given - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter(Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="HIGHER STUDIES", - test_type__icontains=test_type, - name__icontains=uname, year=year, - test_score__gte=test_score)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=first_name, - last_name__icontains=last_name)), - id__icontains=rollno)) - ))))) - - p2 = PlacementRecord.objects.filter( - Q(placement_type="HIGHER STUDIES", name__icontains=stuname, year__icontains=year)) - - """else: - # result of the query when year is not given - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter - (Q(placement_type="HIGHER STUDIES", - test_type__icontains=test_type, - name__icontains=uname, - test_score__gte=test_score)), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter - (Q(user__in=User.objects.filter - (Q(first_name__icontains=first_name, - last_name__icontains=last_name)), - id__icontains=rollno)) - ))))) - request.session['first_name'] = first_name - request.session['last_name'] = last_name - request.session['test_score'] = test_score - request.session['uname'] = uname - request.session['test_type'] = test_type - request.session['rollno'] = rollno - request.session['year'] = form.cleaned_data['year']""" - - total_query2 = p2.count() - - if total_query2 > 30: - pagination_higher = 1 - paginator = Paginator(p2, 30) - page = request.GET.get('page', 1) - p2 = paginator.page(page) - page = int(page) - total_page = int(page+3) - - if page < (paginator.num_pages-3): - if total_query2 > 30 and total_query2 <= 60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(page-2, paginator.num_pages+1) - else: - pagination_higher = 0 - else: - if request.GET.get('page') != None: - try: - if request.session['year']: - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="HIGHER STUDIES", - test_type__icontains=request.session['test_type'], - name__icontains=request.session['uname'], - year=request.session['year'], - test_score__gte=request.session['test_score'])), - unique_id__in=Student.objects.filter( - (Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno'])) - ))))) - else: - higherrecord = StudentRecord.objects.select_related('unique_id','record_id').filter( - Q(record_id__in=PlacementRecord.objects.filter( - Q(placement_type="HIGHER STUDIES", - test_type__icontains=request.session['test_type'], - name__icontains=request.session['uname'], - test_score__gte=request.session['test_score'])), - unique_id__in=Student.objects.filter - ((Q(id__in=ExtraInfo.objects.filter( - Q(user__in=User.objects.filter( - Q(first_name__icontains=request.session['first_name'], - last_name__icontains=request.session['last_name'])), - id__icontains=request.session['rollno'])) - ))))) - except: - higherrecord = '' - - if higherrecord != '': - total_query = higherrecord.count() - else: - total_query = 0 - - if total_query > 30: - no_pagination = 1 - paginator = Paginator(higherrecord, 30) - page = request.GET.get('page', 1) - higherrecord = paginator.page(page) - page = int(page) - total_page = int(page + 3) - - if page<(paginator.num_pages-3): - if total_query > 30 and total_query <=60: - page_range = range(1, 3) - else: - page_range = range(1, total_page+1) - - if page >= 5: - is_disabled = 1 - page_range = range(page-2, total_page) - else: - if page >= 5: - is_disabled = 1 - page_range = range(page-2, paginator.num_pages+1) - else: - page_range = range(1, paginator.num_pages+1) - else: - no_pagination = 0 - else: - higherrecord = '' - if total_query2!=0: - higher_search_record=p2 - - context = { - 'form2' : form2, - 'form3' : form3, - 'form4' : form4, - 'current' : current, - 'current1' : current1, - 'current2' : current2, - - - 'all_records': all_records, #for flashing all placement Schedule - - 'placement_search_record': placement_search_record, - 'pbi_search_record': pbi_search_record, - 'higher_search_record': higher_search_record, - - - - 'statistics_tab' : statistics_tab, - 'pbirecord' : pbirecord, - 'placementrecord' : placementrecord, - 'higherrecord' : higherrecord, - 'years' : years, - 'records' : records, - 'delete_operation' : delete_operation, - 'page_range': page_range, - 'paginator': paginator, - 'pagination_placement': pagination_placement, - 'pagination_pbi': pagination_pbi, - 'pagination_higher': pagination_higher, - 'is_disabled': is_disabled, - 'officer_statistics_past_pbi_search': officer_statistics_past_pbi_search, - 'officer_statistics_past_higher_search': officer_statistics_past_higher_search - } - - return render(request, 'placementModule/placementstatistics.html', context) - - -@login_required -def delete_placement_statistics(request): - """ - The function is used to delete the placement statistic record. - @param: - request - trivial - @variables: - record_id = stores current StudentRecord Id. - """ - if 'deleterecord' in request.POST or 'deleterecordmanaged' in request.POST: - try: - if 'deleterecord' in request.POST: - record_id = int(request.POST['deleterecord']) - elif 'deleterecordmanaged' in request.POST: - record_id = int(request.POST['deleterecordmanaged']) - - student_record = StudentRecord.objects.get(pk=record_id) - PlacementRecord.objects.get(id=student_record.record_id.id).delete() - student_record.delete() - messages.success(request, 'Placement Statistics deleted Successfully!!') - - except Exception as e: - messages.error(request, 'Problem Occurred!! Please Try Again!!') - print(e) - - - if 'deleterecordmanaged' in request.POST: - return redirect('/placement/manage_records/') - - return redirect('/placement/statistics/') - - -def cv(request, username): - # Retrieve data or whatever you need - """ - The function is used to generate the cv in the pdf format. - Embeds the data into the predefined template. - @param: - request - trivial - username - name of user whose cv is to be generated - @variables: - user = stores current user - profile = stores extrainfo of user - current = Stores all working students from HoldsDesignation for the respective degignation - achievementcheck = variable for achievementcheck in form for cv generation - educationcheck = variable for educationcheck in form for cv generation - publicationcheck = variable for publicationcheck in form for cv generation - patentcheck = variable for patentcheck in form for cv generation - internshipcheck = variable for internshipcheck in form for cv generation - projectcheck = variable for projectcheck in form for cv generation - coursecheck = variable for coursecheck in form for cv generation - skillcheck = variable for skillcheck in form for cv generation - user = get_object_or_404(User, Q(username=username)) - profile = get_object_or_404(ExtraInfo, Q(user=user)) - import datetime - now = stores current timestamp - roll = roll of the user - student = variable storing the profile data - studentplacement = variable storing the placement data - skills = variable storing the skills data - education = variable storing the education data - course = variable storing the course data - experience = variable storing the experience data - project = variable storing the project data - achievement = variable storing the achievement data - publication = variable storing the publication data - patent = variable storing the patent data - """ - user = request.user - profile = get_object_or_404(ExtraInfo, Q(user=user)) - - current = HoldsDesignation.objects.filter(Q(working=user, designation__name="student")) - if current: - if request.method == 'POST': - achievementcheck = request.POST.get('achievementcheck') - educationcheck = request.POST.get('educationcheck') - publicationcheck = request.POST.get('publicationcheck') - patentcheck = request.POST.get('patentcheck') - internshipcheck = request.POST.get('internshipcheck') - projectcheck = request.POST.get('projectcheck') - coursecheck = request.POST.get('coursecheck') - skillcheck = request.POST.get('skillcheck') - reference_list = request.POST.getlist('reference_checkbox_list') - extracurricularcheck = request.POST.get('extracurricularcheck') - conferencecheck = request.POST.get('conferencecheck') - else: - conferencecheck = '1' - achievementcheck = '1' - educationcheck = '1' - publicationcheck = '1' - patentcheck = '1' - internshipcheck = '1' - projectcheck = '1' - coursecheck = '1' - skillcheck = '1' - extracurricularcheck = '1' - - - - user = get_object_or_404(User, Q(username=username)) - profile = get_object_or_404(ExtraInfo, Q(user=user)) - student_info=get_object_or_404(Student,Q(id=user.username)) - - batch=student_info.batch - - now = datetime.datetime.now() - print("year----->",now.year) - if now.year-batch<=4: - roll=now.year-batch - else: - roll=4 - - - student = get_object_or_404(Student, Q(id=profile.id)) - skills = Has.objects.select_related('skill_id','unique_id').filter(Q(unique_id=student)) - education = Education.objects.select_related('unique_id').filter(Q(unique_id=student)) - reference = Reference.objects.filter(id__in=reference_list) - course = Course.objects.select_related('unique_id').filter(Q(unique_id=student)) - experience = Experience.objects.select_related('unique_id').filter(Q(unique_id=student)) - project = Project.objects.select_related('unique_id').filter(Q(unique_id=student)) - achievement = Achievement.objects.select_related('unique_id').filter(Q(unique_id=student)) - extracurricular = Extracurricular.objects.select_related('unique_id').filter(Q(unique_id=student)) - conference = Conference.objects.select_related('unique_id').filter(Q(unique_id=student)) - publication = Publication.objects.select_related('unique_id').filter(Q(unique_id=student)) - patent = Patent.objects.select_related('unique_id').filter(Q(unique_id=student)) - today = datetime.date.today() - - if len(reference) == 0: - referencecheck = '0' - else: - referencecheck = '1' - - return render_to_pdf('placementModule/cv.html', {'pagesize': 'A4', 'user': user, 'references': reference, - 'profile': profile, 'projects': project, - 'skills': skills, 'educations': education, - 'courses': course, 'experiences': experience, - 'referencecheck': referencecheck, - 'achievements': achievement, - 'extracurriculars': extracurricular, - 'publications': publication, - 'patents': patent, 'roll': roll, - 'achievementcheck': achievementcheck, - 'extracurricularcheck': extracurricularcheck, - 'educationcheck': educationcheck, - 'publicationcheck': publicationcheck, - 'patentcheck': patentcheck, - 'conferencecheck': conferencecheck, - 'conferences': conference, - 'internshipcheck': internshipcheck, - 'projectcheck': projectcheck, - 'coursecheck': coursecheck, - 'skillcheck': skillcheck, - 'today':today}) - - -def render_to_pdf(template_src, context_dict): - """ - The function is used to generate the cv in the pdf format. - Embeds the data into the predefined template. - @param: - template_src - template of cv to be rendered - context_dict - data fetched from the dtatabase to be filled in the cv template - @variables: - template - stores the template - html - html rendered pdf - result - variable to store data in BytesIO - pdf - storing encoded html of pdf version - """ - template = get_template(template_src) - html = template.render(context_dict) - result = BytesIO() - pdf = pisa.pisaDocument(BytesIO(html.encode("UTF-8")), result) - if not pdf.err: - return HttpResponse(result.getvalue(), content_type='application/pdf') - return HttpResponse('We had some errors
    %s
    ' % escape(html)) - - -def export_to_xls_std_records(qs): - """ - The function is used to generate the file in the xls format. - Embeds the data into the file. - """ - response = HttpResponse(content_type='application/ms-excel') - response['Content-Disposition'] = 'attachment; filename="report.xls"' - - wb = xlwt.Workbook(encoding='utf-8') - ws = wb.add_sheet('Report') - - row_num = 0 - - font_style = xlwt.XFStyle() - font_style.font.bold = True - - columns = ['Roll No.', 'Name', 'CPI', 'Department', 'Discipline', 'Placed', 'Debarred' ] - - for col_num in range(len(columns)): - ws.write(row_num, col_num, columns[col_num], font_style) - - font_style = xlwt.XFStyle() - - for student in qs: - row_num += 1 - - row = [] - row.append(student.id.id) - row.append(student.id.user.first_name+' '+student.id.user.last_name) - row.append(student.cpi) - row.append(student.programme) - row.append(student.id.department.name) - if student.studentplacement.placed_type == "PLACED": - row.append('Yes') - else: - row.append('No') - if student.studentplacement.placed_type == "DEBAR": - row.append('Yes') - else: - row.append('No') - - for col_num in range(len(row)): - ws.write(row_num, col_num, row[col_num], font_style) - - wb.save(response) - return response -def resume(request, username): - # Retrieve data or whatever you need - """ - The function is used to generate the cv in the pdf format. - Embeds the data into the predefined template. - @param: - request - trivial - username - name of user whose cv is to be generated - @variables: - user = stores current user - profile = stores extrainfo of user - current = Stores all working students from HoldsDesignation for the respective degignation - achievementcheck = variable for achievementcheck in form for cv generation - educationcheck = variable for educationcheck in form for cv generation - publicationcheck = variable for publicationcheck in form for cv generation - patentcheck = variable for patentcheck in form for cv generation - internshipcheck = variable for internshipcheck in form for cv generation - projectcheck = variable for projectcheck in form for cv generation - coursecheck = variable for coursecheck in form for cv generation - skillcheck = variable for skillcheck in form for cv generation - user = get_object_or_404(User, Q(username=username)) - profile = get_object_or_404(ExtraInfo, Q(user=user)) - import datetime - now = stores current timestamp - roll = roll of the user - student = variable storing the profile data - studentplacement = variable storing the placement data - skills = variable storing the skills data - education = variable storing the education data - course = variable storing the course data - experience = variable storing the experience data - project = variable storing the project data - achievement = variable storing the achievement data - publication = variable storing the publication data - patent = variable storing the patent data - """ - user = request.user - profile = get_object_or_404(ExtraInfo, Q(user=user)) - - current = HoldsDesignation.objects.filter(Q(working=user, designation__name="student")) - if current: - if request.method == 'POST': - achievementcheck = request.POST.get('achievementcheck') - educationcheck = request.POST.get('educationcheck') - publicationcheck = request.POST.get('publicationcheck') - patentcheck = request.POST.get('patentcheck') - internshipcheck = request.POST.get('internshipcheck') - projectcheck = request.POST.get('projectcheck') - coursecheck = request.POST.get('coursecheck') - skillcheck = request.POST.get('skillcheck') - reference_list = request.POST.getlist('reference_checkbox_list') - extracurricularcheck = request.POST.get('extracurricularcheck') - conferencecheck = request.POST.get('conferencecheck') - else: - conferencecheck = '1' - achievementcheck = '1' - educationcheck = '1' - publicationcheck = '1' - patentcheck = '1' - internshipcheck = '1' - projectcheck = '1' - coursecheck = '1' - skillcheck = '1' - extracurricularcheck = '1' - - - # print(achievementcheck,' ',educationcheck,' ',publicationcheck,' ',patentcheck,' ',internshipcheck,' ',projectcheck,' \n\n\n') - user = get_object_or_404(User, Q(username=username)) - profile = get_object_or_404(ExtraInfo, Q(user=user)) - now = datetime.datetime.now() - if int(str(profile.id)[:2]) == 20: - if (now.month>4): - roll = 1+now.year-int(str(profile.id)[:4]) - else: - roll = now.year-int(str(profile.id)[:4]) - else: - if (now.month>4): - roll = 1+(now.year)-int("20"+str(profile.id)[0:2]) - else: - roll = (now.year)-int("20"+str(profile.id)[0:2]) - - student = get_object_or_404(Student, Q(id=profile.id)) - skills = Has.objects.select_related('skill_id','unique_id').filter(Q(unique_id=student)) - education = Education.objects.select_related('unique_id').filter(Q(unique_id=student)) - reference = Reference.objects.filter(id__in=reference_list) - course = Course.objects.select_related('unique_id').filter(Q(unique_id=student)) - experience = Experience.objects.select_related('unique_id').filter(Q(unique_id=student)) - project = Project.objects.select_related('unique_id').filter(Q(unique_id=student)) - achievement = Achievement.objects.select_related('unique_id').filter(Q(unique_id=student)) - extracurricular = Extracurricular.objects.select_related('unique_id').filter(Q(unique_id=student)) - conference = Conference.objects.select_related('unique_id').filter(Q(unique_id=student)) - publication = Publication.objects.select_related('unique_id').filter(Q(unique_id=student)) - patent = Patent.objects.select_related('unique_id').filter(Q(unique_id=student)) - today = datetime.date.today() - - if len(reference) == 0: - referencecheck = '0' - else: - referencecheck = '1' - - return render_to_pdf('placementModule/cv.html', {'pagesize': 'A4', 'user': user, 'references': reference, - 'profile': profile, 'projects': project, - 'skills': skills, 'educations': education, - 'courses': course, 'experiences': experience, - 'referencecheck': referencecheck, - 'achievements': achievement, - 'extracurriculars': extracurricular, - 'publications': publication, - 'patents': patent, 'roll': roll, - 'achievementcheck': achievementcheck, - 'extracurricularcheck': extracurricularcheck, - 'educationcheck': educationcheck, - 'publicationcheck': publicationcheck, - 'patentcheck': patentcheck, - 'conferencecheck': conferencecheck, - 'conferences': conference, - 'internshipcheck': internshipcheck, - 'projectcheck': projectcheck, - 'coursecheck': coursecheck, - 'skillcheck': skillcheck, - 'today':today}) - - - -def export_to_xls_invitation_status(qs): - response = HttpResponse(content_type='application/ms-excel') - response['Content-Disposition'] = 'attachment; filename="report.xls"' - - wb = xlwt.Workbook(encoding='utf-8') - ws = wb.add_sheet('Report') - - - row_num = 0 - - font_style = xlwt.XFStyle() - font_style.font.bold = True - - columns = ['Roll No.', 'Name', 'Company', 'CTC', 'Invitation Status'] - - for col_num in range(len(columns)): - ws.write(row_num, col_num, columns[col_num], font_style) - - - font_style = xlwt.XFStyle() - - for student in qs: - row_num += 1 - - row = [] - row.append(student.unique_id.id.id) - row.append(student.unique_id.id.user.first_name+' '+student.unique_id.id.user.last_name) - row.append(student.notify_id.company_name) - row.append(student.notify_id.ctc) - row.append(student.invitation) - - for col_num in range(len(row)): - ws.write(row_num, col_num, row[col_num], font_style) - - wb.save(response) - return response - - -def check_invitation_date(placementstatus): - """ - The function is used to run before render of student placement view for ensuring that - last date for RESPONSE is not passed - @param: - placementstatus - queryset containing placement status of particular student - @variables: - ps - individual PlacementStatus object - """ - try: - for ps in placementstatus: - if ps.invitation=='PENDING': - dt = ps.timestamp+datetime.timedelta(days=ps.no_of_days) - if dt name, discipline, year, curriculum . diff --git a/FusionIIIT/applications/programme_curriculum/api/urls.py b/FusionIIIT/applications/programme_curriculum/api/urls.py index 489107a5a..14cf56996 100644 --- a/FusionIIIT/applications/programme_curriculum/api/urls.py +++ b/FusionIIIT/applications/programme_curriculum/api/urls.py @@ -86,6 +86,51 @@ path('admin_update_course_instructor//', views.update_course_instructor_form, name='update_course_instructor_form'), path('admin_delete_course_instructor//', views.admin_delete_course_instructor, name='admin_delete_course_instructor'), + # Thesis APIs + path('admin_theses/', views.admin_view_all_theses, name='admin_view_all_theses'), + path('admin_thesis//', views.admin_view_a_thesis, name='admin_view_a_thesis'), + path('admin_add_thesis/', views.add_thesis, name='add_thesis'), + path('admin_delete_thesis//', views.admin_delete_thesis, name='admin_delete_thesis'), + path('admin_update_thesis//', views.update_thesis, name='update_thesis'), + + # 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'), + + # 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'), + + # 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_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'), path('admin_delete_programme//', views.admin_delete_programme, name='admin_delete_programme'), diff --git a/FusionIIIT/applications/programme_curriculum/api/views.py b/FusionIIIT/applications/programme_curriculum/api/views.py index ec5f8c8be..7c2d473dc 100644 --- a/FusionIIIT/applications/programme_curriculum/api/views.py +++ b/FusionIIIT/applications/programme_curriculum/api/views.py @@ -8,11 +8,11 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User -from ..models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot,NewProposalFile,Proposal_Tracking,CourseInstructor,CourseAuditLog -from ..forms import ProgrammeForm, DisciplineForm, CurriculumForm, SemesterForm, CourseForm, BatchForm, CourseSlotForm, ReplicateCurriculumForm,NewCourseProposalFile,CourseProposalTrackingFile, CourseInstructor, CourseInstructorForm +from ..models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot,NewProposalFile,Proposal_Tracking,CourseInstructor,CourseAuditLog, Thesis, 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 +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 @@ -34,7 +34,9 @@ from notification.views import prog_and_curr_notif # from applications.academic_information.models import Student from applications.globals.models import (DepartmentInfo, Designation,ExtraInfo, Faculty, HoldsDesignation) -from applications.globals.access import IsAcadAdminOrDean, require_designation, user_holds_role, _user_from_request +from applications.globals.access import IsAcadAdminOrDean, require_designation, user_holds_role, _user_from_request, has_any_role +# acadadmin-only guard for curriculum thesis/seminar/teaching-credit mutations +IsAcadAdmin = has_any_role("acadadmin") # ------------module-functions---------------# @login_required(login_url='/accounts/login') @@ -569,6 +571,8 @@ def Admin_view_all_working_curriculums(request): 'version': str(curriculum.version), # Convert Decimal to string for JSON compatibility 'batch': [str(batch) for batch in unique_batches], # Include both primary and multi-curriculum batches 'semesters': curriculum.no_of_semester, + 'category': curriculum.programme.category, + 'programme_name': curriculum.programme.name, }) # Return the data as JSON response @@ -599,9 +603,43 @@ def admin_view_semesters_of_a_curriculum(request, curriculum_id): 'id': slot.id, 'type': slot.type, 'name': slot.name, + 'slot_type': 'course', 'courses': courses }) + # Thesis slots + for ts in ThesisSlot.objects.filter(semester=semester).order_by('id'): + theses = list(ts.theses.values('id', 'name', 'code', 'credit')) + slots.append({ + 'id': ts.id, + 'type': 'Thesis', + 'name': ts.name, + 'slot_type': 'thesis', + 'courses': theses # reuse 'courses' key for table rendering compatibility + }) + + # 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': 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) @@ -1365,57 +1403,58 @@ def add_course_form(request): if form.is_valid(): try: - new_course = form.save(commit=False) - new_course.save() - - # Handle many-to-many relationships if any - if 'disciplines' in data: - new_course.disciplines.set(data['disciplines']) - - if 'pre_requisit_courses' in data and data['pre_requisit_courses']: - new_course.pre_requisit_courses.set(data['pre_requisit_courses']) - - # Create initial audit log for course creation (only for authenticated users) - if request.user.is_authenticated and not request.user.is_anonymous: - initial_data = { - 'name': new_course.name, - 'code': new_course.code, - 'credit': new_course.credit, - 'version': new_course.version, - 'lecture_hours': new_course.lecture_hours, - 'tutorial_hours': new_course.tutorial_hours, - 'pratical_hours': new_course.pratical_hours, - 'discussion_hours': new_course.discussion_hours, - 'project_hours': new_course.project_hours, - 'pre_requisits': new_course.pre_requisits, - 'syllabus': new_course.syllabus, - 'ref_books': new_course.ref_books, - 'percent_quiz_1': new_course.percent_quiz_1, - 'percent_midsem': new_course.percent_midsem, - 'percent_quiz_2': new_course.percent_quiz_2, - 'percent_endsem': new_course.percent_endsem, - 'percent_project': new_course.percent_project, - 'percent_lab_evaluation': new_course.percent_lab_evaluation, - 'percent_course_attendance': new_course.percent_course_attendance, - 'max_seats': new_course.max_seats, - 'working_course': new_course.working_course, - } - - create_course_audit_log( - course=new_course, - user=request.user, - action='CREATE', - old_data=None, - new_data=initial_data, - version_bump_type='MAJOR', # New course creation is always major - old_version=None, - new_version=new_course.version, - admin_override=False, - reason="New course created" - ) - + with transaction.atomic(): + new_course = form.save(commit=False) + new_course.save() + + # Handle many-to-many relationships if any + if 'disciplines' in data: + new_course.disciplines.set(data['disciplines']) + + if 'pre_requisit_courses' in data and data['pre_requisit_courses']: + new_course.pre_requisit_courses.set(data['pre_requisit_courses']) + + # Create initial audit log for course creation (only for authenticated users) + if request.user.is_authenticated and not request.user.is_anonymous: + initial_data = { + 'name': new_course.name, + 'code': new_course.code, + 'credit': new_course.credit, + 'version': new_course.version, + 'lecture_hours': new_course.lecture_hours, + 'tutorial_hours': new_course.tutorial_hours, + 'pratical_hours': new_course.pratical_hours, + 'discussion_hours': new_course.discussion_hours, + 'project_hours': new_course.project_hours, + 'pre_requisits': new_course.pre_requisits, + 'syllabus': new_course.syllabus, + 'ref_books': new_course.ref_books, + 'percent_quiz_1': new_course.percent_quiz_1, + 'percent_midsem': new_course.percent_midsem, + 'percent_quiz_2': new_course.percent_quiz_2, + 'percent_endsem': new_course.percent_endsem, + 'percent_project': new_course.percent_project, + 'percent_lab_evaluation': new_course.percent_lab_evaluation, + 'percent_course_attendance': new_course.percent_course_attendance, + 'max_seats': new_course.max_seats, + 'working_course': new_course.working_course, + } + + create_course_audit_log( + course=new_course, + user=request.user, + action='CREATE', + old_data=None, + new_data=initial_data, + version_bump_type='MAJOR', # New course creation is always major + old_version=None, + new_version=new_course.version, + admin_override=False, + reason="New course created" + ) + return JsonResponse({'success': True, 'message': 'Course added successfully', 'course_id': new_course.id}, status=201) - + except Exception as save_error: return JsonResponse({'success': False, 'message': f'Error saving course: {str(save_error)}'}, status=500) else: @@ -3338,6 +3377,7 @@ def semester_details(request): data = { "curriculum_name": curriculum_name, "curriculum_version": curriculum_version, + "category": curriculum.programme.category, "semesters": semester_list, } @@ -4112,11 +4152,11 @@ def delete_batch(request, batch_id): # 🔒 STUDENT VALIDATION: Check if batch has any students try: - # Step 1: Check StudentBatchUpload table - FIRST filter by year, THEN by discipline - uploaded_students_this_year = StudentBatchUpload.objects.filter( - year=batch.year # FIRST: Only students from this academic year (e.g., 2025) - ).filter( - branch__icontains=batch.discipline.name # THEN: Only this discipline within that year + # Step 1: Check StudentBatchUpload table - Filter by year, discipline AND programme_type + uploaded_students_this_batch = StudentBatchUpload.objects.filter( + year=batch.year, # Academic year (e.g., 2025) + branch__icontains=batch.discipline.name, # Discipline (e.g., CSE, ECE) + programme_type__iexact=batch.name # Programme type (B.Tech, M.Tech, PhD, etc.) ).count() # Step 2: Check academic_information.Student table @@ -4129,14 +4169,16 @@ def delete_batch(request, batch_id): except ImportError: academic_students_this_batch = 0 - # Total = students in this year's uploads + students assigned to this specific batch - total_students = uploaded_students_this_year + academic_students_this_batch + # Total = students in this specific batch (year + discipline + programme_type) + students assigned to this batch ID + total_students = uploaded_students_this_batch + academic_students_this_batch if total_students > 0: return JsonResponse({ 'success': False, - 'message': f'Cannot delete batch "{batch.name} {batch.discipline.acronym} {batch.year}". It contains {total_students} students. Please transfer or remove students first.', + 'message': f'Cannot delete batch "{batch.name} {batch.discipline.acronym} {batch.year}". It contains {total_students} students ({uploaded_students_this_batch} uploaded, {academic_students_this_batch} assigned). Please transfer or remove students first.', 'student_count': total_students, + 'uploaded_students': uploaded_students_this_batch, + 'assigned_students': academic_students_this_batch, 'validation_error': 'batch_has_students', 'batch_info': { 'id': batch.id, @@ -4392,4 +4434,775 @@ def _json_safe(value): reason=reason ) - return audit_log \ No newline at end of file + return audit_log + + +# ============== THESIS API VIEWS ============== + +@api_view(['GET']) +def admin_view_all_theses(request): + """Returns all theses with required fields as JSON data.""" + + theses = Thesis.objects.all() + + # Prepare data for JSON response + theses_data = [ + { + "id": thesis.id, + "code": thesis.code, + "name": thesis.name, + "discipline": thesis.discipline.name, + "discipline_acronym": thesis.discipline.acronym, + "programme_type": thesis.programme_type, + "programme_type_display": thesis.get_programme_type_display(), + "credits": thesis.credit, + "working_thesis": thesis.working_thesis + } + for thesis in theses + ] + + return JsonResponse({'theses': theses_data}) + + +@api_view(['GET']) +def admin_view_a_thesis(request, thesis_id): + """View to handle the details of a Thesis as an API""" + + # Fetch the thesis based on the thesis_id + thesis = get_object_or_404(Thesis, Q(id=thesis_id)) + thesis_serializer = ThesisSerializer(thesis) + + return Response(thesis_serializer.data) + +@api_view(['POST']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated, IsAcadAdmin]) +def add_thesis(request): + """Add a new thesis""" + + try: + data = request.data + + # Validate required fields + required_fields = ['code', 'name', 'credit', 'discipline', 'programme_type'] + for field in required_fields: + if field not in data: + return JsonResponse({'error': f'{field} is required'}, status=400) + + # Get discipline + discipline = get_object_or_404(Discipline, id=data['discipline']) + + # Create thesis + thesis = Thesis.objects.create( + code=data['code'], + name=data['name'], + credit=data['credit'], + discipline=discipline, + programme_type=data['programme_type'], + working_thesis=data.get('working_thesis', True) + ) + + return JsonResponse({ + 'success': True, + 'message': 'Thesis added successfully', + 'thesis_id': thesis.id + }, status=201) + + except IntegrityError: + return JsonResponse({ + 'error': 'A thesis with this code already exists for this discipline' + }, status=400) + except Exception as e: + return JsonResponse({ + 'error': str(e) + }, status=500) + + +@api_view(['DELETE']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated, IsAcadAdmin]) +def admin_delete_thesis(request, thesis_id): + """Delete a thesis""" + + try: + thesis = get_object_or_404(Thesis, id=thesis_id) + thesis_code = thesis.code + thesis_name = thesis.name + + thesis.delete() + + return JsonResponse({ + 'success': True, + 'message': f'Thesis {thesis_code} - {thesis_name} deleted successfully' + }, status=200) + + except Exception as e: + return JsonResponse({ + 'error': str(e) + }, status=500) + + +# ------------ Seminar Views ---------------# + +@api_view(['GET']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def admin_view_all_seminars(request): + """Returns all seminars with required fields as JSON data.""" + + seminars = Seminar.objects.all() + + seminars_data = [ + { + "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 s in seminars + ] + + return JsonResponse({'seminars': seminars_data}) + + +@api_view(['POST']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated, IsAcadAdmin]) +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']) + + seminar = Seminar.objects.create( + code=data['code'], + name=data['name'], + credit=data['credit'], + discipline=discipline, + programme_type=data['programme_type'], + working_seminar=data.get('working_seminar', True) + ) + + return JsonResponse({ + 'success': True, + 'message': 'Seminar added successfully', + 'seminar_id': seminar.id + }, status=201) + + except IntegrityError: + return JsonResponse({ + 'error': 'A seminar with this code already exists for this discipline' + }, status=400) + except Exception as e: + return JsonResponse({ + 'error': str(e) + }, status=500) + + +@api_view(['DELETE']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated, IsAcadAdmin]) +def admin_delete_seminar(request, seminar_id): + """Delete a seminar""" + + try: + 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'Seminar {s_code} - {s_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, IsAcadAdmin]) +def update_thesis(request, thesis_id): + """Get thesis details for editing (GET) or update an existing thesis (PUT).""" + thesis = get_object_or_404(Thesis, id=thesis_id) + + if request.method == 'GET': + data = { + 'id': thesis.id, + 'code': thesis.code, + 'name': thesis.name, + 'credit': thesis.credit, + 'discipline': thesis.discipline.id, + 'discipline_name': thesis.discipline.name, + 'discipline_acronym': thesis.discipline.acronym, + 'programme_type': thesis.programme_type, + } + return Response(data, status=status.HTTP_200_OK) + + elif request.method == 'PUT': + try: + data = json.loads(request.body) + discipline = get_object_or_404(Discipline, id=data.get('discipline')) + + thesis.code = data.get('code', thesis.code) + thesis.name = data.get('name', thesis.name) + thesis.credit = data.get('credit', thesis.credit) + thesis.discipline = discipline + thesis.programme_type = data.get('programme_type', thesis.programme_type) + thesis.save() + + return JsonResponse({ + 'success': True, + 'message': f'Thesis {thesis.code} - {thesis.name} updated successfully', + 'thesis_id': thesis.id, + }, status=200) + + except IntegrityError: + return JsonResponse({ + 'error': 'A thesis with this code already exists for this discipline' + }, status=400) + except Exception as e: + return JsonResponse({ + 'error': str(e) + }, status=500) + + +@csrf_exempt +@api_view(['GET', 'PUT']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated, IsAcadAdmin]) +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': 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) + + elif request.method == 'PUT': + try: + data = json.loads(request.body) + discipline = get_object_or_404(Discipline, id=data.get('discipline')) + + 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'Seminar {s.code} - {s.name} updated successfully', + 'seminar_id': s.id, + }, status=200) + + except IntegrityError: + return JsonResponse({ + 'error': 'A seminar with this code already exists for this discipline' + }, status=400) + except Exception as e: + return JsonResponse({ + 'error': str(e) + }, status=500) + + +@csrf_exempt +@api_view(['POST']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated, IsAcadAdmin]) +def add_thesis_slot(request): + """Add a new thesis slot to a semester.""" + try: + data = json.loads(request.body) + + thesis_slot = ThesisSlot.objects.create( + semester_id=data['semester'], + name=data['name'], + thesis_slot_info=data.get('thesis_slot_info', ''), + duration=data.get('duration', 1), + min_registration_limit=data.get('min_registration_limit', 0), + max_registration_limit=data.get('max_registration_limit', 1000), + evaluation_type=data.get('evaluation_type', 'blocks_sx'), + ) + + if 'theses' in data and data['theses']: + thesis_slot.theses.set(data['theses']) + + return JsonResponse({ + 'status': 'success', + 'message': 'Thesis slot created successfully', + 'id': thesis_slot.id + }) + + except Exception as e: + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }, status=400) + + +@csrf_exempt +@api_view(['POST']) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated, IsAcadAdmin]) +def add_seminar_slot(request): + """Add a new seminar slot to a semester.""" + try: + data = json.loads(request.body) + + seminar_slot = SeminarSlot.objects.create( + semester_id=data['semester'], + name=data['name'], + 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 'seminars' in data and data['seminars']: + seminar_slot.seminars.set(data['seminars']) + + return JsonResponse({ + 'status': 'success', + 'message': 'Seminar slot created successfully', + 'id': seminar_slot.id + }) + + except Exception as e: + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }, status=400) + + +def admin_view_a_thesis_slot(request, thesis_slot_id): + """API to view a thesis slot""" + thesis_slot = get_object_or_404(ThesisSlot, id=thesis_slot_id) + + return JsonResponse({ + 'thesis_slot': { + 'id': thesis_slot.id, + 'name': thesis_slot.name, + 'thesis_slot_info': thesis_slot.thesis_slot_info, + 'duration': thesis_slot.duration, + 'min_registration_limit': thesis_slot.min_registration_limit, + 'max_registration_limit': thesis_slot.max_registration_limit, + 'evaluation_type': thesis_slot.evaluation_type, + 'theses': [ + { + 'id': t.id, + 'code': t.code, + 'name': t.name, + 'credit': t.credit, + } for t in thesis_slot.theses.all() + ], + 'curriculum': { + 'id': thesis_slot.semester.curriculum.id, + 'name': thesis_slot.semester.curriculum.name, + 'version': thesis_slot.semester.curriculum.version, + 'semester_no': thesis_slot.semester.semester_no, + } + }, + }) + + +def admin_view_a_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({ + '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': s.id, + 'code': s.code, + 'name': s.name, + 'credit': s.credit, + } for s in seminar_slot.seminars.all() + ], + 'curriculum': { + '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, + } + }, + }) + + +@require_designation("acadadmin") +def delete_thesis_slot(request, thesis_slot_id): + """Delete a thesis slot""" + thesis_slot = get_object_or_404(ThesisSlot, id=thesis_slot_id) + thesis_slot.delete() + return JsonResponse({'status': 'success', 'message': 'Thesis slot deleted successfully'}) + + +@require_designation("acadadmin") +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'}) + + +@require_designation("acadadmin") +def edit_thesis_slot_form(request, thesis_slot_id): + """GET returns existing thesis slot data; PUT updates it.""" + thesis_slot = get_object_or_404(ThesisSlot, id=thesis_slot_id) + curriculum_id = thesis_slot.semester.curriculum.id + + if request.method == 'GET': + data = { + 'id': thesis_slot.id, + 'semester': thesis_slot.semester.id, + 'name': thesis_slot.name, + 'thesis_slot_info': thesis_slot.thesis_slot_info, + 'theses': [t.id for t in thesis_slot.theses.all()], + 'duration': thesis_slot.duration, + 'min_registration_limit': thesis_slot.min_registration_limit, + 'max_registration_limit': thesis_slot.max_registration_limit, + 'evaluation_type': thesis_slot.evaluation_type, + 'curriculum_id': curriculum_id, + } + return JsonResponse({'status': 'success', 'thesis_slot': data}) + + elif request.method == 'PUT': + try: + data = json.loads(request.body) + form = ThesisSlotForm(data, instance=thesis_slot) + if form.is_valid(): + form.save() + return JsonResponse({ + 'status': 'success', + 'message': 'Thesis slot updated successfully', + }) + else: + return JsonResponse({'status': 'error', 'errors': form.errors}, status=400) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=500) + + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}, status=405) + + +@require_designation("acadadmin") +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': 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', 'seminar_slot': data}) + + elif request.method == 'PUT': + try: + data = json.loads(request.body) + form = SeminarSlotForm(data, instance=seminar_slot) + if form.is_valid(): + form.save() + return JsonResponse({ + 'status': 'success', + '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) + + +# ------------ 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, IsAcadAdmin]) +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, IsAcadAdmin]) +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, IsAcadAdmin]) +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, IsAcadAdmin]) +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, + } + }, + }) + + +@require_designation("acadadmin") +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'}) + + +@require_designation("acadadmin") +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/api/views_student_management.py b/FusionIIIT/applications/programme_curriculum/api/views_student_management.py index 8fd3398d7..9ef8418a0 100644 --- a/FusionIIIT/applications/programme_curriculum/api/views_student_management.py +++ b/FusionIIIT/applications/programme_curriculum/api/views_student_management.py @@ -36,7 +36,7 @@ try: from applications.programme_curriculum.models_student_management import ( - StudentBatchUpload, BatchConfiguration, StudentStatusLog + StudentBatchUpload, BatchConfiguration, StudentStatusLog, PhdStudentBatchUpload ) except ImportError: from django.db import models @@ -170,6 +170,90 @@ def _safe_int_conversion(value): pass return None +def _safe_decimal_conversion(value): + """Convert a value to Decimal, returning None for empty/invalid values.""" + if value is None or value == '' or value == 'null': + return None + try: + from decimal import Decimal, InvalidOperation + clean = str(value).replace(',', '').strip() + if not clean or clean.lower() == 'nan': + return None + return Decimal(clean) + except Exception: + return None + +def normalize_category(value, is_phd=False): + """Normalize Excel/frontend category values to DB codes. + UG/PG model uses 'GEN-EWS' and 'OBC-NCL'; PhD model uses 'EWS' and 'OBC'. + """ + if not value: + return '' + v = str(value).strip() + # Base mapping (UG/PG codes) + mapping = { + 'general': 'GEN', + 'general ews': 'GEN-EWS', + 'general-ews': 'GEN-EWS', + 'gen-ews': 'GEN-EWS', + 'economically weaker section': 'GEN-EWS', + 'ews': 'GEN-EWS', + 'obc-ncl': 'OBC-NCL', + 'other backward class': 'OBC-NCL', + 'other backward class (non-creamy layer)': 'OBC-NCL', + 'obc': 'OBC-NCL', + 'sc': 'SC', + 'scheduled caste': 'SC', + 'st': 'ST', + 'scheduled tribe': 'ST', + 'gen': 'GEN', + } + normalized = mapping.get(v.lower(), v) + # PhD choices only have 'GEN', 'OBC', 'SC', 'ST', 'EWS' — re-map the compound codes + if is_phd: + phd_remap = { + 'GEN-EWS': 'EWS', + 'OBC-NCL': 'OBC', + } + normalized = phd_remap.get(normalized, normalized) + return normalized[:10] # safety truncation to respect max_length=10 + + +def normalize_gender(value): + """Normalize Excel gender values to model choices: 'Male', 'Female', 'Other'.""" + if not value: + return '' + v = str(value).strip().lower() + if v in ('male', 'm'): + return 'Male' + if v in ('female', 'f'): + return 'Female' + return 'Other' + + +def normalize_yes_no(value, default='NO'): + """Normalize Excel YES/NO values to DB choices 'YES' / 'NO'.""" + if not value: + return default + v = str(value).strip().lower() + if v in ('yes', 'y', '1', 'true'): + return 'YES' + if v in ('no', 'n', '0', 'false'): + return 'NO' + return str(value).strip() + + +def to_academic_category(value): + """Map any raw/batch-model category value to the narrower AcademicStudent choices. + AcademicStudent.category only accepts: GEN, SC, ST, OBC + Batch models may hold: GEN-EWS, OBC-NCL, EWS — these must be remapped. + """ + normalized = normalize_category(value or '') + remap = {'GEN-EWS': 'GEN', 'OBC-NCL': 'OBC', 'EWS': 'GEN'} + result = remap.get(normalized, normalized) + return result or 'GEN' + + def parse_date_flexible(date_value): if date_value is None or date_value == '': return None @@ -218,18 +302,33 @@ def get_academic_year_from_batch_year(batch_year): def calculate_batch_filled_seats(batch): """ - Calculate filled seats for a batch using curriculum-based counting only. + Calculate filled seats for a batch. + - PhD batches: count PhdStudentBatchUpload rows matching year + semester + discipline. + - UG/PG batches: count AcademicStudent records enrolled in this batch. """ try: - from applications.academic_information.models import Student - if batch.curriculum: - curriculum_count = Student.objects.filter( - batch_id=batch - ).count() - return curriculum_count + if 'PhD' in (batch.name or ''): + from applications.programme_curriculum.models_student_management import PhdStudentBatchUpload + qs = PhdStudentBatchUpload.objects.filter(year=batch.year) + if 'Odd' in batch.name: + qs = qs.filter(admission_semester__iexact='Odd') + elif 'Even' in batch.name: + qs = qs.filter(admission_semester__iexact='Even') + if batch.discipline: + # Try full name first; fall back to acronym only if name gives 0. + # Avoids cross-matches for batches sharing the same acronym (NS-). + name_qs = qs.filter(discipline__icontains=batch.discipline.name) + if name_qs.exists(): + qs = name_qs + else: + qs = qs.filter(discipline__icontains=batch.discipline.acronym) + return qs.count() else: - return 0 - + from applications.academic_information.models import Student + if batch.curriculum: + return Student.objects.filter(batch_id=batch).count() + else: + return 0 except Exception as e: return 0 @@ -326,28 +425,45 @@ def validate_batch_curriculum_requirements(batch_year, academic_year, action_con return None -def calculate_current_semester(academic_year, current_date=None): +def calculate_current_semester(academic_year, current_date=None, admission_semester=None): + """ + Calculate the current semester number for a student. + + Convention: batch year = academic year start (e.g. 2025 = academic year 2025-26). + + Odd-semester (UG/PG/PhD Odd) — first semester starts August of batch_year. + Even-semester (PhD Even) — first semester starts January of batch_year + 1. + + Examples with batch_year=2025: + Odd: Aug 2025→Sem1, Jan 2026→Sem2, Aug 2026→Sem3 ... + Even: Jan 2026→Sem1, Aug 2026→Sem2, Jan 2027→Sem3 ... + """ if current_date is None: current_date = timezone.now().date() - + current_year = current_date.year current_month = current_date.month - - years_completed = 0 - - if current_month >= 8: - years_completed = current_year - academic_year - semester_in_year = 1 + + is_even_phd = admission_semester and str(admission_semester).strip().lower() == 'even' + + if is_even_phd: + # Even PhD: effective start = January of (batch_year + 1) + eff_year = academic_year + 1 + if current_month >= 8: # Aug–Dec → even numbered semester for them + total_semester = (current_year - eff_year) * 2 + 2 + else: # Jan–Jul → odd numbered semester for them + total_semester = (current_year - eff_year) * 2 + 1 else: - years_completed = current_year - academic_year - 1 - if current_month <= 5: + # Odd-semester (UG/PG/PhD Odd): effective start = August of batch_year + if current_month >= 8: # Aug–Dec → Odd semester in progress + years_completed = current_year - academic_year + semester_in_year = 1 + else: # Jan–Jul → Even semester in progress + years_completed = current_year - academic_year - 1 semester_in_year = 2 - else: - semester_in_year = 2 - - total_semester = (years_completed * 2) + semester_in_year - total_semester = max(1, min(total_semester, 8)) - + total_semester = (years_completed * 2) + semester_in_year + + total_semester = max(1, min(total_semester, 12)) return total_semester @csrf_exempt @@ -451,7 +567,14 @@ def process_excel_upload(request): 'admission_mode': ['admission mode', 'admission type'], 'admission_mode_remarks': ['admission mode remarks', 'admission type remarks'], 'income_group': ['income group', 'family income group'], - 'income': ['income', 'family income', 'annual income'] + 'income': ['income', 'family income', 'annual income'], + # PhD-specific columns + 'application_no': ['application no.', 'application no', 'application number', 'phd application no', 'phd app no'], + 'gate_qualified': ['gate qualaified', 'gate qualified', 'gate qualification'], + 'gate_stream': ['gate stream'], + 'gate_rank': ['gate rank'], + 'admission_type': ['admission type'], + 'admission_semester': ['admission semester', 'semester of admission'], } df.columns = df.columns.str.lower().str.strip() @@ -564,7 +687,15 @@ def process_excel_upload(request): 'Admission Mode': student_data.get('admission_mode', ''), 'Admission Mode Remarks': student_data.get('admission_mode_remarks', ''), 'Income Group': student_data.get('income_group', ''), - 'Income': student_data.get('income', '') + 'Income': student_data.get('income', ''), + # PhD-specific fields + 'Application No.': student_data.get('application_no', ''), + 'Admission Type': student_data.get('admission_type', ''), + 'GATE Qualaified': student_data.get('gate_qualified', ''), + 'GATE Qualified': student_data.get('gate_qualified', ''), + 'GATE Stream': student_data.get('gate_stream', ''), + 'GATE Rank': student_data.get('gate_rank', ''), + 'Admission Semester': student_data.get('admission_semester', ''), } valid_students.append(cleaned_data) @@ -595,9 +726,12 @@ def process_excel_upload(request): # STUDENT BATCH OPERATIONS # ============================================================================= -@csrf_exempt -@require_http_methods(["POST"]) -def check_student_duplicate(student, duplicate_check_fields): +def check_student_duplicate(student, duplicate_check_fields, programme_type='ug'): + """ + Check for duplicate students. + For PhD students, checks PhdStudentBatchUpload. + For UG/PG students, checks StudentBatchUpload. + """ field_mapping = { 'jeeAppNo': 'jee_app_no', @@ -608,6 +742,8 @@ def check_student_duplicate(student, duplicate_check_fields): 'mobile': 'mobile_number' } + is_phd = (programme_type == 'phd') + try: for field in duplicate_check_fields: backend_field = field_mapping.get(field, field.lower()) @@ -617,17 +753,24 @@ def check_student_duplicate(student, duplicate_check_fields): continue if backend_field == 'jee_app_no': - existing = StudentBatchUpload.objects.filter(jee_app_no=student_value).first() - if existing: - return True, f"JEE Application Number {student_value} already exists for {existing.name}" + if not is_phd: # PhD students don't have JEE app numbers + existing = StudentBatchUpload.objects.filter(jee_app_no=student_value).first() + if existing: + return True, f"JEE Application Number {student_value} already exists for {existing.name}" elif backend_field == 'roll_number': - existing = StudentBatchUpload.objects.filter(roll_number=student_value).first() + if is_phd: + existing = PhdStudentBatchUpload.objects.filter(roll_number=student_value).first() + else: + existing = StudentBatchUpload.objects.filter(roll_number=student_value).first() if existing: return True, f"Roll Number {student_value} already exists for {existing.name}" elif backend_field == 'institute_email': - existing = StudentBatchUpload.objects.filter(institute_email=student_value).first() + if is_phd: + existing = PhdStudentBatchUpload.objects.filter(institute_email=student_value).first() + else: + existing = StudentBatchUpload.objects.filter(institute_email=student_value).first() if existing: return True, f"Institute Email {student_value} already exists for {existing.name}" @@ -655,6 +798,7 @@ def save_students_batch(request): data = json.loads(request.body) students = data.get('students', []) programme_type = data.get('programme_type', 'ug') + phd_semester = data.get('phd_semester', None) # Get PhD semester (odd/even) year_result = validate_and_normalize_year(data.get('academic_year')) if isinstance(year_result, JsonResponse): return year_result @@ -690,7 +834,7 @@ def save_students_batch(request): duplicate_students = [] for student in students: - is_duplicate, duplicate_info = check_student_duplicate(student, duplicate_check_fields) + is_duplicate, duplicate_info = check_student_duplicate(student, duplicate_check_fields, programme_type) if is_duplicate: skipped_duplicates += 1 @@ -735,8 +879,20 @@ def save_students_batch(request): discipline_name = student_data.get('Discipline') or student_data.get('branch', '') specialization = student_data.get('Specialization') or student_data.get('specialization', '') + # Debug PhD semester + if programme_type == 'phd': + print(f"DEBUG: programme_type={programme_type}, phd_semester={phd_semester}, type={type(phd_semester)}") + + # For PhD students, use semester-specific batch names + if programme_type == 'phd' and phd_semester: + if phd_semester.lower() == 'odd': + batch_name = 'PhD (Odd)' + elif phd_semester.lower() == 'even': + batch_name = 'PhD (Even)' + else: + batch_name = get_batch_name_from_discipline(discipline_name, programme_type) # For M.Tech students, use specialization-specific batch names - if programme_type == 'pg' and specialization: + elif programme_type == 'pg' and specialization: if 'design' in discipline_name.lower(): batch_name = 'M.Des' elif specialization == 'Mechatronics': @@ -762,6 +918,10 @@ def save_students_batch(request): else: discipline_obj = get_or_create_discipline(discipline_name) + # Debug logging for PhD batch matching + if programme_type == 'phd': + print(f"DEBUG PhD: batch_name={batch_name}, discipline={discipline_obj.name}, year={batch_year}") + try: batch_obj = Batch.objects.get( name=batch_name, @@ -769,7 +929,14 @@ def save_students_batch(request): year=batch_year, running_batch=True ) + if programme_type == 'phd': + print(f"DEBUG PhD: Found batch {batch_obj.id}") except Batch.DoesNotExist: + if programme_type == 'phd': + print(f"DEBUG PhD: Batch not found! Looking for alternatives...") + all_phd_batches = Batch.objects.filter(name__icontains='phd', year=batch_year, running_batch=True) + print(f"DEBUG PhD: Available PhD batches: {[(b.id, b.name, b.discipline.name) for b in all_phd_batches]}") + name_year_matches = Batch.objects.filter(name=batch_name, year=batch_year, running_batch=True) discipline_year_matches = Batch.objects.filter(discipline=discipline_obj, year=batch_year, running_batch=True) existing_batches = Batch.objects.filter(year=batch_year, running_batch=True) @@ -787,21 +954,25 @@ def save_students_batch(request): continue student_name = student_data.get('Name') or student_data.get('name', 'Unknown') - - student_upload = StudentBatchUpload.objects.create( - # Core identification - handle both field name formats - jee_app_no=student_data.get('JEE App. No./CCMT Roll. No.') or student_data.get('JEE App. No / CCMT Roll No') or student_data.get('Jee Main Application Number') or student_data.get('jee_app_no') or student_data.get('jeeAppNo') or None, + + # Normalise admission_semester from the phd_semester param or data + _adm_sem_raw = ( + phd_semester if phd_semester else + student_data.get('Admission Semester') or + student_data.get('admission_semester') or + student_data.get('admissionSemester', '') or '' + ) + _adm_sem = _adm_sem_raw.strip().capitalize() if _adm_sem_raw else '' + + # Common kwargs shared by both PhD and UG/PG models + _common = dict( roll_number=student_data.get('Institute Roll Number') or student_data.get('rollNumber', ''), - institute_email=student_data.get('Institute Email ID') or student_data.get('instituteEmail', ''), - name=student_data.get('Name') or student_data.get('name', ''), - father_name=student_data.get("Father's Name") or student_data.get('fname', ''), - mother_name=student_data.get("Mother's Name") or student_data.get('mname', ''), - gender=student_data.get('Gender') or student_data.get('gender', ''), - category=student_data.get('Category') or student_data.get('category', ''), - pwd=student_data.get('PWD') or student_data.get('pwd', 'NO'), + institute_email=student_data.get('Institute Email ID') or student_data.get('instituteEmail', ''), + gender=normalize_gender(student_data.get('Gender') or student_data.get('gender', '')), + category=normalize_category(student_data.get('Category') or student_data.get('category', ''), is_phd=(programme_type == 'phd')), + pwd=normalize_yes_no(student_data.get('PWD') or student_data.get('pwd', ''), default='NO'), minority=student_data.get('Minority') or student_data.get('minority', ''), - phone_number=sanitize_phone_number(student_data.get('Mobile No') or student_data.get('phoneNumber', '')), personal_email=student_data.get('Alternate Email ID') or student_data.get('email', '') or student_data.get('alternateEmail', '') or student_data.get('personalEmail', '') or student_data.get('personal_email', ''), parent_email=student_data.get('Parent Email') or student_data.get('parentEmail', '') or student_data.get('parent_email', ''), @@ -816,29 +987,48 @@ def save_students_batch(request): admission_mode=student_data.get('Admission Mode') or student_data.get('admissionMode', ''), admission_mode_remarks=student_data.get('Admission Mode Remarks') or student_data.get('admissionModeRemarks', ''), income_group=student_data.get('Income Group') or student_data.get('incomeGroup', ''), - income=student_data.get('Income') or student_data.get('income', None), - - branch=student_data.get('Discipline') or student_data.get('branch', ''), - specialization=student_data.get('Specialization') or student_data.get('specialization', ''), - date_of_birth=dob, - ai_rank=_safe_int_conversion(sanitize_rank_value(student_data.get('AI rank') or student_data.get('jeeRank'))), - category_rank=_safe_int_conversion(sanitize_rank_value(student_data.get('Category Rank') or student_data.get('categoryRank'))), - + income=_safe_decimal_conversion(student_data.get('Income') or student_data.get('income') or None), + father_name=student_data.get("Father's Name") or student_data.get('fname', ''), + mother_name=student_data.get("Mother's Name") or student_data.get('mname', ''), father_occupation=student_data.get("Father's Occupation") or student_data.get('fatherOccupation', ''), father_mobile=sanitize_phone_number(student_data.get('Father Mobile Number') or student_data.get('fatherMobile', '')), mother_occupation=student_data.get("Mother's Occupation") or student_data.get('motherOccupation', ''), mother_mobile=sanitize_phone_number(student_data.get('Mother Mobile Number') or student_data.get('motherMobile', '')), - - allotted_category=student_data.get('allottedcat') or student_data.get('allottedCategory', ''), - allotted_gender=student_data.get('Allotted Gender') or student_data.get('allottedGender', ''), - + date_of_birth=dob, year=batch_year, - academic_year=academic_year, # Use the normalized academic year - programme_type=programme_type, + academic_year=academic_year, reported_status='NOT_REPORTED', - source='excel_upload', # Track that this came from Excel upload - uploaded_by=request.user if request.user.is_authenticated else None + source='excel_upload', + uploaded_by=request.user if request.user.is_authenticated else None, ) + + if programme_type == 'phd': + student_upload = PhdStudentBatchUpload.objects.create( + discipline=discipline_name, + application_no=student_data.get('Application No.') or student_data.get('applicationNo') or None, + admission_type=student_data.get('Admission Type') or student_data.get('admissionType', ''), + gate_qualified=normalize_yes_no(student_data.get('GATE Qualified') or student_data.get('GATE Qualaified') or student_data.get('gateQualified', ''), default='NO'), + gate_stream=student_data.get('GATE Stream') or student_data.get('gateStream', ''), + gate_rank=_safe_int_conversion(sanitize_rank_value(student_data.get('GATE Rank') or student_data.get('gateRank'))), + category_rank=_safe_int_conversion(sanitize_rank_value(student_data.get('Category Rank') or student_data.get('categoryRank'))), + allotted_category=student_data.get('allottedcat') or student_data.get('allottedCategory', ''), + allotted_gender=student_data.get('Allotted Gender') or student_data.get('allottedGender', ''), + admission_semester=_adm_sem, + **_common + ) + else: + student_upload = StudentBatchUpload.objects.create( + jee_app_no=student_data.get('JEE App. No./CCMT Roll. No.') or student_data.get('JEE App. No / CCMT Roll No') or student_data.get('Jee Main Application Number') or student_data.get('jee_app_no') or student_data.get('jeeAppNo') or None, + branch=student_data.get('Discipline') or student_data.get('branch', ''), + specialization=student_data.get('Specialization') or student_data.get('specialization', ''), + ai_rank=_safe_int_conversion(sanitize_rank_value(student_data.get('AI rank') or student_data.get('jeeRank'))), + category_rank=_safe_int_conversion(sanitize_rank_value(student_data.get('Category Rank') or student_data.get('categoryRank'))), + allotted_category=student_data.get('allottedcat') or student_data.get('allottedCategory', ''), + allotted_gender=student_data.get('Allotted Gender') or student_data.get('allottedGender', ''), + admission_semester=_adm_sem, + programme_type=programme_type, + **_common + ) # AUTOMATIC USER ACCOUNT CREATION with HASHED PASSWORD if student_upload.roll_number: @@ -867,7 +1057,8 @@ def save_students_batch(request): total_processed = successful_uploads + failed_uploads + validation_errors + skipped_invalid response_data = { - 'success': True, + 'success': successful_uploads > 0, + 'error_detail': errors[0] if errors and successful_uploads == 0 else None, 'data': { 'successful_uploads': successful_uploads, 'failed_uploads': failed_uploads, @@ -1035,6 +1226,7 @@ def add_single_student(request): try: data = json.loads(request.body) programme_type = data.get('programme_type', 'ug') + phd_semester = data.get('phd_semester', None) # Get PhD semester (odd/even) year_result = validate_and_normalize_year(data.get('academic_year')) if isinstance(year_result, JsonResponse): @@ -1075,6 +1267,13 @@ def add_single_student(request): 'admissionMode': 'admission_mode', 'admissionModeRemarks': 'admission_mode_remarks', 'incomeGroup': 'income_group', + # PhD-specific field name mappings + 'applicationNo': 'application_no', + 'admissionType': 'admission_type', + 'gateQualified': 'gate_qualified', + 'gateStream': 'gate_stream', + 'gateRank': 'gate_rank', + 'admissionSemester': 'admission_semester', } mapped_data = {} @@ -1087,7 +1286,13 @@ def add_single_student(request): data = mapped_data - required_fields = ['name', 'father_name', 'mother_name', 'branch', 'gender', 'category', 'pwd', 'address'] + # Required fields differ by programme type: + # PhD does not require father/mother names or address (adult graduate admissions) + if programme_type == 'phd': + required_fields = ['name', 'branch', 'gender', 'category', 'pwd'] + else: + required_fields = ['name', 'father_name', 'mother_name', 'branch', 'gender', 'category', 'pwd', 'address'] + missing_fields = [field for field in required_fields if not data.get(field)] if missing_fields: @@ -1105,77 +1310,126 @@ def add_single_student(request): }, status=400) student_data = processed_students[0] + dob = parse_date_flexible(data.get('date_of_birth')) + + # Build shared kwargs used by both PhD and UG/PG models + _shared_kwargs = dict( + name=student_data.get('name') or data.get('name', ''), + roll_number=student_data.get('roll_number') or data.get('rollNumber', '') or data.get('roll_number', ''), + institute_email=student_data.get('institute_email') or data.get('instituteEmail', '') or data.get('institute_email', ''), + father_name=data.get('father_name', ''), + mother_name=data.get('mother_name', ''), + gender=normalize_gender(data.get('gender', '')), + category=normalize_category(data.get('category', ''), is_phd=(programme_type == 'phd')), + pwd=normalize_yes_no(data.get('pwd', ''), default='NO'), + minority=data.get('minority', ''), + date_of_birth=dob or None, + phone_number=sanitize_phone_number(data.get('phone_number', '') or data.get('MobileNo', '')), + personal_email=data.get('personal_email', '') or data.get('email', '') or data.get('alternateEmail', ''), + parent_email=data.get('parent_email', '') or data.get('parentEmail', ''), + address=data.get('address', '') or student_data.get('address', ''), + state=data.get('state', ''), + country=data.get('country', 'India'), + nationality=data.get('nationality', 'Indian'), + blood_group=data.get('blood_group', ''), + blood_group_remarks=data.get('blood_group_remarks', ''), + pwd_category=data.get('pwd_category', ''), + pwd_category_remarks=data.get('pwd_category_remarks', ''), + income_group=data.get('income_group', ''), + income=_safe_decimal_conversion(data.get('income') or None), + father_occupation=data.get('father_occupation', ''), + father_mobile=sanitize_phone_number(data.get('father_mobile', '')), + mother_occupation=data.get('mother_occupation', ''), + mother_mobile=sanitize_phone_number(data.get('mother_mobile', '')), + allotted_category=data.get('allotted_category', ''), + allotted_gender=data.get('allotted_gender', ''), + year=batch_year, + academic_year=academic_year, + reported_status='NOT_REPORTED', + allocation_status='ALLOCATED', + source='manual_entry', + ) - jee_app_no = data.get('jee_app_no') - if jee_app_no: - existing_student = StudentBatchUpload.objects.filter(jee_app_no=jee_app_no).first() - if existing_student: + if programme_type == 'phd': + # --- PhD path: save to PhdStudentBatchUpload --- + application_no = data.get('application_no') or None + if application_no: + existing_phd = PhdStudentBatchUpload.objects.filter(application_no=application_no).first() + if existing_phd: + return JsonResponse({ + 'success': False, + 'message': f'Student with Application Number {application_no} already exists (Roll Number: {existing_phd.roll_number})' + }, status=400) + + phd_semester_val = '' + if phd_semester: + phd_semester_val = phd_semester.strip().capitalize() + elif data.get('admission_semester'): + phd_semester_val = str(data['admission_semester']).strip().capitalize() + + if phd_semester_val not in ['Odd', 'Even']: return JsonResponse({ 'success': False, - 'message': f'Student with JEE Application Number {jee_app_no} already exists (Roll Number: {existing_student.roll_number})' + 'message': 'PhD semester (Odd or Even) is required for manual student entry', + 'validation_error': 'missing_phd_semester' }, status=400) - dob = parse_date_flexible(data.get('date_of_birth')) - - # Save to database with ALL Excel-equivalent fields for complete synchronization - with transaction.atomic(): - student = StudentBatchUpload.objects.create( - name=student_data.get('name'), - jee_app_no=student_data.get('jee_app_no'), - roll_number=student_data.get('roll_number'), - institute_email=student_data.get('institute_email'), - - father_name=student_data.get('father_name'), - mother_name=student_data.get('mother_name'), - gender=student_data.get('gender'), - category=student_data.get('category'), - pwd=student_data.get('pwd'), - minority=data.get('minority', ''), - date_of_birth=dob or data.get('date_of_birth'), - - phone_number=sanitize_phone_number(data.get('phone_number', '') or data.get('MobileNo', '')), - personal_email=data.get('personal_email', '') or data.get('email', '') or data.get('alternateEmail', '') or data.get('Alternate Email ID', ''), - parent_email=data.get('parent_email', '') or data.get('parentEmail', ''), - address=student_data.get('address'), - state=data.get('state', '') or data.get('State', ''), - country=data.get('country', 'India'), - nationality=data.get('nationality', 'Indian'), - blood_group=data.get('blood_group', '') or data.get('bloodGroup', ''), - blood_group_remarks=data.get('blood_group_remarks', '') or data.get('bloodGroupRemarks', ''), - pwd_category=data.get('pwd_category', '') or data.get('pwdCategory', ''), - pwd_category_remarks=data.get('pwd_category_remarks', '') or data.get('pwdCategoryRemarks', ''), - admission_mode=data.get('admission_mode', '') or data.get('admissionMode', ''), - admission_mode_remarks=data.get('admission_mode_remarks', '') or data.get('admissionModeRemarks', ''), - income_group=data.get('income_group', '') or data.get('incomeGroup', ''), - income=data.get('income', None), - - father_occupation=data.get('father_occupation', '') or data.get("Father's Occupation", ''), - father_mobile=sanitize_phone_number(data.get('father_mobile', '') or data.get('Father Mobile Number', '')), - mother_occupation=data.get('mother_occupation', '') or data.get("Mother's Occupation", ''), - mother_mobile=sanitize_phone_number(data.get('mother_mobile', '') or data.get('Mother Mobile Number', '')), + with transaction.atomic(): + student = PhdStudentBatchUpload.objects.create( + discipline=student_data.get('branch') or data.get('discipline', ''), + application_no=application_no, + admission_type=data.get('admission_type', ''), + gate_qualified=normalize_yes_no(data.get('gate_qualified', ''), default='NO'), + gate_stream=data.get('gate_stream', ''), + gate_rank=_safe_int_conversion(sanitize_rank_value(data.get('gate_rank'))), + admission_semester=phd_semester_val, + admission_mode=data.get('admission_mode', ''), + admission_mode_remarks=data.get('admission_mode_remarks', ''), + **_shared_kwargs + ) + # Auto-create user account (consistent with Excel upload path) + if student.roll_number: + try: + student.create_user_account() + except Exception: + pass + else: + # --- UG / PG path: save to StudentBatchUpload --- + jee_app_no = data.get('jee_app_no') + if jee_app_no: + existing_student = StudentBatchUpload.objects.filter(jee_app_no=jee_app_no).first() + if existing_student: + return JsonResponse({ + 'success': False, + 'message': f'Student with JEE Application Number {jee_app_no} already exists (Roll Number: {existing_student.roll_number})' + }, status=400) + with transaction.atomic(): + student = StudentBatchUpload.objects.create( + jee_app_no=jee_app_no or None, branch=student_data.get('branch'), specialization=data.get('specialization', ''), - ai_rank=sanitize_rank_value(data.get('ai_rank') or data.get('AI rank')), - category_rank=sanitize_rank_value(data.get('category_rank') or data.get('Category Rank')), - - allotted_category=data.get('allotted_category', '') or data.get('allottedcat', ''), - allotted_gender=data.get('allotted_gender', '') or data.get('allottedGender', ''), - - year=batch_year, + ai_rank=_safe_int_conversion(sanitize_rank_value(data.get('ai_rank') or data.get('AI rank'))), + category_rank=_safe_int_conversion(sanitize_rank_value(data.get('category_rank') or data.get('Category Rank'))), + admission_mode=data.get('admission_mode', ''), + admission_mode_remarks=data.get('admission_mode_remarks', ''), + admission_semester=str(data.get('admission_semester', '') or '').strip().capitalize(), programme_type=programme_type, - reported_status='NOT_REPORTED', - academic_year=academic_year, - allocation_status='ALLOCATED', - source='manual_entry' + **_shared_kwargs ) + # Auto-create user account (consistent with Excel upload path) + if student.roll_number: + try: + student.create_user_account() + except Exception: + pass return JsonResponse({ 'success': True, 'data': { 'student_id': student.id, - 'roll_number': student.roll_number or student_data.get('roll_number'), - 'institute_email': student.institute_email or student_data.get('institute_email'), + 'roll_number': student.roll_number, + 'institute_email': student.institute_email, 'name': student.name, 'personal_email': student.personal_email, 'parent_email': student.parent_email, @@ -1271,6 +1525,7 @@ def update_student_status(request): student_id = data.get('studentId') reported_status = data.get('reportedStatus') + programme_type_hint = (data.get('programmeType') or data.get('programme_type') or '').lower() if not student_id or not reported_status: return JsonResponse({ @@ -1285,13 +1540,30 @@ def update_student_status(request): 'message': 'Invalid reportedStatus. Must be REPORTED, NOT_REPORTED, or PENDING' }, status=400) - try: - student = StudentBatchUpload.objects.get(id=student_id) - except StudentBatchUpload.DoesNotExist: - return JsonResponse({ - 'success': False, - 'message': 'Student not found' - }, status=404) + is_phd_student = False + # If caller explicitly indicates a PhD student, skip the UG/PG table lookup to + # avoid collisions between StudentBatchUpload.id and PhdStudentBatchUpload.id. + if programme_type_hint == 'phd': + try: + student = PhdStudentBatchUpload.objects.get(id=student_id) + is_phd_student = True + except PhdStudentBatchUpload.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': 'Student not found' + }, status=404) + else: + try: + student = StudentBatchUpload.objects.get(id=student_id) + except StudentBatchUpload.DoesNotExist: + try: + student = PhdStudentBatchUpload.objects.get(id=student_id) + is_phd_student = True + except PhdStudentBatchUpload.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': 'Student not found' + }, status=404) old_status = student.reported_status student.reported_status = reported_status @@ -1356,10 +1628,21 @@ def update_student_status(request): dept_name = 'Design' # Exact database name discipline_name = 'Design' discipline_acronym = 'Des.' # Use existing acronym + elif 'NATURAL SCIENCE' in branch_upper or 'NS-' in branch_upper: + dept_name = 'Natural Science' + discipline_name = branch_field # Keep exact name for Discipline lookup + elif 'LIBERAL ARTS' in branch_upper or 'LA-' in branch_upper: + dept_name = 'Natural Science' # Closest available dept; no dedicated LA dept + discipline_name = branch_field # Keep exact name for Discipline lookup else: - # Default fallback - dept_name = 'CSE' - discipline_name = 'Computer Science and Engineering' + if is_phd_student: + # For any unrecognised PhD discipline, keep the exact string for Discipline lookup + dept_name = 'Natural Science' + discipline_name = branch_field + else: + # Default fallback for UG/PG + dept_name = 'CSE' + discipline_name = 'Computer Science and Engineering' try: department = DepartmentInfo.objects.get(name=dept_name) @@ -1453,16 +1736,32 @@ def update_student_status(request): dept_name = 'Design' # Exact database name discipline_name = 'Design' discipline_acronym = 'Des.' # Use existing acronym + elif 'NATURAL SCIENCE' in branch_upper or 'NS-' in branch_upper: + dept_name = 'Natural Science' + discipline_name = branch_field + elif 'LIBERAL ARTS' in branch_upper or 'LA-' in branch_upper: + dept_name = 'Natural Science' + discipline_name = branch_field else: - dept_name = 'CSE' - discipline_name = 'Computer Science and Engineering' - - if dept_name == 'Design': + if is_phd_student: + dept_name = 'Natural Science' + discipline_name = branch_field + else: + dept_name = 'CSE' + discipline_name = 'Computer Science and Engineering' + + if is_phd_student: + # PhD: PhdStudentBatchUpload.discipline stores the exact Discipline.name, + # so a direct lookup is the most reliable path. + discipline = Discipline.objects.filter(name__iexact=branch_field).first() + if not discipline: + discipline = Discipline.objects.filter(name=branch_field).first() + elif dept_name == 'Design': discipline = Discipline.objects.filter( acronym='Des.', name='Design' ).first() - + if not discipline: discipline = Discipline.objects.filter(name__icontains='design').first() else: @@ -1470,7 +1769,7 @@ def update_student_status(request): acronym=dept_name, name__exact=discipline_name ).first() - + if not discipline: disciplines = Discipline.objects.filter(acronym=dept_name).order_by('name') discipline = disciplines.first() @@ -1583,8 +1882,30 @@ def update_student_status(request): batch_obj = None batch_created = False + # For PhD students, match batch by admission_semester (Odd or Even) + if programme_category == 'PhD' and student.admission_semester: + if student.admission_semester.strip().lower() in ['odd', 'even']: + semester_capitalized = student.admission_semester.strip().capitalize() + batch_name = f'PhD ({semester_capitalized})' + batch_obj = Batch.objects.filter( + name=batch_name, + year=student.year, + discipline=discipline, + running_batch=True + ).first() + + if not batch_obj: + return JsonResponse({ + 'success': False, + 'message': f'No batch found for {batch_name} {discipline.name} Year-{student.year}. Please create the required batch first.', + 'error_code': 'BATCH_NOT_FOUND', + 'required_batch': batch_name, + 'discipline': discipline.name, + 'year': student.year + }, status=400) + # For PG students with specialization, only use existing batches - if programme_category == 'PG' and student.specialization and student_specific_curriculum: + elif programme_category == 'PG' and student.specialization and student_specific_curriculum: if student.specialization == 'Design': batch_obj = Batch.objects.filter( name=programme_name, # M.Des @@ -1686,7 +2007,10 @@ def update_student_status(request): transfer_message_addition = transfer_message_addition if 'transfer_message_addition' in locals() else "" - current_semester = calculate_current_semester(int(student.year)) + # For PhD students, pass admission_semester so Even-semester + # admits get the correct (+1) offset in semester calculation. + _admission_sem = getattr(student, 'admission_semester', None) + current_semester = calculate_current_semester(int(student.year), admission_semester=_admission_sem) # USE EXISTING BATCH (NO AUTOMATIC BATCH CREATION) final_batch = batch_obj @@ -1709,7 +2033,7 @@ def update_student_status(request): 'batch': student.year, 'father_name': student.father_name or '', 'mother_name': student.mother_name or '', - 'category': student.category or '', + 'category': to_academic_category(student.category), 'cpi': 0.0, 'curr_semester_no': current_semester, 'hall_no': 0, @@ -1725,7 +2049,7 @@ def update_student_status(request): academic_student.batch = student.year academic_student.father_name = student.father_name or '' academic_student.mother_name = student.mother_name or '' - academic_student.category = student.category or '' + academic_student.category = to_academic_category(student.category) academic_student.curr_semester_no = current_semester from applications.academic_information.models import Constants valid_choices = [choice[0] for choice in Constants.MTechSpecialization] @@ -2711,6 +3035,17 @@ def get_available_curriculums_for_batch(batch_obj): return [] +def get_batch_category(batch_name): + """ + Infer a batch's programme category (UG/PG/PHD) from its BATCH_NAMES value, + e.g. 'B.Tech' -> UG, 'M.Tech AI & ML' -> PG, 'PhD (Odd)' -> PHD. + """ + if batch_name.startswith('B.'): + return 'UG' + if batch_name.startswith('PhD'): + return 'PHD' + return 'PG' + def get_batch_curriculum_display(batch_obj): """ Get curriculum display information for a batch @@ -2975,10 +3310,17 @@ def get_or_create_discipline(discipline_name): normalized_name = discipline_name.strip() discipline_lower = normalized_name.lower() + # Mapping of variations to canonical names discipline_mapping = { 'computer science and engineering': 'Computer Science and Engineering', - 'electronics and communication engineering': 'Electronics and Communication Engineering', + 'cse': 'Computer Science and Engineering', + 'computer science': 'Computer Science and Engineering', + 'electronics and communication engineering': 'Electronics and Communication Engineering', + 'ece': 'Electronics and Communication Engineering', + 'electronics and communication': 'Electronics and Communication Engineering', 'mechanical engineering': 'Mechanical Engineering', + 'me': 'Mechanical Engineering', + 'mechanical': 'Mechanical Engineering', 'smart manufacturing': 'Smart Manufacturing', 'design': 'Design', 'mechatronics': 'Mechatronics' @@ -3061,16 +3403,12 @@ def create_or_update_main_student_record(student_data, batch_obj, batch_year): extra_info.date_of_birth = dob extra_info.save() - category_mapping = { - 'General': 'GEN', - 'GEN': 'GEN', - 'OBC': 'OBC', - 'SC': 'SC', - 'ST': 'ST' - } - category = category_mapping.get(student_data.get('Category', 'GEN'), 'GEN') + # Normalize raw Excel category to AcademicStudent valid choices (GEN, SC, ST, OBC) + category = to_academic_category(student_data.get('Category') or student_data.get('category', '')) - current_semester = calculate_current_semester(batch_year) + # Pass admission_semester for PhD Even-semester admits + _admission_sem = student_data.get('admission_semester') or student_data.get('Admission Semester') + current_semester = calculate_current_semester(batch_year, admission_semester=_admission_sem) academic_student, created = AcademicStudent.objects.get_or_create( id=extra_info, @@ -3221,9 +3559,30 @@ def update_student(request, student_id): Update a student by ID """ try: - student = StudentBatchUpload.objects.get(id=student_id) data = json.loads(request.body) - old_discipline = student.branch + programme_type_hint = (data.get('programmeType') or data.get('programme_type') or '').lower() + + is_phd = False + if programme_type_hint == 'phd': + try: + student = PhdStudentBatchUpload.objects.get(id=student_id) + is_phd = True + except PhdStudentBatchUpload.DoesNotExist: + return JsonResponse({'success': False, + 'message': f'PhD Student with ID {student_id} not found'}, status=404) + else: + try: + student = StudentBatchUpload.objects.get(id=student_id) + except StudentBatchUpload.DoesNotExist: + # Fallback: check PhdStudentBatchUpload (in case programmeType was not sent) + try: + student = PhdStudentBatchUpload.objects.get(id=student_id) + is_phd = True + except PhdStudentBatchUpload.DoesNotExist: + return JsonResponse({'success': False, + 'message': f'Student with ID {student_id} not found'}, status=404) + + old_discipline = student.discipline if is_phd else student.branch discipline_changed = False field_mapping = { @@ -3291,22 +3650,26 @@ def update_student(request, student_id): roll_number = data.get('rollNumber') or data.get('roll_number') institute_email = data.get('instituteEmail') or data.get('institute_email') + # Use the correct model and field for uniqueness checks + _dup_model = PhdStudentBatchUpload if is_phd else StudentBatchUpload + _app_field = 'application_no' if is_phd else 'jee_app_no' + if jee_app_no and jee_app_no != student.jee_app_no: - if StudentBatchUpload.objects.filter(jee_app_no=jee_app_no).exclude(id=student_id).exists(): + if _dup_model.objects.filter(**{_app_field: jee_app_no}).exclude(id=student_id).exists(): return JsonResponse({ 'success': False, - 'message': f'JEE Application Number {jee_app_no} already exists for another student' + 'message': f'Application Number {jee_app_no} already exists for another student' }, status=400) if roll_number and roll_number != student.roll_number: - if StudentBatchUpload.objects.filter(roll_number=roll_number).exclude(id=student_id).exists(): + if _dup_model.objects.filter(roll_number=roll_number).exclude(id=student_id).exists(): return JsonResponse({ 'success': False, 'message': f'Roll Number {roll_number} already exists for another student' }, status=400) if institute_email and institute_email != student.institute_email: - if StudentBatchUpload.objects.filter(institute_email=institute_email).exclude(id=student_id).exists(): + if _dup_model.objects.filter(institute_email=institute_email).exclude(id=student_id).exists(): return JsonResponse({ 'success': False, 'message': f'Institute Email {institute_email} already exists for another student' @@ -3315,8 +3678,13 @@ def update_student(request, student_id): for frontend_field, backend_field in field_mapping.items(): if frontend_field in data: value = data[frontend_field] - if hasattr(student, backend_field): - setattr(student, backend_field, value) + # For PhD: jee_app_no is a read-only compat property; write to application_no + actual_field = 'application_no' if (is_phd and backend_field == 'jee_app_no') else backend_field + if hasattr(student, actual_field): + try: + setattr(student, actual_field, value) + except AttributeError: + pass direct_fields = [ 'name', 'gender', 'category', 'pwd', 'minority', 'address', 'state', 'branch', 'specialization', @@ -3327,9 +3695,25 @@ def update_student(request, student_id): for field in direct_fields: if field in data: + # For PhD: 'branch' and 'specialization' are read-only compat properties. + # Map 'branch' → 'discipline'; skip 'specialization'. + if is_phd and field == 'specialization': + continue + actual_field = 'discipline' if (is_phd and field == 'branch') else field if field == 'branch' and data[field] != old_discipline: discipline_changed = True - setattr(student, field, data[field]) + value = data[field] + # Normalize choice fields to match DB expectations + if field == 'gender': + value = normalize_gender(value) + elif field == 'category': + value = normalize_category(value, is_phd=is_phd) + elif field == 'pwd': + value = normalize_yes_no(value, default='NO') + try: + setattr(student, actual_field, value) + except AttributeError: + pass dob_value = data.get('dob') or data.get('dateOfBirth') or data.get('date_of_birth') if dob_value: @@ -3345,7 +3729,7 @@ def update_student(request, student_id): pass jee_rank_value = data.get('jeeRank') or data.get('aiRank') or data.get('ai_rank') - if jee_rank_value: + if jee_rank_value and not is_phd: # PhD uses gate_rank, not ai_rank try: sanitized_rank = sanitize_rank_value(jee_rank_value) student.ai_rank = int(sanitized_rank.replace(',', '')) if sanitized_rank.replace(',', '').isdigit() else None @@ -3382,7 +3766,13 @@ def update_student(request, student_id): setattr(student, field, None) except (ValueError, TypeError): setattr(student, field, None) - + + # income is a DecimalField; an empty string (sent when the field is left + # blank in the edit form) fails Django's decimal validation on save(), + # unlike the rank_fields above which already normalize '' to None. + if 'income' in data: + student.income = _safe_decimal_conversion(data.get('income')) + # Update timestamp if field exists if hasattr(student, 'updated_at'): student.updated_at = timezone.now() @@ -3495,7 +3885,26 @@ def delete_student(request, student_id): from django.contrib.auth.models import User from django.db import transaction - student = StudentBatchUpload.objects.get(id=student_id) + is_phd = False + programme_type_hint = request.GET.get('programme_type', '').lower() + if programme_type_hint == 'phd': + try: + student = PhdStudentBatchUpload.objects.get(id=student_id) + is_phd = True + except PhdStudentBatchUpload.DoesNotExist: + return JsonResponse({'success': False, + 'message': f'PhD Student with ID {student_id} not found'}, status=404) + else: + try: + student = StudentBatchUpload.objects.get(id=student_id) + except StudentBatchUpload.DoesNotExist: + # Fallback: also check PhdStudentBatchUpload + try: + student = PhdStudentBatchUpload.objects.get(id=student_id) + is_phd = True + except PhdStudentBatchUpload.DoesNotExist: + return JsonResponse({'success': False, + 'message': f'Student with ID {student_id} not found'}, status=404) student_name = student.name student_roll = student.roll_number @@ -3917,26 +4326,35 @@ def get_batch_students(request, batch_id): specialization = request.GET.get('specialization') discipline = request.GET.get('discipline') - students = StudentBatchUpload.objects.filter( - year=batch.year, - programme_type=programme_type - ) - - # Apply disciplinary filtering based on programme type - if programme_type == 'pg': + if programme_type == 'phd': + # Primary store: PhdStudentBatchUpload + phd_qs = PhdStudentBatchUpload.objects.filter(year=batch.year) + if 'Odd' in batch.name: + phd_qs = phd_qs.filter(admission_semester__iexact='Odd') + elif 'Even' in batch.name: + phd_qs = phd_qs.filter(admission_semester__iexact='Even') + if batch.discipline: + # Try full discipline name first; fall back to acronym. + # This handles Excel uploads that store short codes ('ECE', 'ME') + # while avoiding cross-matches for batches with shared acronyms + # (e.g. NS- is used by both NS-MATHS and NS-PHSYICS). + name_qs = phd_qs.filter(discipline__icontains=batch.discipline.name) + if name_qs.exists(): + phd_qs = name_qs + else: + phd_qs = phd_qs.filter(discipline__icontains=batch.discipline.acronym) + students = sorted(list(phd_qs), key=lambda s: s.roll_number or '') + elif programme_type == 'pg': + students = StudentBatchUpload.objects.filter(year=batch.year, programme_type=programme_type) if specialization: students = students.filter(specialization__icontains=specialization) else: - discipline_name = batch.discipline.name discipline_filters = Q() - discipline_filters |= Q(branch__icontains=discipline_name) - if 'Engineering' in discipline_name: typo_name = discipline_name.replace('Engineering', 'Enginnering') discipline_filters |= Q(branch__icontains=typo_name) - if 'Computer Science' in discipline_name: discipline_filters |= Q(branch__icontains='CSE') discipline_filters |= Q(branch__icontains='Computer Science') @@ -3946,21 +4364,17 @@ def get_batch_students(request, batch_id): elif 'Mechanical' in discipline_name: discipline_filters |= Q(branch__icontains='ME') discipline_filters |= Q(branch__icontains='Mechanical') - students = students.filter(discipline_filters) + students = students.order_by('roll_number') else: + students = StudentBatchUpload.objects.filter(year=batch.year, programme_type=programme_type) discipline_name = batch.discipline.name discipline_filters = Q() - discipline_filters |= Q(branch__icontains=discipline_name) - if 'Engineering' in discipline_name: typo_name = discipline_name.replace('Engineering', 'Enginnering') discipline_filters |= Q(branch__icontains=typo_name) - - students = students.filter(discipline_filters) - - students = students.order_by('roll_number') + students = students.filter(discipline_filters).order_by('roll_number') # Read-only mirror of the assigned Student.section, keyed by roll number. roll_numbers = [s.roll_number for s in students if s.roll_number] @@ -3980,7 +4394,20 @@ def get_batch_students(request, batch_id): 'name': student.name, 'roll_number': student.roll_number, 'institute_email': student.institute_email, - 'jee_app_no': student.jee_app_no, + 'jee_app_no': getattr(student, 'jee_app_no', None), + + # PhD-specific fields (safe getattr; empty/None for UG/PG students) + 'application_no': getattr(student, 'application_no', ''), + 'admission_type': getattr(student, 'admission_type', ''), + 'gate_qualified': getattr(student, 'gate_qualified', ''), + 'gateQualified': getattr(student, 'gate_qualified', ''), + 'gate_stream': getattr(student, 'gate_stream', ''), + 'gateStream': getattr(student, 'gate_stream', ''), + 'gate_rank': getattr(student, 'gate_rank', None), + 'gateRank': getattr(student, 'gate_rank', None), + 'admission_semester': getattr(student, 'admission_semester', ''), + 'admissionSemester': getattr(student, 'admission_semester', ''), + 'discipline': getattr(student, 'discipline', ''), 'father_name': student.father_name, 'mother_name': getattr(student, 'mother_name', ''), @@ -4190,7 +4617,17 @@ def admin_batches_unified(request): students = StudentBatchUpload.objects.filter( year=batch.year, branch__icontains=batch.discipline.name - ).order_by('roll_number') + ) + + # For PhD batches, also filter by admission_semester (case-insensitive) + programme_type = 'phd' if batch.name.upper().startswith('PHD') else 'pg' if batch.name.startswith('M.') else 'ug' + if programme_type == 'phd': + if 'Odd' in batch.name: + students = students.filter(admission_semester__iexact='Odd') + elif 'Even' in batch.name: + students = students.filter(admission_semester__iexact='Even') + + students = students.order_by('roll_number') for student in students: students_data.append({ @@ -4302,30 +4739,40 @@ def sync_batch_data(request): sync_results = [] + # Only B.Tech/B.Des batches match these historical UG cohort sizes - + # applying them to PG/PhD batches (much smaller cohorts) would wildly + # overstate available seats, so the fallback below is UG-only. + ug_default_seats = { + 'CSE': 300, + 'ECE': 120, + 'ME': 80, + 'SM': 80, + 'Des.': 80, + } + for batch in all_batches: # Use centralized filled seats calculation function actual_filled = calculate_batch_filled_seats(batch) + batch_category = get_batch_category(batch.name) if hasattr(batch, 'total_seats') and batch.total_seats: available_seats = max(0, batch.total_seats - actual_filled) else: - - default_seats = { - 'CSE': 300, - 'ECE': 120, - 'ME': 80, - 'SM': 80, - 'Des.': 80, - } - batch.total_seats = default_seats.get(batch.discipline.acronym, 100) + if batch_category == 'UG': + batch.total_seats = ug_default_seats.get(batch.discipline.acronym, 100) + else: + # No discipline-specific PG/PhD seat data exists yet; + # fall back to the Batch model's own generic default. + batch.total_seats = Batch._meta.get_field('total_seats').default batch.save() available_seats = max(0, batch.total_seats - actual_filled) curriculum_display = get_batch_curriculum_display(batch) - + sync_results.append({ 'batch_id': batch.id, 'name': batch.name, + 'category': batch_category, 'discipline': batch.discipline.acronym, 'discipline_name': batch.discipline.name, 'year': batch.year, @@ -4364,24 +4811,36 @@ def validate_batch_prerequisites(request): try: data = json.loads(request.body) academic_year = data.get('academic_year') # e.g., 2025 + requested_disciplines = data.get('disciplines') or [] if not academic_year: return JsonResponse({ 'success': False, 'message': 'Academic year is required' }, status=400) + + if isinstance(requested_disciplines, str): + requested_disciplines = [requested_disciplines] + + requested_disciplines = [ + str(discipline).strip() + for discipline in requested_disciplines + if str(discipline).strip() + ] from applications.programme_curriculum.models import Batch, Discipline - required_disciplines = ['Computer Science and Engineering', 'Electronics and Communication Engineering', - 'Mechanical Engineering', 'Smart Manufacturing', 'Design'] + default_required_disciplines = ['Computer Science and Engineering', 'Electronics and Communication Engineering', + 'Mechanical Engineering', 'Smart Manufacturing', 'Design'] + + required_disciplines = requested_disciplines or default_required_disciplines missing_batches = [] existing_batches = [] for discipline_name in required_disciplines: try: - discipline = Discipline.objects.filter(name=discipline_name).first() + discipline = Discipline.objects.filter(name__iexact=discipline_name).first() if discipline: batch = Batch.objects.filter( year=academic_year, @@ -4404,6 +4863,12 @@ def validate_batch_prerequisites(request): 'acronym': discipline.acronym, 'action_required': f'Create {discipline.acronym} batch for year {academic_year}' }) + else: + missing_batches.append({ + 'discipline': discipline_name, + 'acronym': discipline_name, + 'action_required': f'Create {discipline_name} batch for year {academic_year}' + }) except Exception as e: pass @@ -5023,7 +5488,7 @@ def transfer_student_to_academic_system(student): current_semester=calculate_current_semester(student.academic_year), current_year=student.academic_year, cpi=0.0, - category=student.category or 'General', + category=to_academic_category(student.category), father_name=student.father_name or '', mother_name=student.mother_name or '', hall_no=0, diff --git a/FusionIIIT/applications/programme_curriculum/forms.py b/FusionIIIT/applications/programme_curriculum/forms.py index 4037e6e17..e99d41d1a 100644 --- a/FusionIIIT/applications/programme_curriculum/forms.py +++ b/FusionIIIT/applications/programme_curriculum/forms.py @@ -3,7 +3,7 @@ from django.forms import ModelForm, widgets from django.forms import Form, ValidationError from django.forms.models import ModelChoiceField -from .models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot, PROGRAMME_CATEGORY_CHOICES,NewProposalFile,Proposal_Tracking, CourseInstructor +from .models import Programme, Discipline, Curriculum, Semester, Course, Batch, CourseSlot, PROGRAMME_CATEGORY_CHOICES,NewProposalFile,Proposal_Tracking, CourseInstructor, Thesis, 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) @@ -456,3 +456,93 @@ class Meta: # user_id = cleaned_data.get('receive_id') # if user_id: # self.fields['receive_design'].queryset = HoldsDesignation.objects.select_related('designation').filter(user_id=user_id) + + +class ThesisForm(ModelForm): + class Meta: + model = Thesis + fields = '__all__' + widgets = { + 'code': forms.TextInput(attrs={'placeholder': 'Thesis Code (e.g., CS799)', 'max_length': 10}), + 'name': forms.TextInput(attrs={'placeholder': 'Thesis Name', 'max_length': 100}), + 'credit': forms.NumberInput(attrs={'placeholder': 'Credits'}), + 'discipline': forms.Select(attrs={'class': 'ui fluid search selection dropdown'}), + 'programme_type': forms.Select(attrs={'class': 'ui fluid search selection dropdown'}), + 'working_thesis': forms.CheckboxInput(attrs={'class': 'ui checkbox'}), + } + + +class SeminarForm(ModelForm): + class Meta: + model = Seminar + fields = '__all__' + widgets = { + '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_seminar': forms.CheckboxInput(attrs={'class': 'ui checkbox'}), + } + + +class ThesisSlotForm(ModelForm): + class Meta: + model = ThesisSlot + fields = '__all__' + widgets = { + 'semester': forms.Select(attrs={'class': 'ui fluid search selection dropdown'}), + 'name': forms.TextInput(attrs={'placeholder': 'Name/Code', 'max_length': 100}), + 'type': forms.Select(attrs={'class': 'ui fluid search selection dropdown'}), + 'thesis_slot_info': forms.Textarea(attrs={'placeholder': 'Enter Information about this Thesis Slot'}), + 'theses': forms.SelectMultiple(attrs={'class': 'ui fluid search selection dropdown'}), + 'duration': forms.NumberInput(attrs={'placeholder': 'Semester Duration'}), + 'min_registration_limit': forms.NumberInput(attrs={'placeholder': 'Min Reg limit'}), + 'max_registration_limit': forms.NumberInput(attrs={'placeholder': 'Max Reg limit'}), + } + + +class SeminarSlotForm(ModelForm): + class Meta: + 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'}), + '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/0032_add_phd_admission_semester.py b/FusionIIIT/applications/programme_curriculum/migrations/0032_add_phd_admission_semester.py new file mode 100644 index 000000000..09a99ef52 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0032_add_phd_admission_semester.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0031_add_curriculum_options_to_batch'), + ] + + operations = [ + migrations.AddField( + model_name='studentbatchupload', + name='admission_semester', + field=models.CharField(blank=True, choices=[('Odd', 'Odd Semester'), ('Even', 'Even Semester')], help_text='For PhD students: Semester in which admission occurred (becomes their Semester 1)', max_length=10, null=True), + ), + migrations.AlterField( + model_name='batch', + name='name', + field=models.CharField(choices=[('B.Tech', 'B.Tech'), ('M.Tech', 'M.Tech'), ('M.Tech AI & ML', 'M.Tech AI & ML'), ('M.Tech Data Science', 'M.Tech Data Science'), ('M.Tech Communication and Signal Processing', 'M.Tech Communication and Signal Processing'), ('M.Tech Nanoelectronics and VLSI Design', 'M.Tech Nanoelectronics and VLSI Design'), ('M.Tech Power & Control', 'M.Tech Power & Control'), ('M.Tech Design', 'M.Tech Design'), ('M.Tech CAD/CAM', 'M.Tech CAD/CAM'), ('M.Tech Manufacturing and Automation', 'M.Tech Manufacturing and Automation'), ('B.Des', 'B.Des'), ('M.Des', 'M.Des'), ('Phd', 'Phd')], max_length=50), + ), + migrations.AlterField( + model_name='studentbatchupload', + name='jee_app_no', + field=models.CharField(blank=True, help_text='JEE App. No./CCMT Roll. No.', max_length=50, null=True, unique=True), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0033_auto_20260210_1723.py b/FusionIIIT/applications/programme_curriculum/migrations/0033_auto_20260210_1723.py new file mode 100644 index 000000000..e9a3f5d72 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0033_auto_20260210_1723.py @@ -0,0 +1,49 @@ +# Generated by Django 3.1.5 on 2026-02-10 17:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0032_add_phd_admission_semester'), + ] + + operations = [ + migrations.AlterField( + model_name='batch', + name='name', + field=models.CharField(choices=[('B.Tech', 'B.Tech'), ('M.Tech', 'M.Tech'), ('M.Tech AI & ML', 'M.Tech AI & ML'), ('M.Tech Data Science', 'M.Tech Data Science'), ('M.Tech Communication and Signal Processing', 'M.Tech Communication and Signal Processing'), ('M.Tech Nanoelectronics and VLSI Design', 'M.Tech Nanoelectronics and VLSI Design'), ('M.Tech Power & Control', 'M.Tech Power & Control'), ('M.Tech Design', 'M.Tech Design'), ('M.Tech CAD/CAM', 'M.Tech CAD/CAM'), ('M.Tech Manufacturing and Automation', 'M.Tech Manufacturing and Automation'), ('B.Des', 'B.Des'), ('M.Des', 'M.Des'), ('PhD (Odd)', 'PhD (Odd)'), ('PhD (Even)', 'PhD (Even)')], max_length=50), + ), + migrations.AlterField( + model_name='batch', + name='year', + field=models.PositiveIntegerField(default=2026), + ), + migrations.AlterField( + model_name='courseinstructor', + name='year', + field=models.IntegerField(default=2026), + ), + migrations.AlterField( + model_name='programme', + name='programme_begin_year', + field=models.PositiveIntegerField(default=2026), + ), + migrations.CreateModel( + name='Thesis', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=10, unique=True)), + ('name', models.CharField(max_length=100)), + ('credit', models.PositiveIntegerField(default=0)), + ('programme_type', models.CharField(choices=[('PG', 'Postgraduate'), ('PHD', 'Doctor of Philosophy')], max_length=3)), + ('working_thesis', models.BooleanField(default=True)), + ('discipline', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.discipline')), + ], + options={ + 'unique_together': {('code', 'discipline')}, + }, + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0034_progressseminar.py b/FusionIIIT/applications/programme_curriculum/migrations/0034_progressseminar.py new file mode 100644 index 000000000..ec939cc29 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0034_progressseminar.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.5 on 2026-02-11 15:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0033_auto_20260210_1723'), + ] + + operations = [ + migrations.CreateModel( + name='ProgressSeminar', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=10, unique=True)), + ('name', models.CharField(max_length=100)), + ('credit', models.PositiveIntegerField(default=0)), + ('programme_type', models.CharField(choices=[('PG', 'Postgraduate'), ('PHD', 'Doctor of Philosophy')], max_length=3)), + ('working_progress_seminar', models.BooleanField(default=True)), + ('discipline', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.discipline')), + ], + options={ + 'unique_together': {('code', 'discipline')}, + }, + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0035_progressseminarslot_thesisslot.py b/FusionIIIT/applications/programme_curriculum/migrations/0035_progressseminarslot_thesisslot.py new file mode 100644 index 000000000..f46b5fa9a --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0035_progressseminarslot_thesisslot.py @@ -0,0 +1,48 @@ +# Generated by Django 3.1.5 on 2026-02-11 16:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0034_progressseminar'), + ] + + operations = [ + migrations.CreateModel( + name='ThesisSlot', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('type', models.CharField(choices=[('Professional Core', 'Professional Core'), ('Professional Elective', 'Professional Elective'), ('Professional Lab', 'Professional Lab'), ('Engineering Science', 'Engineering Science'), ('Natural Science', 'Natural Science'), ('Humanities', 'Humanities'), ('Design', 'Design'), ('Manufacturing', 'Manufacturing'), ('Management Science', 'Management Science'), ('Open Elective', 'Open Elective'), ('Swayam', 'Swayam'), ('Project', 'Project'), ('Optional', 'Optional'), ('Backlog', 'Backlog'), ('Others', 'Others')], max_length=70)), + ('thesis_slot_info', models.TextField(blank=True, null=True)), + ('duration', models.PositiveIntegerField(default=1)), + ('min_registration_limit', models.PositiveIntegerField(default=0)), + ('max_registration_limit', models.PositiveIntegerField(default=1000)), + ('semester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.semester')), + ('theses', models.ManyToManyField(blank=True, to='programme_curriculum.Thesis')), + ], + options={ + 'unique_together': {('semester', 'name', 'type')}, + }, + ), + migrations.CreateModel( + name='ProgressSeminarSlot', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('type', models.CharField(choices=[('Professional Core', 'Professional Core'), ('Professional Elective', 'Professional Elective'), ('Professional Lab', 'Professional Lab'), ('Engineering Science', 'Engineering Science'), ('Natural Science', 'Natural Science'), ('Humanities', 'Humanities'), ('Design', 'Design'), ('Manufacturing', 'Manufacturing'), ('Management Science', 'Management Science'), ('Open Elective', 'Open Elective'), ('Swayam', 'Swayam'), ('Project', 'Project'), ('Optional', 'Optional'), ('Backlog', 'Backlog'), ('Others', 'Others')], max_length=70)), + ('progress_seminar_slot_info', models.TextField(blank=True, null=True)), + ('duration', models.PositiveIntegerField(default=1)), + ('min_registration_limit', models.PositiveIntegerField(default=0)), + ('max_registration_limit', models.PositiveIntegerField(default=1000)), + ('progress_seminars', models.ManyToManyField(blank=True, to='programme_curriculum.ProgressSeminar')), + ('semester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.semester')), + ], + options={ + 'unique_together': {('semester', 'name', 'type')}, + }, + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0036_phd_student_batch_upload.py b/FusionIIIT/applications/programme_curriculum/migrations/0036_phd_student_batch_upload.py new file mode 100644 index 000000000..99deb34bb --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0036_phd_student_batch_upload.py @@ -0,0 +1,200 @@ +from django.conf import settings +from django.db import migrations, models +import applications.programme_curriculum.models_student_management +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0035_progressseminarslot_thesisslot'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PhdStudentBatchUpload', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + # Core identification + ('application_no', models.CharField( + blank=True, max_length=50, null=True, unique=True, + help_text='PhD Application Number (col: Application No.)' + )), + ('roll_number', models.CharField( + blank=True, max_length=20, null=True, unique=True, + help_text='Institute Roll Number' + )), + ('institute_email', models.EmailField( + blank=True, max_length=254, null=True, + help_text='Institute Email ID' + )), + # Personal + ('name', models.CharField(max_length=200, help_text='Full Name')), + ('discipline', models.CharField(max_length=200, help_text='Discipline / Branch')), + ('admission_type', models.CharField( + blank=True, max_length=100, null=True, + choices=[ + ('FULL TIME with Institute Assistantship', 'FULL TIME with Institute Assistantship'), + ('FULL TIME with Govt. / Semi Govt. Fellowship Award', 'FULL TIME with Govt. / Semi Govt. Fellowship Award'), + ('FULL TIME Self Financed', 'FULL TIME Self Financed'), + ('PART TIME (External)', 'PART TIME (External)'), + ('QIP', 'QIP'), + ('Any other (remarks)', 'Any other (remarks)'), + ], + help_text='Admission Type' + )), + ('gender', models.CharField( + max_length=10, + choices=[('Male', 'Male'), ('Female', 'Female'), ('Other', 'Other')] + )), + ('category', models.CharField( + max_length=10, + choices=[ + ('GEN', 'General'), ('OBC', 'Other Backward Class'), + ('SC', 'Scheduled Caste'), ('ST', 'Scheduled Tribe'), + ('EWS', 'Economically Weaker Section'), + ] + )), + ('minority', models.TextField(blank=True, null=True, help_text='Minority Status')), + ('pwd', models.CharField( + default='NO', max_length=3, + choices=[('YES', 'Yes'), ('NO', 'No')] + )), + ('pwd_category', models.CharField( + blank=True, max_length=100, null=True, + choices=[ + ('Locomotor Disability', 'Locomotor Disability'), + ('Visual Impairment', 'Visual Impairment'), + ('Hearing Impairment', 'Hearing Impairment'), + ('Speech and Language Disability', 'Speech and Language Disability'), + ('Intellectual Disability', 'Intellectual Disability'), + ('Autism Spectrum Disorder', 'Autism Spectrum Disorder'), + ('Multiple Disabilities', 'Multiple Disabilities'), + ('Any other (remarks)', 'Any other (remarks)'), + ] + )), + ('pwd_category_remarks', models.TextField(blank=True, null=True)), + # Contact + ('phone_number', models.CharField(blank=True, max_length=15, null=True)), + ('personal_email', models.EmailField(blank=True, max_length=254, null=True)), + ('parent_email', models.EmailField(blank=True, max_length=254, null=True)), + ('address', models.TextField(blank=True, null=True, help_text='Full Address (with pincode)')), + ('state', models.CharField(blank=True, max_length=100, null=True)), + # Family + ('father_name', models.CharField(blank=True, max_length=200, null=True)), + ('father_occupation', models.CharField(blank=True, max_length=200, null=True)), + ('father_mobile', models.CharField(blank=True, max_length=15, null=True)), + ('mother_name', models.CharField(blank=True, max_length=200, null=True)), + ('mother_occupation', models.CharField(blank=True, max_length=200, null=True)), + ('mother_mobile', models.CharField(blank=True, max_length=15, null=True)), + # Personal details + ('date_of_birth', models.DateField(blank=True, null=True)), + ('blood_group', models.CharField( + blank=True, max_length=10, null=True, + choices=[ + ('A+', 'A+'), ('A-', 'A-'), ('B+', 'B+'), ('B-', 'B-'), + ('AB+', 'AB+'), ('AB-', 'AB-'), ('O+', 'O+'), ('O-', 'O-'), + ('Other', 'Other'), + ] + )), + ('blood_group_remarks', models.TextField(blank=True, null=True)), + ('country', models.CharField(blank=True, default='India', max_length=100, null=True)), + ('nationality', models.CharField(blank=True, default='Indian', max_length=100, null=True)), + # Admission details + ('admission_mode', models.CharField( + blank=True, max_length=50, null=True, + choices=[ + ('Institute Level', 'Institute Level'), ('QIP', 'QIP'), + ('GATE', 'GATE'), ('Sponsored', 'Sponsored'), + ('Foreign National', 'Foreign National'), + ('Any other (remarks)', 'Any other (remarks)'), + ] + )), + ('admission_mode_remarks', models.TextField(blank=True, null=True)), + ('income_group', models.CharField( + blank=True, max_length=30, null=True, + choices=[ + ('Below 1 Lakh', 'Below 1 Lakh'), + ('Between 1 to 4 Lakh', 'Between 1 to 4 Lakh'), + ('Between 4 to 6 Lakh', 'Between 4 to 6 Lakh'), + ('Above 6 Lakh', 'Above 6 Lakh'), + ] + )), + ('income', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('allotted_category', models.CharField(blank=True, max_length=50, null=True)), + # PhD / GATE specific + ('gate_qualified', models.CharField( + blank=True, max_length=3, null=True, + choices=[('YES', 'Yes'), ('NO', 'No')], + help_text='GATE Qualified' + )), + ('gate_stream', models.CharField(blank=True, max_length=100, null=True)), + ('gate_rank', models.IntegerField(blank=True, null=True)), + # System / batch tracking + ('admission_semester', models.CharField( + blank=True, max_length=10, null=True, + choices=[('Odd', 'Odd Semester'), ('Even', 'Even Semester')] + )), + ('year', models.IntegerField( + db_column='batch_year', + default=applications.programme_curriculum.models_student_management.get_current_academic_year, + help_text='Admission batch year (e.g., 2025)' + )), + ('academic_year', models.CharField(blank=True, max_length=20)), + ('reported_status', models.CharField( + default='NOT_REPORTED', max_length=20, + choices=[ + ('NOT_REPORTED', 'Not Reported'), + ('REPORTED', 'Reported'), + ('WITHDRAWAL', 'Withdrawal'), + ] + )), + ('source', models.CharField(default='admin_upload', max_length=50)), + # Auth / metadata + ('user', models.ForeignKey( + blank=True, db_column='user_account_id', null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='phd_student_profile', + to=settings.AUTH_USER_MODEL + )), + ('uploaded_by', models.ForeignKey( + blank=True, db_column='created_by_id', null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='uploaded_phd_students', + to=settings.AUTH_USER_MODEL + )), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'PhD Student Batch Upload', + 'verbose_name_plural': 'PhD Student Batch Uploads', + 'ordering': ['roll_number', 'name'], + }, + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['year'], name='phd_stud_year_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['discipline'], name='phd_stud_disc_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['reported_status'], name='phd_stud_status_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['admission_semester'], name='phd_stud_sem_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['application_no'], name='phd_stud_appno_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['roll_number'], name='phd_stud_roll_idx'), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0037_phd_student_missing_fields.py b/FusionIIIT/applications/programme_curriculum/migrations/0037_phd_student_missing_fields.py new file mode 100644 index 000000000..c529803fd --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0037_phd_student_missing_fields.py @@ -0,0 +1,64 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + Adds fields present in StudentBatchUpload that were missing from PhdStudentBatchUpload: + - allotted_gender + - aadhar_number + - category_rank + - allocation_status + - email_password + - password_email_sent + - password_generated_at + """ + + dependencies = [ + ('programme_curriculum', '0036_phd_student_batch_upload'), + ] + + operations = [ + # Allotment tracking + migrations.AddField( + model_name='phdstudentbatchupload', + name='allotted_gender', + field=models.CharField(blank=True, max_length=50, null=True, help_text='Allotted Gender'), + ), + # Identity + migrations.AddField( + model_name='phdstudentbatchupload', + name='aadhar_number', + field=models.CharField(blank=True, max_length=12, null=True, help_text='Aadhaar Number (12 digits)'), + ), + # Rank + migrations.AddField( + model_name='phdstudentbatchupload', + name='category_rank', + field=models.IntegerField(blank=True, null=True, help_text='Category Rank in admission (GATE category rank or equivalent)'), + ), + # Allocation state + migrations.AddField( + model_name='phdstudentbatchupload', + name='allocation_status', + field=models.CharField(default='ALLOCATED', max_length=50, help_text='Allocation Status (e.g., ALLOCATED, PENDING)'), + ), + # Password / email notification workflow + migrations.AddField( + model_name='phdstudentbatchupload', + name='email_password', + field=models.CharField( + blank=True, max_length=50, null=True, + help_text='Temporary plain-text password storage for email notification (cleared after sending)', + ), + ), + migrations.AddField( + model_name='phdstudentbatchupload', + name='password_email_sent', + field=models.BooleanField(default=False, help_text='Whether the password email has been sent to the student'), + ), + migrations.AddField( + model_name='phdstudentbatchupload', + name='password_generated_at', + field=models.DateTimeField(blank=True, null=True, help_text='Timestamp when the password was generated'), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0038_thesis_registration_models.py b/FusionIIIT/applications/programme_curriculum/migrations/0038_thesis_registration_models.py new file mode 100644 index 000000000..afca966a9 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0038_thesis_registration_models.py @@ -0,0 +1,141 @@ +# Generated by Django 3.1.5 on 2026-03-06 11:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0037_phd_student_missing_fields'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='phdstudentbatchupload', + name='phd_stud_year_idx', + ), + migrations.RemoveIndex( + model_name='phdstudentbatchupload', + name='phd_stud_disc_idx', + ), + migrations.RemoveIndex( + model_name='phdstudentbatchupload', + name='phd_stud_status_idx', + ), + migrations.RemoveIndex( + model_name='phdstudentbatchupload', + name='phd_stud_sem_idx', + ), + migrations.RemoveIndex( + model_name='phdstudentbatchupload', + name='phd_stud_appno_idx', + ), + migrations.RemoveIndex( + model_name='phdstudentbatchupload', + name='phd_stud_roll_idx', + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='academic_year', + field=models.CharField(blank=True, help_text='Academic Year String (e.g., 2025-26)', max_length=20), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='admission_semester', + field=models.CharField(blank=True, choices=[('Odd', 'Odd Semester'), ('Even', 'Even Semester')], help_text='Semester of PhD admission: Odd or Even', max_length=10, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='admission_type', + field=models.CharField(blank=True, choices=[('FULL TIME with Institute Assistantship', 'FULL TIME with Institute Assistantship'), ('FULL TIME with Govt. / Semi Govt. Fellowship Award', 'FULL TIME with Govt. / Semi Govt. Fellowship Award'), ('FULL TIME Self Financed', 'FULL TIME Self Financed'), ('PART TIME (External)', 'PART TIME (External)'), ('QIP', 'QIP'), ('Any other (remarks)', 'Any other (remarks)')], help_text='Admission Type (col: Admission Type)', max_length=100, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='allotted_category', + field=models.CharField(blank=True, help_text='allottedcat', max_length=50, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='discipline', + field=models.CharField(help_text='Discipline / Branch (col: Discipline)', max_length=200), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='gate_qualified', + field=models.CharField(blank=True, choices=[('YES', 'Yes'), ('NO', 'No')], help_text='GATE Qualified (col: GATE Qualaified)', max_length=3, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='gate_rank', + field=models.IntegerField(blank=True, help_text='GATE Rank (col: GATE Rank)', null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='gate_stream', + field=models.CharField(blank=True, help_text='GATE Stream (col: GATE Stream)', max_length=100, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='institute_email', + field=models.EmailField(blank=True, help_text='Institute Email ID (col: Institute Email ID)', max_length=254, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='minority', + field=models.TextField(blank=True, help_text='Minority Status (col: Minority)', null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='name', + field=models.CharField(help_text='Full Name (col: Name)', max_length=200), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='parent_email', + field=models.EmailField(blank=True, help_text='Parent Email', max_length=254, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='personal_email', + field=models.EmailField(blank=True, help_text='Alternate Email ID', max_length=254, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='phone_number', + field=models.CharField(blank=True, help_text='MobileNo', max_length=15, null=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='roll_number', + field=models.CharField(blank=True, help_text='Institute Roll Number (col: Institute Roll Number)', max_length=20, null=True, unique=True), + ), + migrations.AlterField( + model_name='phdstudentbatchupload', + name='source', + field=models.CharField(default='admin_upload', help_text='Source of data: excel_upload, manual_entry, etc.', max_length=50), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['year'], name='programme_c_batch_y_c985fe_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['discipline'], name='programme_c_discipl_72ce54_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['reported_status'], name='programme_c_reporte_7022dd_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['admission_semester'], name='programme_c_admissi_a286bd_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['application_no'], name='programme_c_applica_e9ceaf_idx'), + ), + migrations.AddIndex( + model_name='phdstudentbatchupload', + index=models.Index(fields=['roll_number'], name='programme_c_roll_nu_64bfc6_idx'), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0039_auto_20260306_1157.py b/FusionIIIT/applications/programme_curriculum/migrations/0039_auto_20260306_1157.py new file mode 100644 index 000000000..ac76bfbf0 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0039_auto_20260306_1157.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.5 on 2026-03-06 11:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0038_thesis_registration_models'), + ] + + operations = [ + migrations.AlterField( + model_name='progressseminarslot', + name='type', + field=models.CharField(choices=[('Thesis', 'Thesis'), ('Progress Seminar', 'Progress Seminar'), ('Teaching Credit', 'Teaching Credit')], max_length=70), + ), + migrations.AlterField( + model_name='thesisslot', + name='type', + field=models.CharField(choices=[('Thesis', 'Thesis'), ('Progress Seminar', 'Progress Seminar'), ('Teaching Credit', 'Teaching Credit')], max_length=70), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0040_remove_type_from_thesis_and_ps_slots.py b/FusionIIIT/applications/programme_curriculum/migrations/0040_remove_type_from_thesis_and_ps_slots.py new file mode 100644 index 000000000..877367256 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0040_remove_type_from_thesis_and_ps_slots.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.5 on 2026-03-06 12:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0039_auto_20260306_1157'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='progressseminarslot', + unique_together={('semester', 'name')}, + ), + migrations.AlterUniqueTogether( + name='thesisslot', + unique_together={('semester', 'name')}, + ), + migrations.RemoveField( + model_name='progressseminarslot', + name='type', + ), + migrations.RemoveField( + model_name='thesisslot', + name='type', + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0041_merge_20260314_1604.py b/FusionIIIT/applications/programme_curriculum/migrations/0041_merge_20260314_1604.py new file mode 100644 index 000000000..e7aff6a66 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0041_merge_20260314_1604.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-03-14 16:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0032_auto_20260210_1820'), + ('programme_curriculum', '0040_remove_type_from_thesis_and_ps_slots'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0042_auto_20260410_1454.py b/FusionIIIT/applications/programme_curriculum/migrations/0042_auto_20260410_1454.py new file mode 100644 index 000000000..4e93a8e87 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0042_auto_20260410_1454.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-04-10 14:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0041_merge_20260314_1604'), + ] + + operations = [ + migrations.AlterField( + model_name='batch', + name='name', + field=models.CharField(choices=[('B.Tech', 'B.Tech'), ('M.Tech', 'M.Tech'), ('M.Tech AI & ML', 'M.Tech AI & ML'), ('M.Tech Data Science', 'M.Tech Data Science'), ('M.Tech Communication and Signal Processing', 'M.Tech Communication and Signal Processing'), ('M.Tech Nanoelectronics and VLSI Design', 'M.Tech Nanoelectronics and VLSI Design'), ('M.Tech Power & Control', 'M.Tech Power & Control'), ('M.Tech Design', 'M.Tech Design'), ('M.Tech CAD/CAM', 'M.Tech CAD/CAM'), ('M.Tech Manufacturing and Automation', 'M.Tech Manufacturing and Automation'), ('B.Des', 'B.Des'), ('M.Des', 'M.Des'), ('PhD (Odd)', 'PhD (Odd)'), ('PhD (Even)', 'PhD (Even)')], max_length=50), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0042_merge_20260805_2216.py b/FusionIIIT/applications/programme_curriculum/migrations/0042_merge_20260805_2216.py new file mode 100644 index 000000000..b9a43db0b --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0042_merge_20260805_2216.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-08-05 22:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0034_studentbatchupload_section'), + ('programme_curriculum', '0041_merge_20260314_1604'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0043_auto_20260805_2217.py b/FusionIIIT/applications/programme_curriculum/migrations/0043_auto_20260805_2217.py new file mode 100644 index 000000000..2ecdb19cb --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0043_auto_20260805_2217.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-08-05 22:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0042_merge_20260805_2216'), + ] + + operations = [ + migrations.AlterField( + model_name='batch', + name='name', + field=models.CharField(choices=[('B.Tech', 'B.Tech'), ('M.Tech', 'M.Tech'), ('M.Tech AI & ML', 'M.Tech AI & ML'), ('M.Tech Data Science', 'M.Tech Data Science'), ('M.Tech Communication and Signal Processing', 'M.Tech Communication and Signal Processing'), ('M.Tech Nanoelectronics and VLSI Design', 'M.Tech Nanoelectronics and VLSI Design'), ('M.Tech Power & Control', 'M.Tech Power & Control'), ('M.Tech Design', 'M.Tech Design'), ('M.Tech CAD/CAM', 'M.Tech CAD/CAM'), ('M.Tech Manufacturing and Automation', 'M.Tech Manufacturing and Automation'), ('B.Des', 'B.Des'), ('M.Des', 'M.Des'), ('PhD (Odd)', 'PhD (Odd)'), ('PhD (Even)', 'PhD (Even)')], max_length=50), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0043_courseauditlog_json_encoder.py b/FusionIIIT/applications/programme_curriculum/migrations/0043_courseauditlog_json_encoder.py new file mode 100644 index 000000000..1b0075a58 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0043_courseauditlog_json_encoder.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.5 on 2026-07-09 10:47 + +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0042_auto_20260410_1454'), + ] + + operations = [ + migrations.AlterField( + model_name='courseauditlog', + name='new_values', + field=models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), + ), + migrations.AlterField( + model_name='courseauditlog', + name='old_values', + field=models.JSONField(blank=True, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/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/migrations/0046_thesisslot_evaluation_type.py b/FusionIIIT/applications/programme_curriculum/migrations/0046_thesisslot_evaluation_type.py new file mode 100644 index 000000000..9b72992bf --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0046_thesisslot_evaluation_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-08-03 16:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0045_rename_progress_seminar_to_seminar'), + ] + + operations = [ + migrations.AddField( + model_name='thesisslot', + name='evaluation_type', + field=models.CharField(choices=[('blocks_sx', 'Block-wise S/X (one grade per 3 credits)'), ('decimal', 'Single decimal score (supervisor + examiner average)')], default='blocks_sx', max_length=20), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0047_merge_20260804_2335.py b/FusionIIIT/applications/programme_curriculum/migrations/0047_merge_20260804_2335.py new file mode 100644 index 000000000..31d85d120 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0047_merge_20260804_2335.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-08-04 23:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0046_thesisslot_evaluation_type'), + ('programme_curriculum', '0034_studentbatchupload_section'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0048_merge_20260805_2220.py b/FusionIIIT/applications/programme_curriculum/migrations/0048_merge_20260805_2220.py new file mode 100644 index 000000000..26fed6266 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0048_merge_20260805_2220.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.5 on 2026-08-05 22:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0043_auto_20260805_2217'), + ('programme_curriculum', '0047_merge_20260804_2335'), + ] + + operations = [ + ] diff --git a/FusionIIIT/applications/programme_curriculum/models.py b/FusionIIIT/applications/programme_curriculum/models.py index 438d5b9ed..32b413774 100644 --- a/FusionIIIT/applications/programme_curriculum/models.py +++ b/FusionIIIT/applications/programme_curriculum/models.py @@ -2,6 +2,7 @@ from django import forms import datetime import json +from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone from django.db.models.fields import IntegerField, PositiveIntegerField from django.db.models import CheckConstraint, Q, F @@ -50,7 +51,8 @@ ('M.Tech Manufacturing and Automation', 'M.Tech Manufacturing and Automation'), ('B.Des', 'B.Des'), ('M.Des', 'M.Des'), - ('Phd', 'Phd') + ('PhD (Odd)', 'PhD (Odd)'), + ('PhD (Even)', 'PhD (Even)') ] VERSION_BUMP_CHOICES = [ @@ -71,8 +73,8 @@ class CourseAuditLog(models.Model): ('UPDATE', 'Update'), ('DELETE', 'Delete') ], default='UPDATE') - old_values = models.JSONField(null=True, blank=True) # Store old field values - new_values = models.JSONField(null=True, blank=True) # Store new field values + old_values = models.JSONField(null=True, blank=True, encoder=DjangoJSONEncoder) # Store old field values + new_values = models.JSONField(null=True, blank=True, encoder=DjangoJSONEncoder) # Store new field values changed_fields = models.JSONField(default=list) # List of field names that changed version_bump_type = models.CharField(max_length=10, choices=VERSION_BUMP_CHOICES, default='NONE') old_version = models.DecimalField(max_digits=5, decimal_places=1, null=True, blank=True) @@ -228,6 +230,72 @@ def courseslots(self): return CourseSlot.objects.filter(courses=self.id) +class Thesis(models.Model): + """Store thesis details for PhD and M.Tech programmes""" + + code = models.CharField(max_length=10, null=False, blank=False, unique=True) + name = models.CharField(max_length=100, null=False, blank=False) + credit = models.PositiveIntegerField(default=0, null=False, blank=False) + discipline = models.ForeignKey(Discipline, on_delete=models.CASCADE, null=False) + programme_type = models.CharField( + max_length=3, + choices=[('PG', 'Postgraduate'), ('PHD', 'Doctor of Philosophy')], + null=False, + blank=False + ) + working_thesis = models.BooleanField(default=True) + + class Meta: + unique_together = ('code', 'discipline') + + def __str__(self): + return f"{self.code} - {self.name} ({self.discipline.acronym})" + + +class 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, + choices=[('PG', 'Postgraduate'), ('PHD', 'Doctor of Philosophy')], + null=False, + blank=False + ) + 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})" + + class Batch(models.Model): """Store batch details""" @@ -275,6 +343,84 @@ def for_batches(self): return ((Semester.objects.get(id=self.semester.id)).curriculum).batches +class ThesisSlot(models.Model): + """Store thesis slot details for a semester""" + + EVALUATION_TYPE_CHOICES = [ + ('blocks_sx', 'Block-wise S/X (one grade per 3 credits)'), + ('decimal', 'Single decimal score (supervisor + examiner average)'), + ] + + semester = models.ForeignKey( + Semester, null=False, on_delete=models.CASCADE) + name = models.CharField(max_length=100, null=False, blank=False) + thesis_slot_info = models.TextField(null=True, blank=True) + theses = models.ManyToManyField(Thesis, blank=True) + duration = models.PositiveIntegerField(default=1) + min_registration_limit = models.PositiveIntegerField(default=0) + max_registration_limit = models.PositiveIntegerField(default=1000) + # Default 'blocks_sx' covers PhD (all semesters) and PG (sem 2/3) unchanged; + # PG's final thesis semester is marked 'decimal' by the curriculum admin. + evaluation_type = models.CharField( + max_length=20, choices=EVALUATION_TYPE_CHOICES, default='blocks_sx') + + 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 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) + 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) + + def __str__(self): + return str(Semester.__str__(self.semester) + ", " + self.name) + + class Meta: + unique_together = ('semester', 'name') + + @property + def for_batches(self): + return ((Semester.objects.get(id=self.semester.id)).curriculum).batches + + class CourseInstructor(models.Model): course_id = models.ForeignKey(Course, on_delete=models.CASCADE) instructor_id = models.ForeignKey(Faculty, on_delete=models.CASCADE) diff --git a/FusionIIIT/applications/programme_curriculum/models_student_management.py b/FusionIIIT/applications/programme_curriculum/models_student_management.py index a444f7a7b..94a9fb8e1 100644 --- a/FusionIIIT/applications/programme_curriculum/models_student_management.py +++ b/FusionIIIT/applications/programme_curriculum/models_student_management.py @@ -216,6 +216,15 @@ class StudentBatchUpload(models.Model): programme_type = models.CharField(max_length=10, choices=PROGRAMME_TYPE_CHOICES) allocation_status = models.CharField(max_length=50, default='ALLOCATED', help_text="Allocation Status") reported_status = models.CharField(max_length=20, choices=REPORTED_STATUS_CHOICES, default='NOT_REPORTED') + + # PhD-specific fields + admission_semester = models.CharField( + max_length=10, + blank=True, + null=True, + choices=[('Odd', 'Odd Semester'), ('Even', 'Even Semester')], + help_text="For PhD students: Semester in which admission occurred (becomes their Semester 1)" + ) # Source tracking - Track where the student data came from source = models.CharField( @@ -545,6 +554,362 @@ def __str__(self): return f"{self.student.name} - {self.old_reported_status} → {self.new_reported_status} at {self.created_at}" +class PhdStudentBatchUpload(models.Model): + """ + Model to store PhD student data uploaded via Excel or manual entry. + Maps directly to the PhD Student Data Template Excel sheet columns. + PhD-specific fields: application_no, admission_type, gate_qualified, gate_stream, gate_rank. + """ + + GENDER_CHOICES = [ + ('Male', 'Male'), + ('Female', 'Female'), + ('Other', 'Other'), + ] + + CATEGORY_CHOICES = [ + ('GEN', 'General'), + ('OBC', 'Other Backward Class'), + ('SC', 'Scheduled Caste'), + ('ST', 'Scheduled Tribe'), + ('EWS', 'Economically Weaker Section'), + ] + + PWD_CHOICES = [ + ('YES', 'Yes'), + ('NO', 'No'), + ] + + PWD_CATEGORY_CHOICES = [ + ('Locomotor Disability', 'Locomotor Disability'), + ('Visual Impairment', 'Visual Impairment'), + ('Hearing Impairment', 'Hearing Impairment'), + ('Speech and Language Disability', 'Speech and Language Disability'), + ('Intellectual Disability', 'Intellectual Disability'), + ('Autism Spectrum Disorder', 'Autism Spectrum Disorder'), + ('Multiple Disabilities', 'Multiple Disabilities'), + ('Any other (remarks)', 'Any other (remarks)'), + ] + + BLOOD_GROUP_CHOICES = [ + ('A+', 'A+'), ('A-', 'A-'), + ('B+', 'B+'), ('B-', 'B-'), + ('AB+', 'AB+'), ('AB-', 'AB-'), + ('O+', 'O+'), ('O-', 'O-'), + ('Other', 'Other'), + ] + + ADMISSION_MODE_CHOICES = [ + ('Institute Level', 'Institute Level'), + ('QIP', 'QIP'), + ('GATE', 'GATE'), + ('Sponsored', 'Sponsored'), + ('Foreign National', 'Foreign National'), + ('Any other (remarks)', 'Any other (remarks)'), + ] + + ADMISSION_TYPE_CHOICES = [ + ('FULL TIME with Institute Assistantship', 'FULL TIME with Institute Assistantship'), + ('FULL TIME with Govt. / Semi Govt. Fellowship Award', 'FULL TIME with Govt. / Semi Govt. Fellowship Award'), + ('FULL TIME Self Financed', 'FULL TIME Self Financed'), + ('PART TIME (External)', 'PART TIME (External)'), + ('QIP', 'QIP'), + ('Any other (remarks)', 'Any other (remarks)'), + ] + + INCOME_GROUP_CHOICES = [ + ('Below 1 Lakh', 'Below 1 Lakh'), + ('Between 1 to 4 Lakh', 'Between 1 to 4 Lakh'), + ('Between 4 to 6 Lakh', 'Between 4 to 6 Lakh'), + ('Above 6 Lakh', 'Above 6 Lakh'), + ] + + REPORTED_STATUS_CHOICES = [ + ('NOT_REPORTED', 'Not Reported'), + ('REPORTED', 'Reported'), + ('WITHDRAWAL', 'Withdrawal'), + ] + + ADMISSION_SEMESTER_CHOICES = [ + ('Odd', 'Odd Semester'), + ('Even', 'Even Semester'), + ] + + GATE_QUALIFIED_CHOICES = [ + ('YES', 'Yes'), + ('NO', 'No'), + ] + + # ---- Core identification (col 2, 3) ---- + application_no = models.CharField( + max_length=50, unique=True, blank=True, null=True, + help_text="PhD Application Number (col: Application No.)" + ) + roll_number = models.CharField( + max_length=20, unique=True, blank=True, null=True, + help_text="Institute Roll Number (col: Institute Roll Number)" + ) + institute_email = models.EmailField( + blank=True, null=True, + help_text="Institute Email ID (col: Institute Email ID)" + ) + + # ---- Personal information (col 4, 6, 7, 8, 9, 10, 11, 12) ---- + name = models.CharField(max_length=200, help_text="Full Name (col: Name)") + discipline = models.CharField(max_length=200, help_text="Discipline / Branch (col: Discipline)") + admission_type = models.CharField( + max_length=100, choices=ADMISSION_TYPE_CHOICES, blank=True, null=True, + help_text="Admission Type (col: Admission Type)" + ) + gender = models.CharField(max_length=10, choices=GENDER_CHOICES) + category = models.CharField(max_length=10, choices=CATEGORY_CHOICES) + minority = models.TextField( + blank=True, null=True, + help_text="Minority Status (col: Minority)" + ) + pwd = models.CharField(max_length=3, choices=PWD_CHOICES, default='NO') + pwd_category = models.CharField( + max_length=100, choices=PWD_CATEGORY_CHOICES, blank=True, null=True + ) + pwd_category_remarks = models.TextField(blank=True, null=True) + + # ---- Contact and address (col 13-16, 36, 37) ---- + phone_number = models.CharField(max_length=15, blank=True, null=True, help_text="MobileNo") + personal_email = models.EmailField(blank=True, null=True, help_text="Alternate Email ID") + parent_email = models.EmailField(blank=True, null=True, help_text="Parent Email") + address = models.TextField(blank=True, null=True, help_text="Full Address (with pincode)") + state = models.CharField(max_length=100, blank=True, null=True) + + # ---- Family information (col 17-22) ---- + father_name = models.CharField(max_length=200, blank=True, null=True) + father_occupation = models.CharField(max_length=200, blank=True, null=True) + father_mobile = models.CharField(max_length=15, blank=True, null=True) + mother_name = models.CharField(max_length=200, blank=True, null=True) + mother_occupation = models.CharField(max_length=200, blank=True, null=True) + mother_mobile = models.CharField(max_length=15, blank=True, null=True) + + # ---- Personal details (col 23-27) ---- + date_of_birth = models.DateField(blank=True, null=True) + blood_group = models.CharField(max_length=10, choices=BLOOD_GROUP_CHOICES, blank=True, null=True) + blood_group_remarks = models.TextField(blank=True, null=True) + country = models.CharField(max_length=100, blank=True, null=True, default='India') + nationality = models.CharField(max_length=100, blank=True, null=True, default='Indian') + + # ---- Admission details (col 28-31, 35) ---- + admission_mode = models.CharField( + max_length=50, choices=ADMISSION_MODE_CHOICES, blank=True, null=True + ) + admission_mode_remarks = models.TextField(blank=True, null=True) + income_group = models.CharField( + max_length=30, choices=INCOME_GROUP_CHOICES, blank=True, null=True + ) + income = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True) + allotted_category = models.CharField(max_length=50, blank=True, null=True, help_text="allottedcat") + allotted_gender = models.CharField(max_length=50, blank=True, null=True, help_text="Allotted Gender") + + # ---- PhD / GATE specific fields (col 32-34) ---- + gate_qualified = models.CharField( + max_length=3, choices=GATE_QUALIFIED_CHOICES, blank=True, null=True, + help_text="GATE Qualified (col: GATE Qualaified)" + ) + gate_stream = models.CharField( + max_length=100, blank=True, null=True, + help_text="GATE Stream (col: GATE Stream)" + ) + gate_rank = models.IntegerField( + blank=True, null=True, + help_text="GATE Rank (col: GATE Rank)" + ) + category_rank = models.IntegerField( + blank=True, null=True, + help_text="Category Rank in admission (GATE category rank or equivalent)" + ) + aadhar_number = models.CharField( + max_length=12, blank=True, null=True, + help_text="Aadhaar Number (12 digits)" + ) + + # ---- System / batch tracking fields ---- + admission_semester = models.CharField( + max_length=10, blank=True, null=True, + choices=ADMISSION_SEMESTER_CHOICES, + help_text="Semester of PhD admission: Odd or Even" + ) + year = models.IntegerField( + help_text="Admission batch year (e.g., 2025)", + db_column='batch_year', + default=get_current_academic_year + ) + academic_year = models.CharField( + max_length=20, blank=True, + help_text="Academic Year String (e.g., 2025-26)" + ) + reported_status = models.CharField( + max_length=20, choices=REPORTED_STATUS_CHOICES, default='NOT_REPORTED' + ) + allocation_status = models.CharField( + max_length=50, default='ALLOCATED', + help_text="Allocation Status (e.g., ALLOCATED, PENDING)" + ) + source = models.CharField( + max_length=50, default='admin_upload', + help_text="Source of data: excel_upload, manual_entry, etc." + ) + + # ---- Authentication link ---- + user = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + related_name='phd_student_profile', db_column='user_account_id' + ) + + # ---- Email / password notification fields ---- + email_password = models.CharField( + max_length=50, blank=True, null=True, + help_text="Temporary plain-text password storage for email notification (cleared after sending)" + ) + password_email_sent = models.BooleanField( + default=False, + help_text="Whether the password email has been sent to the student" + ) + password_generated_at = models.DateTimeField( + blank=True, null=True, + help_text="Timestamp when the password was generated" + ) + + # ---- Metadata ---- + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + uploaded_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + related_name='uploaded_phd_students', db_column='created_by_id' + ) + + class Meta: + verbose_name = 'PhD Student Batch Upload' + verbose_name_plural = 'PhD Student Batch Uploads' + ordering = ['roll_number', 'name'] + indexes = [ + models.Index(fields=['year']), + models.Index(fields=['discipline']), + models.Index(fields=['reported_status']), + models.Index(fields=['admission_semester']), + models.Index(fields=['application_no']), + models.Index(fields=['roll_number']), + ] + + def __str__(self): + return f"{self.name} ({self.roll_number or self.application_no})" + + # ------------------------------------------------------------------ + # Compatibility properties + # These allow code written for StudentBatchUpload to work transparently + # with PhdStudentBatchUpload without changes throughout the codebase. + # ------------------------------------------------------------------ + @property + def branch(self): + """PhD stores discipline; expose as 'branch' for backward compat.""" + return self.discipline + + @property + def specialization(self): + """PhD students have no specialization.""" + return '' + + @property + def programme_type(self): + """PhD model always represents PhD students.""" + return 'phd' + + @property + def jee_app_no(self): + """PhD students use application_no; expose as jee_app_no for backward compat.""" + return self.application_no + + @staticmethod + def generate_secure_password(length=12): + """Generate cryptographically secure password""" + import secrets + import string as _string + lowercase = _string.ascii_lowercase + uppercase = _string.ascii_uppercase + digits = _string.digits + special = "!@#$%" + password = [ + secrets.choice(lowercase), + secrets.choice(uppercase), + secrets.choice(digits), + secrets.choice(special), + ] + all_chars = lowercase + uppercase + digits + special + for _ in range(length - 4): + password.append(secrets.choice(all_chars)) + secrets.SystemRandom().shuffle(password) + return ''.join(password) + + def create_user_account(self, password=None): + """Create Django User account for this PhD student.""" + from django.contrib.auth.models import User as DjangoUser + from django.db import IntegrityError + from django.utils import timezone + + if self.user: + return self.user, None + + if not password: + password = self.generate_secure_password() + + username = self.roll_number or self.application_no + email = (self.institute_email or self.personal_email or '').lower() + + try: + existing_user = DjangoUser.objects.filter(username=username).first() + if existing_user: + self.user = existing_user + self.save() + return existing_user, None + + user = DjangoUser.objects.create_user( + username=username, + email=email, + password=password, + first_name=self.name.split()[0] if self.name else '', + last_name=' '.join(self.name.split()[1:]) if self.name and len(self.name.split()) > 1 else '', + is_active=True, + ) + self.user = user + self.email_password = password + self.password_generated_at = timezone.now() + self.password_email_sent = False + self.save() + return user, password + + except IntegrityError as e: + if 'username' in str(e): + existing_user = DjangoUser.objects.filter(username=username).first() + if existing_user: + self.user = existing_user + self.save() + return existing_user, None + raise e + + def save(self, *args, **kwargs): + """Auto-set academic_year string and normalise emails.""" + if not self.academic_year: + year = self.year or get_current_academic_year() + next_year = (year + 1) % 100 + self.academic_year = f"{year}-{next_year:02d}" + + if self.roll_number and not self.institute_email: + self.institute_email = f"{self.roll_number.lower()}@iiitdmj.ac.in" + + for field in ('institute_email', 'personal_email', 'parent_email'): + val = getattr(self, field) + if val: + setattr(self, field, val.lower()) + + super().save(*args, **kwargs) + + class UploadHistory(models.Model): """ Model to track upload history and statistics @@ -645,7 +1010,9 @@ def create_student_profiles_automatically(students_list): 'programme': student.get_programme_name(), 'batch': student.year, 'cpi': 0.0, - 'category': student.category or 'General', + # Remap batch-model category values to AcademicStudent choices (GEN/SC/ST/OBC only) + 'category': {'GEN-EWS': 'GEN', 'OBC-NCL': 'OBC', 'EWS': 'GEN'}.get( + student.category or 'GEN', student.category or 'GEN'), 'father_name': student.father_name or '', 'mother_name': student.mother_name or '', 'hall_no': 0, @@ -692,3 +1059,26 @@ def auto_create_student_profile(sender, instance, created, **kwargs): post_save.connect(auto_create_student_profile, sender=StudentBatchUpload) except: pass + + +@receiver(post_save, sender=PhdStudentBatchUpload) +def auto_create_phd_student_profile(sender, instance, created, **kwargs): + """Automatically create PhD student profile when status changes to REPORTED. + + This is a safety-net signal — the primary path is update_student_status() in + views_student_management.py which handles REPORTED → ExtraInfo/AcademicStudent/ + HoldsDesignation propagation directly. This signal fires for any direct model + .save() calls (e.g. from admin panel or management commands). + """ + if instance.reported_status == 'REPORTED' and not instance.user: + try: + post_save.disconnect(auto_create_phd_student_profile, sender=PhdStudentBatchUpload) + try: + instance.create_user_account() + finally: + post_save.connect(auto_create_phd_student_profile, sender=PhdStudentBatchUpload) + except Exception as e: + try: + post_save.connect(auto_create_phd_student_profile, sender=PhdStudentBatchUpload) + except: + pass diff --git a/FusionIIIT/applications/programme_curriculum/signals.py b/FusionIIIT/applications/programme_curriculum/signals.py index 79411e6b7..9fedbf4e7 100644 --- a/FusionIIIT/applications/programme_curriculum/signals.py +++ b/FusionIIIT/applications/programme_curriculum/signals.py @@ -108,6 +108,9 @@ def create_academic_student(student_upload, extra_info): 'EWS': 'GEN', } category = category_mapping.get(student_upload.category, 'GEN') + specialization = 'None' + if batch and batch.discipline: + specialization = batch.discipline.name academic_student = Student.objects.create( id=extra_info, @@ -120,7 +123,7 @@ def create_academic_student(student_upload, extra_info): mother_name=student_upload.mother_name or '', hall_no=0, room_no='', - specialization='None', + specialization=specialization, curr_semester_no=1 ) @@ -187,12 +190,23 @@ def get_batch_for_student(student_upload): try: discipline = Discipline.objects.filter(name=discipline_name).first() if discipline: - batch = Batch.objects.filter( - discipline=discipline, - year=student_upload.year - ).first() - if batch: - return batch + # For PhD students, look for PhD-specific batch + if student_upload.programme == 'PHD': + batch = Batch.objects.filter( + discipline=discipline, + year=student_upload.year, + name__icontains='phd' + ).first() + if batch: + return batch + else: + # For UG/PG, find regular batch + batch = Batch.objects.filter( + discipline=discipline, + year=student_upload.year + ).exclude(name__icontains='phd').first() + if batch: + return batch except Exception as e: pass diff --git a/FusionIIIT/applications/research_procedures/views.py b/FusionIIIT/applications/research_procedures/views.py index 838cf0d62..d1cded709 100644 --- a/FusionIIIT/applications/research_procedures/views.py +++ b/FusionIIIT/applications/research_procedures/views.py @@ -882,21 +882,23 @@ def forward_request(request,id): project= projects.objects.get(project_id= filez.src_object_id ) receiver_instance= project.project_investigator_id receiver_designation= getDesignation(receiver_instance.username) + role_tag = None else: receiver_instance= HoldsDesignation.objects.get(designation__name=receiver_designation).user + role_tag = receiver_designation attachment= request.FILES.get('attachment') receiver= receiver_instance.username filex= forward_file( file_id= fileid, receiver= receiver, - receiver_designation=receiver_designation, + receiver_designation=receiver_designation, file_extra_JSON= { "message": "Request forwarded."}, remarks= remarks, - file_attachment= attachment, + file_attachment= attachment, ) if(receiver_designation == 'Professor' or receiver_designation == 'Assistant Professor' or receiver_designation == 'rspc_admin'): - research_procedures_notif(request.user, receiver_instance, "Request update") + research_procedures_notif(request.user, receiver_instance, "Request update", role=role_tag) messages.success(request,"Request forwarded successfully") diff --git a/FusionIIIT/applications/visitor_hostel/views.py b/FusionIIIT/applications/visitor_hostel/views.py index 3419a7d14..cfa59dc69 100644 --- a/FusionIIIT/applications/visitor_hostel/views.py +++ b/FusionIIIT/applications/visitor_hostel/views.py @@ -601,7 +601,7 @@ def cancel_booking_request(request): # to notify the VhIncharge about a new cancelltaion request visitors_hostel_notif( - request.user, incharge_name.user, 'cancellation_request_placed') + request.user, incharge_name.user, 'cancellation_request_placed', role=incharge_name.designation.name) return HttpResponseRedirect('/visitorhostel/') else: return HttpResponseRedirect('/visitorhostel/') @@ -997,7 +997,7 @@ def forward_booking(request): # notify incharge about forwarded booking visitors_hostel_notif( - request.user, incharge_name.user, 'booking_forwarded') + request.user, incharge_name.user, 'booking_forwarded', role=incharge_name.designation.name) return HttpResponseRedirect('/visitorhostel/') else: return HttpResponseRedirect('/visitorhostel/') diff --git a/FusionIIIT/notification/views.py b/FusionIIIT/notification/views.py index ca732a7b7..728c34e53 100644 --- a/FusionIIIT/notification/views.py +++ b/FusionIIIT/notification/views.py @@ -4,7 +4,7 @@ # Create your views here. -def leave_module_notif(sender, recipient, type, date=None): +def leave_module_notif(sender, recipient, type, date=None, role=None): url = 'leave:leave' module = 'Leave Module' sender = sender @@ -34,7 +34,7 @@ def leave_module_notif(sender, recipient, type, date=None): verb = "Your replacement has been cancelled for "+date notify.send(sender=sender, recipient=recipient, - url=url, module=module, verb=verb) + url=url, module=module, verb=verb, role=role) def placement_cell_notif(sender, recipient, type): @@ -70,7 +70,7 @@ def office_module_notif(sender, recipient): url=url, module=module, verb=verb) -def central_mess_notif(sender, recipient, type, message=None): +def central_mess_notif(sender, recipient, type, message=None, role=None): url = 'mess:mess' module = 'Central Mess' sender = sender @@ -92,8 +92,8 @@ def central_mess_notif(sender, recipient, type, message=None): elif type == 'added_committee': verb = "You have been added to the mess committee. " - notify.send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) - + notify.send(sender=sender, recipient=recipient, url=url, module=module, verb=verb, role=role) + def placement_cellNotif(sender, recipient, type): url = 'placement:placement' module = 'Placement Cell' @@ -103,7 +103,7 @@ def placement_cellNotif(sender, recipient, type): notify.send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) -def visitors_hostel_notif(sender, recipient, type): +def visitors_hostel_notif(sender, recipient, type, role=None): url='visitorhostel:visitorhostel' module="Visitor's Hostel" sender = sender @@ -122,9 +122,9 @@ def visitors_hostel_notif(sender, recipient, type): elif type =='booking_rejected': verb='Your Booking Request has been rejected ' - notify.send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + notify.send(sender=sender, recipient=recipient, url=url, module=module, verb=verb, role=role) -def healthcare_center_notif(sender, recipient, type, message): +def healthcare_center_notif(sender, recipient, type, message, role=None): url='healthcenter:healthcenter' module='Healthcare Center' sender = sender @@ -154,7 +154,7 @@ def healthcare_center_notif(sender, recipient, type, message): verb = "You have a new medical relief approval request" elif type == 'rel_approved': verb = 'Your medical relief request has been approved' - notify.send(sender=sender, recipient=recipient, url=url, module=module, verb=verb, flag=flag) + notify.send(sender=sender, recipient=recipient, url=url, module=module, verb=verb, flag=flag, role=role) def file_tracking_notif(sender, recipient, title): url = 'filetracking:inward' @@ -199,7 +199,7 @@ def scholarship_portal_notif(sender, recipient, type): url=url, module=module, verb=verb) -def complaint_system_notif(sender, recipient, type, complaint_id, student, message): +def complaint_system_notif(sender, recipient, type, complaint_id, student, message, role=None): if (student == 0): url = ('complaint:detail') else: @@ -211,10 +211,10 @@ def complaint_system_notif(sender, recipient, type, complaint_id, student, messa description = complaint_id notify.send(sender=sender, recipient=recipient, url=url, - module=module, verb=verb, description=description) + module=module, verb=verb, description=description, role=role) -def office_dean_PnD_notif(sender, recipient, type): +def office_dean_PnD_notif(sender, recipient, type, role=None): url = 'office_module:officeOfDeanPnD' module = 'Office of Dean PnD Module' sender = sender @@ -237,10 +237,10 @@ def office_dean_PnD_notif(sender, recipient, type): elif type == 'assignment_rejected': verb = "Assignment has been rejected." notify.send(sender=sender, recipient=recipient, - url=url, module=module, verb=verb) + url=url, module=module, verb=verb, role=role) -def office_module_DeanS_notif(sender, recipient, type): +def office_module_DeanS_notif(sender, recipient, type, role=None): url = 'office_module:officeOfDeanStudents' module = 'Office Module' sender = sender @@ -271,7 +271,7 @@ def office_module_DeanS_notif(sender, recipient, type): verb = "Budget has been alloted by Junior Superintendent" notify.send(sender=sender, recipient=recipient, - url=url, module=module, verb=verb) + url=url, module=module, verb=verb, role=role) def gymkhana_voting(sender, recipient, type, title, desc): @@ -438,7 +438,7 @@ def office_module_DeanRSPC_notif(sender, recipient, type): url=url, module=module, verb=verb) -def research_procedures_notif(sender, recipient, type): +def research_procedures_notif(sender, recipient, type, role=None): url = 'research_procedures:patent_registration' module = 'Research Procedures' sender = sender @@ -456,7 +456,7 @@ def research_procedures_notif(sender, recipient, type): elif type == "created": verb = "A new Patent has been Created" - notify.send(sender=sender,recipient=recipient,url=url,module=module,verb=verb) + notify.send(sender=sender,recipient=recipient,url=url,module=module,verb=verb,role=role) def hostel_notifications(sender, recipient, type): url = 'hostelmanagement:hostel_view' 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 %} - diff --git a/FusionIIIT/templates/email/examiner_panel_invitation.html b/FusionIIIT/templates/email/examiner_panel_invitation.html new file mode 100644 index 000000000..e8c34bf49 --- /dev/null +++ b/FusionIIIT/templates/email/examiner_panel_invitation.html @@ -0,0 +1,41 @@ + + + + + + Thesis Examiner Invitation + + +
    +

    Thesis Examiner Invitation

    + +

    Dear {{ prof_name }},

    + +

    You are invited to serve as the external examiner for the M.Tech thesis evaluations of the following batch:

    +

    + {{ batch_name }} +

    + +

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

    + +
    + Accept + Decline +
    + +
    +

    Important:

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

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

    +
    + + diff --git a/FusionIIIT/templates/email/examiner_panel_invitation.txt b/FusionIIIT/templates/email/examiner_panel_invitation.txt new file mode 100644 index 000000000..cf76b317f --- /dev/null +++ b/FusionIIIT/templates/email/examiner_panel_invitation.txt @@ -0,0 +1,24 @@ +THESIS EXAMINER INVITATION +========================== + +Dear {{ prof_name }}, + +You are invited to serve as the external examiner for the M.Tech thesis +evaluations of the following batch: + +"{{ batch_name }}" + +Please respond to this invitation by clicking one of the links below: + +Accept: {{ accept_url }} +Decline: {{ reject_url }} + +IMPORTANT INFORMATION: +- This invitation expires on {{ expires_at }} +- These links are unique to you - please do not share them +- If accepted, you will evaluate every student in this batch +- If you did not expect this invitation, please ignore this email + +Thank you for your consideration, +Thesis Committee +PDPM IIITDM Jabalpur diff --git a/FusionIIIT/templates/email/examiner_panel_scoring.html b/FusionIIIT/templates/email/examiner_panel_scoring.html new file mode 100644 index 000000000..dd24f473b --- /dev/null +++ b/FusionIIIT/templates/email/examiner_panel_scoring.html @@ -0,0 +1,39 @@ + + + + + + Thesis Scoring Form + + +
    +

    Thesis Scoring Form

    + +

    Dear {{ prof_name }},

    + +

    Thank you for accepting to examine the theses of the batch:

    +

    + {{ batch_name }} +

    + +

    Please score each student's thesis (out of 100) using the secure link below:

    + + + +
    +

    Note:

    +
      +
    • This link is unique to you and should not be shared
    • +
    • You can save this email and return to score remaining students later
    • +
    • Your evaluation is valuable and greatly appreciated
    • +
    +
    + +

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

    +
    + + diff --git a/FusionIIIT/templates/email/examiner_panel_scoring.txt b/FusionIIIT/templates/email/examiner_panel_scoring.txt new file mode 100644 index 000000000..8ea7c967b --- /dev/null +++ b/FusionIIIT/templates/email/examiner_panel_scoring.txt @@ -0,0 +1,21 @@ +THESIS SCORING FORM +==================== + +Dear {{ prof_name }}, + +Thank you for accepting to examine the theses of the batch: + +"{{ batch_name }}" + +Please score each student's thesis (out of 100) using the secure link below: + +{{ scoring_url }} + +NOTE: +- This link is unique to you and should not be shared +- You can save this email and return to score remaining students later +- Your evaluation is valuable and greatly appreciated + +Thank you for your valuable contribution, +Thesis Committee +PDPM IIITDM Jabalpur diff --git a/FusionIIIT/templates/email/invitation.html b/FusionIIIT/templates/email/invitation.html new file mode 100644 index 000000000..d020da0e9 --- /dev/null +++ b/FusionIIIT/templates/email/invitation.html @@ -0,0 +1,40 @@ + + + + + + Thesis Review Invitation + + +
    +

    Thesis Review Invitation

    + +

    Dear {{ prof_name }},

    + +

    You are invited to review the PhD thesis titled:

    +

    + {{ thesis_title }} +

    + +

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

    + + + +
    +

    Important:

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

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

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

    Thesis Review Form

    + +

    Dear {{ prof_name }},

    + +

    Thank you for accepting to review the PhD thesis:

    +

    + {{ thesis_title }} +

    + +

    Please submit your review using the secure link below:

    + + + +
    +

    Note:

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

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

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

    ✓ Review Received - Thank You!

    + +

    Dear {{ prof_name }},

    + +

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

    +

    + {{ thesis_title }} +

    + +

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

    + +
    +

    + Your review has been successfully submitted and recorded. +

    +
    + +

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

    + +

    With sincere gratitude,
    + Thesis Committee
    + PDPM IIITDM Jabalpur

    +
    + + diff --git a/FusionIIIT/templates/email/thank_you.txt b/FusionIIIT/templates/email/thank_you.txt new file mode 100644 index 000000000..2ae5e8087 --- /dev/null +++ b/FusionIIIT/templates/email/thank_you.txt @@ -0,0 +1,18 @@ +REVIEW RECEIVED - THANK YOU! +============================ + +Dear {{ prof_name }}, + +Thank you for submitting your comprehensive review of the PhD thesis: + +"{{ thesis_title }}" + +Your expert feedback and insights are invaluable to the academic process and will significantly contribute to the quality and rigor of this research work. + +✓ Your review has been successfully submitted and recorded. + +We deeply appreciate the time and effort you have dedicated to this evaluation. Your contribution helps maintain the high standards of academic excellence. + +With sincere gratitude, +Thesis Committee +PDPM IIITDM Jabalpur diff --git a/FusionIIIT/templates/placementModule/._placement.html b/FusionIIIT/templates/placementModule/._placement.html deleted file mode 100644 index b414c702c..000000000 Binary files a/FusionIIIT/templates/placementModule/._placement.html and /dev/null differ diff --git a/FusionIIIT/templates/placementModule/activity.html b/FusionIIIT/templates/placementModule/activity.html deleted file mode 100644 index ef43e0d3d..000000000 --- a/FusionIIIT/templates/placementModule/activity.html +++ /dev/null @@ -1,537 +0,0 @@ -{% extends 'globals/base.html' %} -{% load static %} - - -{% block title %} - Placement Schedule -{% endblock %} - - -{% block body %} - {% block navBar %} - {% include 'dashboard/navbar.html' %} - {% endblock %} - - {% comment %}The grid starts here!{% endcomment %} -
    - - {% comment %}The left-margin segment!{% endcomment %} -
    - - {% comment %} - The left-rail segment starts here! - {% endcomment %} -
    - - {% comment %}The user image card starts here!{% endcomment %} - {% block usercard %} - {% include 'globals/usercard.html' %} - {% endblock %} - {% comment %}The user image card ends here!{% endcomment %} - -
    - - {% comment %}The Tab-Menu starts here!{% endcomment %} - - {% comment %}The Tab-Menu ends here!{% endcomment %} - -
    - {% comment %} - The left-rail segment ends here! - {% endcomment %} - - - {% comment %} The central-rail segment starts here!{% endcomment %} -
    - - - -
    -
    - {% for c in current %} - {% if c.designation.name == 'student' %} - {% if placementstatus %} - -
    -
    - {% for ps in placementstatus %} - {% if ps.invitation != 'PENDING' and ps.invitation != 'Pending' %} - {% if ps.placed != 'Placed' and ps.placed != 'PLACED' %} - -
    -
    -
    - {{ ps.notify_id.placement_type }} - {{ ps.notify_id.timestamp }} - -
    -
    -
    -

    {{ ps.notify_id.company_name }} , CTC: {{ ps.notify_id.ctc }}

    -

    {{ ps.notify_id.description }}

    -
    - {% if ps.invitation == 'ACCEPTED' or ps.invitation == 'Accepted' %} -
    -

    Invitation was {{ ps.invitation}}.

    -
    - {% endif %} - {% if ps.invitation == 'REJECTED' or ps.invitation == 'Rejected' %} -
    -

    Invitation was {{ ps.invitation}}.

    -
    - {% endif %} - {% if ps.invitation == 'IGNORE' %} -
    -

    Invitation was {{ ps.invitation}}.

    -
    - {% endif %} -
    -

    - {% endif %} - {% endif %} - {% endfor %} -
    - - {% else %} -
    There are no Activities Scheduled.
    - {% endif %} - - {% endif %} - {% endfor %} - - {% for c1 in current1 %} - {% if c1.designation.name == 'placement chairman' %} - {% if schedules %} -
    -
    - - {% for sch in schedules|dictsortreversed:"schedule_at" %} - -
    -
    -
    -

    {{ sch.notify_id.placement_type }} | {{ sch.notify_id.company_name }}


    - Time of Message: {{ sch.notify_id.timestamp }} - -
    -
    -
    -

    CTC (LPA): {{ sch.notify_id.ctc }}

    -

    {{ sch.title }} | {{ sch.notify_id.description }}

    -

    Date/Time & Location: {{ sch.placement_date }} | {{ sch.time }} | {{ sch.location }}

    -

    Job Description: {{ sch.description }}

    -
    -
    -

    - {% endfor %} - -
    - - {% endif %} - {% endif %} - {% endfor %} - - {% for c1 in current2 %} - {% if c1.designation.name == 'placement officer' %} - {% if schedules %} - -
    -
    - - {% for sch in schedules|dictsortreversed:"schedule_at" %} - -
    -
    -
    -
    - {% csrf_token %} - - -
    -

    {{ sch.notify_id.placement_type }} | {{ sch.notify_id.company_name }}


    - - Time of Message: {{ sch.notify_id.timestamp }} - -
    -
    -
    -

    CTC (LPA): {{ sch.notify_id.ctc }}

    -

    {{ sch.title }} | {{ sch.notify_id.description }}

    -

    Date/Time & Location: {{ sch.placement_date }} | {{ sch.time }} | {{ sch.location }}

    -

    Job Description: {{ sch.description }}

    -
    - {% if sch.attached_file %} - download - {% endif %} -
    -

    - {% endfor %} - -
    - - {% endif %} - {% endif %} - {% endfor %} -
    -
    - - {% for c3 in current2 %} - {% if c3.designation.name == 'placement officer'%} -
    -
    - {% if form5.errors %} -
    - -
    - There were some errors with your submission -
    -
      - {% if form5.company_name.errors %} -
    • {{ form5.company_name.errors }}
    • - {% endif %} - - {% if form5.placement_date.errors %} -
    • {{ form5.placement_date.errors }}
    • - {% endif %} - - {% if form5.location.errors %} -
    • {{ form5.location.errors }}
    • - {% endif %} - - {% if form5.ctc.errors %} -
    • {{ form5.ctc.errors }}
    • - {% endif %} - - {% if form5.time.errors %} -
    • {{ form5.time.errors }}
    • - {% endif %} - - {% if form5.placement_type.errors %} -
    • {{ form5.placement_type.errors }}
    • - {% endif %} - - {% if form5.description.errors %} -
    • {{ form5.description.errors }}
    • - {% endif %} -
    -
    - {% endif %} - - {% comment %}Form Tag starts here!{% endcomment %} -
    - {% comment %}The add a new skill Accordian starts here!{% endcomment %} -
    -
    - - Add a new Event! -
    - -
    -
    - {% csrf_token %} -
    -
    - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form5.company_name }} -
    -
    - -
    - -
    -
    - - {{ form5.placement_date }} -
    -
    -
    - -
    - -
    - {% comment %}The Location input!{% endcomment %} - {{ form5.location }} -
    -
    -
    -
    -
    - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form5.ctc }} -
    -
    -
    - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form5.time }} -
    -
    -
    - -
    - {% comment %}The Location input!{% endcomment %} - {{ form5.placement_type }} -
    -
    -
    -
    - - -
    - {% comment %}The Location input!{% endcomment %} - {{ form5.description }} -
    -
    - -
    - -
    - -
    -
    - -
    -
    -
    - {% comment %}The add a new Achievement Accordian ends here!{% endcomment %} -
    - {% comment %}Form Tag ends here!{% endcomment %} -
    -
    - {% endif %} - {% endfor %} - - {% for c1 in current1 %} - {% if c1.designation.name == 'placement chairman'%} -
    -
    - - {% comment %}Form Tag starts here!{% endcomment %} -
    - {% comment %}The add a new skill Accordian starts here!{% endcomment %} -
    -
    - - Add a new Event! -
    - {{ form5.non_field_errors }} -
    -
    - {% csrf_token %} -
    -
    - {{ form5.company_name.errors }} - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form5.company_name }} -
    -
    - -
    - {{ form5.placement_date.errors }} - -
    -
    - - {{ form5.placement_date }} -
    -
    -
    - -
    - {{ form5.location.errors }} - -
    - {% comment %}The Location input!{% endcomment %} - {{ form5.location }} -
    -
    -
    -
    -
    - {{ form5.ctc.errors }} - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form5.ctc }} -
    -
    -
    - {{ form5.time.errors }} - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form5.time }} -
    -
    -
    - {{ form5.placement_type.errors }} - -
    - {% comment %}The Location input!{% endcomment %} - {{ form5.placement_type }} -
    -
    -
    -
    - {{ form5.description.errors }} - - -
    - {% comment %}The Location input!{% endcomment %} - {{ form5.description }} -
    -
    -
    - -
    - -
    -
    - -
    -
    -
    - {% comment %}The add a new Achievement Accordian ends here!{% endcomment %} -
    - {% comment %}Form Tag ends here!{% endcomment %} -
    -
    - {% endif %} - {% endfor %} -
    - -
    -
    - -{% endblock %} diff --git a/FusionIIIT/templates/placementModule/add_placement_record.html b/FusionIIIT/templates/placementModule/add_placement_record.html deleted file mode 100644 index 5b9f07324..000000000 --- a/FusionIIIT/templates/placementModule/add_placement_record.html +++ /dev/null @@ -1,401 +0,0 @@ -{% extends 'globals/base.html' %} -{% load static %} - - -{% block title %} - Add Placement Schedule -{% endblock %} - -{% block body %} - {% block navBar %} - {% include 'dashboard/navbar.html' %} - {% endblock %} - - {% comment %}The grid starts here!{% endcomment %} -
    - - {% comment %}The left-margin segment!{% endcomment %} -
    - - {% comment %} - The left-rail segment starts here! - {% endcomment %} -
    - - {% comment %}The user image card starts here!{% endcomment %} - {% block usercard %} - {% include 'globals/usercard.html' %} - {% endblock %} - {% comment %}The user image card ends here!{% endcomment %} - -
    - - {% comment %}The Tab-Menu starts here!{% endcomment %} - - {% comment %}The Tab-Menu ends here!{% endcomment %} - -
    - {% comment %} - The left-rail segment ends here! - {% endcomment %} - - - {% comment %} The central-rail segment starts here!{% endcomment %} - - - -
    -
    - {% csrf_token %} -
    -

    New Placement Record

    -
    - - - - - - - - - - - - - - - - - - - - -
    - - -
    - - - - - - - - - -
    -
    - - - - {% block interviewrequest %} - {% include 'placementModule/interviewrequest.html' %} - {% endblock %} - - - - {% comment %}The right-rail segment ends here!{% endcomment %} - - {% comment %}The right-margin segment!{% endcomment %} -
    - -
    - -{% endblock %} - - -{% block javascript %} - - - - - - - - - - - - - - - -{% endblock %} diff --git a/FusionIIIT/templates/placementModule/add_placement_visits.html b/FusionIIIT/templates/placementModule/add_placement_visits.html deleted file mode 100644 index a04e9264d..000000000 --- a/FusionIIIT/templates/placementModule/add_placement_visits.html +++ /dev/null @@ -1,422 +0,0 @@ -{% extends 'globals/base.html' %} -{% load static %} - - -{% block title %} - Add Placement Schedule -{% endblock %} - -{% block body %} - {% block navBar %} - {% include 'dashboard/navbar.html' %} - {% endblock %} - - {% comment %}The grid starts here!{% endcomment %} -
    - - {% comment %}The left-margin segment!{% endcomment %} -
    - - {% comment %} - The left-rail segment starts here! - {% endcomment %} -
    - - {% comment %}The user image card starts here!{% endcomment %} - {% block usercard %} - {% include 'globals/usercard.html' %} - {% endblock %} - {% comment %}The user image card ends here!{% endcomment %} - -
    - - {% comment %}The Tab-Menu starts here!{% endcomment %} - - {% comment %}The Tab-Menu ends here!{% endcomment %} - -
    - {% comment %} - The left-rail segment ends here! - {% endcomment %} - - - {% comment %} The central-rail segment starts here!{% endcomment %} - - - -
    -
    - {% csrf_token %} -
    -

    New Chairman Visit

    -
    - - - - - - - - - - - - - - - - - - -
    - - -
    - - - - - - - - - -
    -
    - -
    -

    Previous Visits

    - - - - - - - - - - {% for rec in all_placement_visits %} - - - - - - - - - - - - - - {% endfor %} -
    IdCompany VisitLocationDescriptionTime-StampDate
    {{ rec.id }}{{ rec.company_name }}{{ rec.location }}{{ rec.description }}{{ rec.timestamp }}{{ rec.visiting_date }} - -
    -
    - -
    - - - - {% block interviewrequest %} - {% include 'placementModule/interviewrequest.html' %} - {% endblock %} - - - - {% comment %}The right-rail segment ends here!{% endcomment %} - - {% comment %}The right-margin segment!{% endcomment %} -
    - -
    - -{% endblock %} - - -{% block javascript %} - - - - - - - - - - - - - - - -{% endblock %} diff --git a/FusionIIIT/templates/placementModule/cocurricular.html b/FusionIIIT/templates/placementModule/cocurricular.html deleted file mode 100644 index 7f7267438..000000000 --- a/FusionIIIT/templates/placementModule/cocurricular.html +++ /dev/null @@ -1,390 +0,0 @@ -{% block cocurricular %} - {% comment %}The tab menu starts here!{% endcomment %} - - -
    - -
    - {% comment %}Form Tag starts here!{% endcomment %} -
    - {% comment %}The add a new Publication Accordian starts here!{% endcomment %} -
    -
    - - Add a new Publication! -
    - {{ form5.non_field_errors }} -
    -
    - {% csrf_token %} -
    -
    - {{ form5.publication_title.errors }} - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form5.publication_title }} -
    -
    - -
    - {{ form5.publication_date.errors }} - -
    -
    - - {{ form5.publication_date }} -
    -
    -
    -
    - -
    -
    - {{ form5.description.errors }} - - -
    - {{ form5.description }} -
    -
    - -
    - {{ form5.publisher.errors }} - - -
    - {{ form5.publisher }} -
    -
    - -
    - - -
    -
    - -
    -
    -
    - {% comment %}The add a new Publication Accordian ends here!{% endcomment %} -
    - {% comment %}Form Tag ends here!{% endcomment %} - - {% if publications %} -
    - - - - - - - - - - - - - {% for publication in publications %} - - - - - - - - - - - - {% endfor %} - -
    Publication TitlePublisherDescriptionDate PublishedDelete
    - {{ publication.publication_title }} - - {{ publication.publisher }} - - {{ publication.description }} - - {{ publication.publication_date }} - -
    - {% csrf_token %} - -
    -
    - {% endif %} - -
    -
    -
    -
    - {% comment %}Form Tag starts here!{% endcomment %} -
    - {% comment %}The add a new Publication Accordian starts here!{% endcomment %} -
    -
    - - Add a new Patent! -
    - {{ form7.non_field_errors }} -
    -
    - {% csrf_token %} -
    -
    - {{ form7.patent_name.errors }} - -
    - {% comment %}The Patent Name input!{% endcomment %} - {{ form7.patent_name }} -
    -
    - -
    - {{ form7.patent_date.errors }} - -
    -
    - - {{ form7.patent_date }} -
    -
    -
    -
    - -
    -
    - {{ form7.description.errors }} - - -
    - {{ form7.description }} -
    -
    - -
    - {{ form7.patent_office.errors }} - - -
    - {{ form7.patent_office }} -
    -
    - -
    - - -
    -
    - -
    -
    -
    - {% comment %}The add a new Publication Accordian ends here!{% endcomment %} -
    - {% comment %}Form Tag ends here!{% endcomment %} - - - {% if patent %} -
    - - - - - - - - - - - - - {% for paten in patent %} - - - - - - - - - - - - {% endfor %} - -
    Patent NamePatent OfficeDescriptionDate PublishedDelete
    - {{ paten.patent_name }} - - {{ paten.patent_office }} - - {{ paten.description }} - - {{ paten.patent_date }} - -
    - {% csrf_token %} - -
    -
    - {% endif %} -
    -
    -
    -
    - {% comment %}Form Tag starts here!{% endcomment %} -
    - {% comment %}The add a new Course Accordian starts here!{% endcomment %} -
    -
    - - Add a new Conference/Seminar! -
    - {{ form62.non_field_errors }} -
    -
    - {% csrf_token %} -
    -
    - {{ form62.course_name.errors }} - - -
    - {{ form62.conference_name }} -
    -
    - -
    - - {% comment %}The start and end date input{% endcomment %} -
    -
    - {{ form62.sdate.errors }} - -
    -
    - - {{ form62.sdate }} -
    -
    -
    - -
    - {{ form62.edate.errors }} - -
    -
    - - {{ form62.edate }} -
    -
    -
    -
    - -
    -
    - {{ form62.description.errors }} - - {{ form62.description }} -
    - -
    - - -
    -
    - -
    -
    -
    - {% comment %}The add a new Course Accordian ends here!{% endcomment %} -
    - {% comment %}Form Tag ends here!{% endcomment %} - - {% if conferences %} -
    - - - - - - - - - - - - - {% for conference in conferences %} - - - - - - - - - - - - {% endfor %} - -
    Organisation NameStart DateEnd DateDescriptionDelete
    - {{ conference.conference_name }} - - {{ conference.sdate }} - - {{ conference.edate }} - - {{ conference.description }} - -
    - {% csrf_token %} - -
    -
    - {% endif %} -
    -
    -{% endblock %} diff --git a/FusionIIIT/templates/placementModule/extracurricular.html b/FusionIIIT/templates/placementModule/extracurricular.html deleted file mode 100644 index 9180a8ee5..000000000 --- a/FusionIIIT/templates/placementModule/extracurricular.html +++ /dev/null @@ -1,150 +0,0 @@ -{% block extracurricular %} - {% comment %}The tab menu starts here!{% endcomment %} - - -
    -
    - {% comment %}Form Tag starts here!{% endcomment %} -
    - {% comment %}Then add a new achievement Accordian starts here!{% endcomment %} -
    -
    - - Add a new Extracurricular Activity! -
    - {{ form88.non_field_errors }} -
    -
    - {% csrf_token %} -
    -
    - {{ form88.event_name.errors }} - -
    - {% comment %}The Achievement Name input!{% endcomment %} - {{ form88.event_name }} -
    -
    - -
    - {{ form88.event_type.errors }} - -
    - {{ form88.event_type }} -
    -
    - -
    - - {% comment %}The from date input{% endcomment %} -
    -
    - {{ form88.date_earned.errors }} - -
    -
    - - {{ form88.date_earned }} -
    -
    -
    -
    - {{ form88.name_of_position.errors }} - -
    - {{ form88.name_of_position }} -
    -
    -
    - -
    -
    - {{ form88.description.errors }} - - - {{ form88.description }} -
    - -
    - - -
    -
    - -
    -
    -
    - {% comment %}The add a new Achievement Accordian ends here!{% endcomment %} -
    - {% comment %}Form Tag ends here!{% endcomment %} - - {% if extracurriculars %} -
    - - - - - - - - - - - - - - {% for activity in extracurriculars %} - - - - - - - - - - - - - - {% endfor %} - -
    Event NameTypePositionDateDescriptionDelete
    - {{ activity.event_name }} - - {{ activity.event_type }} - - {{ activity.name_of_position }} - - {{ activity.date_earned }} - - {{ activity.description }} - -
    - {% csrf_token %} - -
    -
    - {% endif %} - -
    -
    -{% endblock %} diff --git a/FusionIIIT/templates/placementModule/interviewrequest.html b/FusionIIIT/templates/placementModule/interviewrequest.html deleted file mode 100644 index 540c382a0..000000000 --- a/FusionIIIT/templates/placementModule/interviewrequest.html +++ /dev/null @@ -1,66 +0,0 @@ -{% load static %} - -{% block interviewrequest %} - {% if placementstatus %} -
    - {% for ps in placementstatus %} - {% if ps.invitation == 'PENDING' or ps.invitation == 'Pending' %} - {% if ps.placed == 'Not Placed' or ps.placed == 'NOT PLACED' %} - -
    -
    -
    - {{ ps.notify_id.placement_type }} Invitation -
    -
    - {{ ps.notify_id.company_name }} , CTC: {{ ps.notify_id.ctc }}
    - Respond Before:
    {{ ps.response_date }} -
    - -
    - -
    -

    {{ ps.notify_id.description }}

    -

    Do you want to accept the invitation?

    -
    -
    -
    -
    -
    - {% csrf_token %} - -
    -
    - {% csrf_token %} - -
    -
    -
    -
    - {% endif %} - {% endif %} - - {% if ps.placed == 'Placed' or ps.placed == 'PLACED' %} -
    -
    -
    - {{ ps.notify_id.placement_type }} CONGRATULATIONS -
    -
    - {{ ps.notify_id.company_name }} , CTC: {{ ps.notify_id.ctc }} -
    - -
    - -
    -

    Congatulations on the Placement.

    -

    We wish you all the luck for your future.

    -
    -
    -
    - {% endif %} - - {% endfor %} -
    - {% endif %} -{% endblock %} diff --git a/FusionIIIT/templates/placementModule/managerecords.html b/FusionIIIT/templates/placementModule/managerecords.html deleted file mode 100644 index 6f70782f8..000000000 --- a/FusionIIIT/templates/placementModule/managerecords.html +++ /dev/null @@ -1,839 +0,0 @@ -{% extends 'globals/base.html' %} -{% load static %} - - -{% block title %} - Placement -{% endblock %} - - -{% block body %} - {% block navBar %} - {% include 'dashboard/navbar.html' %} - {% endblock %} - - {% comment %}The grid starts here!{% endcomment %} -
    - - {% comment %}The left-margin segment!{% endcomment %} -
    - - {% comment %} - The left-rail segment starts here! - {% endcomment %} -
    - - {% comment %}The user image card starts here!{% endcomment %} - {% block usercard %} - {% include 'globals/usercard.html' %} - {% endblock %} - {% comment %}The user image card ends here!{% endcomment %} - -
    - - {% comment %}The Tab-Menu starts here!{% endcomment %} - - {% comment %}The Tab-Menu ends here!{% endcomment %} - -
    - {% comment %} - The left-rail segment ends here! - {% endcomment %} - - - {% comment %} The central-rail segment starts here!{% endcomment %} -
    - {% comment %}The tab menu starts here!{% endcomment %} - - -
    -
    - - - - - - - - -
    -
    -
    - {% comment %}The central-rail segment ends here!{% endcomment %} - -
    - - {% endblock %} - - {% block javascript %} - - {% endblock %} diff --git a/FusionIIIT/templates/placementModule/pdf_demo.html b/FusionIIIT/templates/placementModule/pdf_demo.html deleted file mode 100644 index 3b4e9a679..000000000 --- a/FusionIIIT/templates/placementModule/pdf_demo.html +++ /dev/null @@ -1,55 +0,0 @@ - - - Student Record - - - - - - - - - - - - - - {% for student in students %} - - - - - - - - - - {% endfor %} -
    StudentCPIDepartmentDisciplinePlacedDebarred?
    -
    - {{ student.id.user.first_name}} {{ student.id.user.last_name }} -
    - {{ student.id.id }} -
    -
    -
    - {{ student.cpi }} - - {{ student.programme }} - - {{ student.id.department.name }} -
    - - \ No newline at end of file diff --git a/FusionIIIT/templates/placementModule/pdf_invitation_status.html b/FusionIIIT/templates/placementModule/pdf_invitation_status.html deleted file mode 100644 index 6d2557a56..000000000 --- a/FusionIIIT/templates/placementModule/pdf_invitation_status.html +++ /dev/null @@ -1,149 +0,0 @@ -{% load static %} - - - - - Invitation Status Record - - - - - -

    Invitation Status Record

    -
    - - - - - - - - - - - - - {% for student in placementstatus %} - {% if student.notify_id.placement_type == 'PLACEMENT' %} - - - - - - - - {% endif %} - {% endfor %} - -
    Roll No.NameCompanyCTCInvitation Status
    - {{ student.unique_id.id.id }} - - {{ student.unique_id.id.user.first_name }} {{ student.unique_id.id.user.last_name }} - - {{ student.notify_id.company_name }} - - {{ student.notify_id.ctc }} - - {{ student.invitation }} -
    -
    - - - - - - diff --git a/FusionIIIT/templates/placementModule/pdf_student_record.html b/FusionIIIT/templates/placementModule/pdf_student_record.html deleted file mode 100644 index 3a9616b9b..000000000 --- a/FusionIIIT/templates/placementModule/pdf_student_record.html +++ /dev/null @@ -1,174 +0,0 @@ -{% load static %} - - - - - Student Record - - - - - -

    Student Record

    -
    - - - - - - - - - - - - - - - {% for student in students %} - - - - - - - - - - - - - - - - {% endfor %} - -
    Roll No.NameCPIDepartmentDisciplinePlacedDebarred
    - {{ student.id.id }} - - {{ student.id.user.first_name}} {{ student.id.user.last_name }} - - {{ student.cpi }} - - {{ student.programme }} - - {{ student.id.department.name }} - - {% if student.studentplacement.unique_id == student %} - {% if student.studentplacement.placed_type == "PLACED" %} - Yes - {% else %} - No - {% endif %} - {% endif %} - - {% if student.studentplacement.unique_id == student %} - {% if student.studentplacement.placed_type == "DEBAR" %} - Yes - {% else %} - No - {% endif %} - {% endif %} -
    - -
    - - - - - - diff --git a/FusionIIIT/templates/placementModule/placement.html b/FusionIIIT/templates/placementModule/placement.html deleted file mode 100644 index a6707bb65..000000000 --- a/FusionIIIT/templates/placementModule/placement.html +++ /dev/null @@ -1,945 +0,0 @@ -{% extends 'globals/base.html' %} -{% load static %} - - -{% block title %} - Placement Schedule -{% endblock %} - - -{% block body %} - {% block navBar %} - {% include 'dashboard/navbar.html' %} - {% endblock %} - - {% comment %}The grid starts here!{% endcomment %} -
    - - {% comment %}The left-margin segment!{% endcomment %} -
    - - {% comment %} - The left-rail segment starts here! - {% endcomment %} -
    - - {% comment %}The user image card starts here!{% endcomment %} - {% block usercard %} - {% include 'globals/usercard.html' %} - {% endblock %} - {% comment %}The user image card ends here!{% endcomment %} - -
    - - {% comment %}The Tab-Menu starts here!{% endcomment %} - - {% comment %}The Tab-Menu ends here!{% endcomment %} - -
    - {% comment %} - The left-rail segment ends here! - {% endcomment %} - - - {% comment %} The central-rail segment starts here!{% endcomment %} -
    - - - -
    - {% for c3 in current2 %} - {% if c3.designation.name == 'placement officer' %} -
    -
    - -
    - -
    -
    - -
    - - -
    -
    - {% endif %} - {% endfor %} - - - {% for c in current1 %} - {% if c.designation.name == 'placement chairman' %} -
    -
    - -
    - -
    -
    - -
    - - -
    -
    - {% endif %} - {% endfor %} - - -
    - {% for c in current %} - {% if c.designation.name == 'student' %} - {% if schedules %} - {% for sch in schedules|dictsortreversed:"schedule_at" %} -
    - -
    -
    -
    -

    {{ sch.notify_id.placement_type }} | {{ sch.notify_id.company_name }} | {{ sch.placement_date }}


    - Time of Message: {{ sch.notify_id.timestamp }} - -
    -
    -
    -

    CTC (LPA): {{ sch.notify_id.ctc }}

    -

    {{ sch.title }} | {{ sch.role.role }}

    -

    Date/Time & Location: {{ sch.placement_date }} | {{ sch.time }} | {{ sch.location }}

    -

    Job Description: {{ sch.description }}

    -
    -
    -

    -
    - {% endfor %} - {% else %} -
    No Active Schedule.
    - {% endif %} - - - {% endif %} - {% endfor %} - - {% for c1 in current1 %} - {% if c1.designation.name == 'placement chairman' %} - {% if schedules %} -
    -
    - - {% for sch in schedules|dictsortreversed:"schedule_at" %} -
    - -
    -
    -
    -

    {{ sch.notify_id.placement_type }} | {{ sch.notify_id.company_name }} | {{ sch.placement_date }}


    - Time of Message: {{ sch.notify_id.timestamp }} - -
    -
    -
    -

    CTC (LPA): {{ sch.notify_id.ctc }}

    -

    {{ sch.title }} | {{ sch.role.role }}

    -

    Date/Time & Location: {{ sch.placement_date }} | {{ sch.time }} | {{ sch.location }}

    -

    Job Description: {{ sch.description }}

    -
    -
    -

    -
    - {% endfor %} - -
    - - {% endif %} - {% endif %} - {% endfor %} - - - - {% for c1 in current2 %} - {% if c1.designation.name == 'placement officer' %} - {% if schedules %} - -
    -
    - - {% for sch in schedules|dictsortreversed:"schedule_at" %} -
    - -
    -
    -
    - -
    - {% csrf_token %} - - -
    - -

    {{ sch.notify_id.placement_type }} | {{ sch.notify_id.company_name }} | {{ sch.placement_date }}


    - - Time of Message: {{ sch.notify_id.timestamp }} - -
    -
    -
    -

    CTC (LPA): {{ sch.notify_id.ctc }}

    -

    {{ sch.title }} | {{ sch.get_role }}

    -

    Date/Time & Location: {{ sch.placement_date }} | {{ sch.time }} | {{ sch.location }}

    -

    Job Description: {{ sch.description }}

    -
    - {% if sch.attached_file %} - download - {% endif %} -
    -

    -
    - {% endfor %} - -
    - - {% endif %} - {% endif %} - {% endfor %} -
    -
    - - {% for c3 in current2 %} - {% if c3.designation.name == 'placement officer'%} -
    -
    - {% if form5.errors %} -
    - -
    - There were some errors with your submission -
    -
      - {% if form5.company_name.errors %} -
    • {{ form5.company_name.errors }}
    • - {% endif %} - - {% if form5.placement_date.errors %} -
    • {{ form5.placement_date.errors }}
    • - {% endif %} - - {% if form5.location.errors %} -
    • {{ form5.location.errors }}
    • - {% endif %} - - {% if form5.ctc.errors %} -
    • {{ form5.ctc.errors }}
    • - {% endif %} - - {% if form5.time.errors %} -
    • {{ form5.time.errors }}
    • - {% endif %} - - {% if form5.placement_type.errors %} -
    • {{ form5.placement_type.errors }}
    • - {% endif %} - - {% if form5.description.errors %} -
    • {{ form5.description.errors }}
    • - {% endif %} -
    -
    - {% endif %} - - {% comment %}Form Tag starts here!{% endcomment %} -
    - {% comment %}The add a new skill Accordian starts here!{% endcomment %} -
    -
    - - Add a new Event! -
    - -
    -
    - {% csrf_token %} -
    -
    - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form5.company_name }} - - - -
    -
    - -
    - -
    -
    - - {{ form5.placement_date }} -
    -
    -
    - -
    - -
    - {% comment %}The Location input!{% endcomment %} - {{ form5.location }} -
    -
    -
    -
    -
    - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form5.ctc }} -
    -
    -
    - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form5.time }} -
    -
    -
    - -
    - {% comment %}The Location input!{% endcomment %} - {{ form5.placement_type }} -
    -
    -
    -
    - - -
    - {% comment %}The Location input!{% endcomment %} - {{ form5.description }} -
    -
    -
    - - - - - - -
    - -
    - - -
    - -
    -
    - -
    -
    -
    - {% comment %}The add a new Achievement Accordian ends here!{% endcomment %} -
    - {% comment %}Form Tag ends here!{% endcomment %} -
    -
    - {% endif %} - {% endfor %} - - {% for c1 in current1 %} - {% if c1.designation.name == 'placement chairman'%} -
    -
    - - {% comment %}Form Tag starts here!{% endcomment %} -
    - {% comment %}The add a new skill Accordian starts here!{% endcomment %} -
    -
    - - Add a new Event! -
    - {{ form5.non_field_errors }} -
    -
    - {% csrf_token %} -
    -
    - {{ form5.company_name.errors }} - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form5.company_name }} -
    -
    - -
    - {{ form5.placement_date.errors }} - -
    -
    - - {{ form5.placement_date }} -
    -
    -
    - -
    - {{ form5.location.errors }} - -
    - {% comment %}The Location input!{% endcomment %} - {{ form5.location }} -
    -
    -
    -
    -
    - {{ form5.ctc.errors }} - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form5.ctc }} -
    -
    -
    - {{ form5.time.errors }} - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form5.time }} -
    -
    -
    - {{ form5.placement_type.errors }} - -
    - {% comment %}The Location input!{% endcomment %} - {{ form5.placement_type }} -
    -
    -
    -
    - {{ form5.description.errors }} - - -
    - {% comment %}The Location input!{% endcomment %} - {{ form5.description }} -
    -
    -
    - -
    - -
    -
    - -
    -
    -
    - {% comment %}The add a new Achievement Accordian ends here!{% endcomment %} -
    - {% comment %}Form Tag ends here!{% endcomment %} -
    -
    - {% endif %} - {% endfor %} -
    - - - {% for c in current %} - {% if c.designation.name == 'student' %} - {% comment %}The right-rail segment starts here!{% endcomment %} -
    -
    - {% comment %} - TODO: the right rail! - {% endcomment %} - - {% comment %}Generate CV {% endcomment %} -
    -
    - - Download a C.V.? -
    -
    -
    -
    - {{ form.non_field_errors }} - {% csrf_token %} -
    - - -
    -

    -
    - - -
    -

    -
    - - -
    -

    -
    - - -
    -

    -
    - - -
    -

    -
    - - -
    -

    -
    - - -
    -

    -
    - - -
    -

    -
    - - -
    -

    -
    - - -
    -

    -
    - - -
    -
    - -
    -

    - - -
    -
    -
    - -
    - -
    - - {% block interviewrequest %} - {% include 'placementModule/interviewrequest.html' %} - {% endblock %} - -
    - - {% comment %}The right-rail segment ends here!{% endcomment %} - {% endif %} - {% endfor %} - {% comment %}The right-margin segment!{% endcomment %} -
    -
    - - -{% endblock %} - - -{% block javascript %} - - - - - - - - - - - - - - - -{% endblock %} diff --git a/FusionIIIT/templates/placementModule/placementstatistics.html b/FusionIIIT/templates/placementModule/placementstatistics.html deleted file mode 100644 index 5a8f3095f..000000000 --- a/FusionIIIT/templates/placementModule/placementstatistics.html +++ /dev/null @@ -1,917 +0,0 @@ -{% extends 'globals/base.html' %} -{% load static %} - - -{% block title %} - Placement Schedule -{% endblock %} - - -{% block body %} - {% block navBar %} - {% include 'dashboard/navbar.html' %} - {% endblock %} - - {% comment %}The grid starts here!{% endcomment %} -
    - - {% comment %}The left-margin segment!{% endcomment %} -
    - - {% comment %} - The left-rail segment starts here! - {% endcomment %} -
    - - {% comment %}The user image card starts here!{% endcomment %} - {% block usercard %} - {% include 'globals/usercard.html' %} - {% endblock %} - {% comment %}The user image card ends here!{% endcomment %} - -
    - - {% comment %}The Tab-Menu starts here!{% endcomment %} - - {% comment %}The Tab-Menu ends here!{% endcomment %} - -
    - - - {% comment %} The central-rail segment starts here!{% endcomment %} -
    - {% comment %}The tab menu starts here!{% endcomment %} - - -
    -
    - - - - - - - - - - -
    -
    - -
    -
    - - - {% for year in years %} - {% if forloop.first %} -
    - {% else %} -
    - {% endif %} - - - - - - - - - - - - {% for record in records %} - {% if record.year == year.year %} - {% if record.placement_type != "HIGHER STUDIES" %} - - - - - - - {% endif %} - {% endif %} - {% endfor %} - - - - -
    Package
    {{ record.name }}{{ record.ctc }}
    -
    -
    -
    - - {% endfor %} - -
    -
    -
    - -
    -
    - -{% endblock %} diff --git a/FusionIIIT/templates/placementModule/placementvisits.html b/FusionIIIT/templates/placementModule/placementvisits.html deleted file mode 100644 index 9a6c5bd68..000000000 --- a/FusionIIIT/templates/placementModule/placementvisits.html +++ /dev/null @@ -1,148 +0,0 @@ -{% block placementvisits %} - {% comment %}The tab menu starts here!{% endcomment %} - - {% comment %}The tab menu ends here!{% endcomment %} - -
    -
    - {% comment %}Form Tag starts here!{% endcomment %} -
    - {% comment %}The add a new skill Accordian starts here!{% endcomment %} -
    -
    - - Add a new Placement Visit! -
    - - {{ form.non_field_errors }} -
    -
    - {% csrf_token %} -
    -
    - {{ form.company_name.errors }} - - -
    - {{ form.company_name }} -
    -
    - -
    - {{ form.visiting_date.errors }} - -
    -
    - - {{ form.visiting_date }} -
    -
    -
    -
    - - {% comment %} A new row starts here!{% endcomment %} -
    - {{ form.description.errors }} - -
    - - {{ form.description }} -
    -
    - - {% comment %} A new row starts here!{% endcomment %} -
    -
    - {{ form.location.errors }} - - -
    - {{ form.location }} -
    -
    - - {% comment %}A new row starts here!{% endcomment %} -
    - -
    -
    - -
    -
    -
    - {% comment %}The add a new skill Accordian ends here!{% endcomment %} -
    - {% comment %}Form Tag ends here!{% endcomment %} -
    -
    - -
    -
    - {% comment %} - TODO: can add a list also instead like mess module! - {% endcomment %} - - - - - - - {% comment %} - TODO: Concatinate city and location here! - {% endcomment %} - - - - - - - {% for chairmanvisit in chairmanvisits %} - - - - - - - - - - - - {% endfor %} - -
    DateCompany NameLocationDescriptionDelete
    - {{ chairmanvisit.visiting_date }} - - {{ chairmanvisit.company_name }} - - {{ chairmanvisit.location }} - - {{ chairmanvisit.description }} - -
    - {% csrf_token %} - -
    -
    -
    -
    - -{% endblock %} diff --git a/FusionIIIT/templates/placementModule/reference.html b/FusionIIIT/templates/placementModule/reference.html deleted file mode 100644 index 68f25be3c..000000000 --- a/FusionIIIT/templates/placementModule/reference.html +++ /dev/null @@ -1,131 +0,0 @@ -{% block reference %} -
    -
    - Reference -
    - -
    -
    - - - - {% comment %} - TODO: Add a proper JS logic for Reference! - {% endcomment %} - -
    - {% comment %}The add a new Reference Accordian starts here!{% endcomment %} -
    -
    - - Add a new Reference -
    - {{ form15.non_field_errors }} -
    -
    - {% csrf_token %} -
    -
    - {{ form15.reference_name.errors }} - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form15.reference_name }} -
    -
    - -
    - {{ form15.post.errors }} - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form15.post }} -
    -
    -
    - -
    -
    - {{ form15.email.errors }} - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form15.email }} -
    -
    - -
    - {{ form15.mobile_number.errors }} - -
    - {% comment %}The Institute Name input!{% endcomment %} - {{ form15.mobile_number }} -
    -
    -
    - -
    - - -
    -
    -
    -
    - {% comment %}The add a new skill Accordian ends here!{% endcomment %} -
    - {% if references %} -
    - - - - - - - - - - - - - {% for reference in references %} - - - - - - - - - - - {% endfor %} - -
    NamePostEmailMobile NumberDelete
    - {{ reference.reference_name }} - - {{ reference.post }} - - {{ reference.email }} - - {{ reference.mobile_number }} - -
    - {% csrf_token %} - -
    -
    - {% endif %} - -
    -{% endblock %} diff --git a/FusionIIIT/templates/placementModule/studentrecords.html b/FusionIIIT/templates/placementModule/studentrecords.html deleted file mode 100644 index 1adf26dbc..000000000 --- a/FusionIIIT/templates/placementModule/studentrecords.html +++ /dev/null @@ -1,1105 +0,0 @@ -{% extends 'globals/base.html' %} -{% load static %} - - -{% block title %} - Placement Schedule -{% endblock %} - - -{% block body %} - {% block navBar %} - {% include 'dashboard/navbar.html' %} - {% endblock %} - - {% comment %}The grid starts here!{% endcomment %} -
    - - {% comment %}The left-margin segment!{% endcomment %} -
    - - {% comment %} - The left-rail segment starts here! - {% endcomment %} -
    - - {% comment %}The user image card starts here!{% endcomment %} - {% block usercard %} - {% include 'globals/usercard.html' %} - {% endblock %} - {% comment %}The user image card ends here!{% endcomment %} - -
    - - {% comment %}The Tab-Menu starts here!{% endcomment %} - - {% comment %}The Tab-Menu ends here!{% endcomment %} - -
    - {% comment %} - The left-rail segment ends here! - {% endcomment %} - - - {% comment %} The central-rail segment starts here!{% endcomment %} -
    - - {% comment %}The tab menu starts here!{% endcomment %} - - - {% if not students and student_record_check == 1 %} -
    - -
    - There is no such record of this type. -
    -
      -
    • try changing some fields or,
    • -
    • include some more fields in search
    • -
    -
    - {% endif %} - - - - -
    -
    - {{ form13.non_field_errors }} - {% csrf_token %} -
    - {{ form13.company.errors }} - -
    - {{ form13.company }} -
    -
    - -
    -
    - {{ form13.programme.errors }} - - {{ form13.programme }} -
    - -
    -
    - {{ form13.dep_btech.errors }} - - {{ form13.dep_btech }} -
    - -
    - {{ form13.dep_bdes.errors }} - - {{ form13.dep_bdes }} -
    - -
    - {{ form13.dep_mtech.errors }} - - {{ form13.dep_mtech }} -
    - -
    - {{ form13.dep_mdes.errors }} - - {{ form13.dep_mdes }} -
    - -
    - {{ form13.dep_phd.errors }} - - {{ form13.dep_phd }} -
    -
    - -
    - {{ form13.rollno.errors }} - - {{ form13.rollno }} -
    - -
    - {{ form13.cpi.errors }} - - {{ form13.cpi }} -
    - -
    -
    -
    - {{ form13.no_of_days.errors }} - - {{ form13.no_of_days }} -
    -
    -
    -
    -

    - -
    -
    -

    -
    -
    - -
    - - -
    - {% if not placementstatus_placement and officer_manage %} -
    - -
    - There is no such record of this type. -
    -
      -
    • try changing some fields or,
    • -
    • include some more fields in search
    • -
    -
    - {% endif %} -
    -
    -
    -
    - - Manage Placement Records! -
    - {{ form11.non_field_errors }} -
    -
    - {% csrf_token %} -
    -
    - {{ form11.stuname.errors }} - -
    - {{ form11.stuname }} -
    -
    - -
    - {{ form11.roll.errors }} - -
    - {{ form11.roll }} -
    -
    -
    - - {% comment %}The start and end date input{% endcomment %} -
    -
    - {{ form11.company.errors }} - -
    - {{ form11.company }} -
    -
    - -
    - {{ form11.ctc.errors }} - -
    - {{ form11.ctc }} -
    -
    -
    - -
    - -
    - -
    -
    -
    -
    -
    -
    - - {% if placementstatus_placement %} -
    - - {% if no_pagination %} -
    - -
    - {% endif %} - -
    -
    - - - - - - - - - - - - - - {% for student in placementstatus_placement %} - {% if student.notify_id.placement_type == 'PLACEMENT' and student.invitation == "ACCEPTED" %} - - - - - - - - - - - - {% endif %} - {% endfor %} - - -
    StudentCompanyCTC (LPA)InvitationDelete
    -

    - -
    - {{ student.unique_id.id.user.first_name }} {{ student.unique_id.id.user.last_name }} -
    - {{ student.unique_id.id.id }} -
    -
    -

    -
    - {{ student.notify_id.company_name }} - - {{ student.notify_id.ctc }} - - {{ student.invitation }} - -
    - {% csrf_token %} - -
    -
    - {% endif %} - -
    -
    - -
    - {% if not placementstatus_pbi and mnpbi_post %} -
    - -
    - There is no such record of this type. -
    -
      -
    • try changing some fields or,
    • -
    • include some more fields in search
    • -
    -
    - {% endif %} -
    -
    -
    -
    - - Manage PBI Records! -
    - {{ form9.non_field_errors }} -
    -
    - {% csrf_token %} -
    -
    - {{ form9.stuname.errors }} - -
    - {{ form9.stuname }} -
    -
    - -
    - {{ form9.roll.errors }} - -
    - {{ form9.roll }} -
    -
    -
    - - {% comment %}The start and end date input{% endcomment %} -
    -
    - {{ form9.company.errors }} - -
    - {{ form9.company }} -
    -
    - -
    - {{ form9.ctc.errors }} - -
    - {{ form9.ctc }} -
    -
    -
    - -
    - -
    - -
    -
    -
    -
    -
    -
    - - {% if placementstatus_pbi %} -
    - - - - - {% if no_pagination %} - -
    - -
    - {% endif %} - -
    - -
    - - - - - - - - - - - - - {% for student in placementstatus_pbi %} - {% if student.notify_id.placement_type == 'PBI' %} - - - - - - - - - - - - {% endif %} - {% endfor %} - -
    StudentCompanyCTC (LPA)InvitationDelete
    -

    - -
    - {{ student.unique_id.id.user.first_name }} {{ student.unique_id.id.user.last_name }} -
    - {{ student.unique_id.id.id }} -
    -
    -

    -
    - {{ student.notify_id.company_name }} - - {{ student.notify_id.ctc }} - - {{ student.invitation }} - -
    - {% csrf_token %} - - -
    -
    - - {% endif %} -
    -
    - -
    - - {% if students %} -
    -
    -
    - - - {% endif %} - - {% if invitecheck == 1 %} -
    -
    - Notification Sent! -
    -

    Placement Invitation is sent to selected students.

    -
    - {% endif %} - - -
    -
    - - -{% endblock %} - -{% block javascript %} - - - - -{% endblock %} diff --git a/FusionIIIT/templates/programme_curriculum/acad_admin/common.html b/FusionIIIT/templates/programme_curriculum/acad_admin/common.html index d6fd581f2..8107d27bf 100644 --- a/FusionIIIT/templates/programme_curriculum/acad_admin/common.html +++ b/FusionIIIT/templates/programme_curriculum/acad_admin/common.html @@ -54,6 +54,9 @@ Course Instructor + Theses + +
    {% endblock %} {% comment %}The Tab-Menu ends here!{% endcomment %} diff --git a/FusionIIIT/test_settings.py b/FusionIIIT/test_settings.py new file mode 100644 index 000000000..15d57089c --- /dev/null +++ b/FusionIIIT/test_settings.py @@ -0,0 +1,47 @@ +""" +Test settings for the placement_cell test suite (and any other app tests). + +Why this file exists +-------------------- +The project's historical migrations do not apply cleanly on a fresh database +(e.g. ``programme_curriculum.0026`` references ``course_registration`` before it +exists). Django's test runner builds the test database by replaying every +migration, so tests would fail during DB setup for reasons unrelated to the code +under test. + +To keep the test suite reliable now and in the future, we disable migrations for +the test database and let Django create the schema directly from the current +model state. This is a standard, well-supported pattern and is also much faster. + +It does NOT touch the production settings (common/development/production.py). + +Run the placement suite (list the modules explicitly -- ``applications`` has no +``__init__.py`` so unittest package discovery cannot introspect it):: + + python manage.py test \ + applications.placement_cell.tests.test_placement_api \ + applications.placement_cell.tests.test_use_cases \ + applications.placement_cell.tests.test_business_rules \ + applications.placement_cell.tests.test_workflows \ + applications.placement_cell.tests.test_module \ + --settings=test_settings +""" + +from Fusion.settings.development import * # noqa: F401,F403 + + +class DisableMigrations: + """Make every app create its schema from models instead of migrations.""" + + def __contains__(self, item): + return True + + def __getitem__(self, item): + return None + + +MIGRATION_MODULES = DisableMigrations() + +# Faster, deterministic tests. +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] +DEBUG = False diff --git a/requirements.txt b/requirements.txt index 4b97f144f..21a3bfdec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -74,3 +74,4 @@ xlwt==1.3.0 django-crontab==0.7.1 zipfile2==0.0.12 pandas==2.0.3 +PyYAML==5.4.1 # placement_cell spec-driven test suite