Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions FusionIIIT/applications/academic_procedures/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,25 @@
url(r'^dean/thesis/(?P<pk>\d+)/review/$', views.dean_review_api, name='dean-thesis-review'),
url(r'^dean/thesis/(?P<pk>\d+)/generate/$', views.dean_generate_pdf_api, name='dean-thesis-generate'),

# Thesis Topic Change Request endpoints
url(r'^stu/thesis/change-requests/$', views.student_thesis_change_requests, name='student-thesis-change-requests'),
url(r'^stu/thesis/change-request/$', views.student_thesis_change_request_create, name='student-thesis-change-request-create'),
url(r'^faculty/thesis/change-requests/pending-consent/$', views.faculty_thesis_change_requests_pending_consent, name='faculty-thesis-change-requests-pending-consent'),
url(r'^faculty/thesis/change-request/(?P<pk>\d+)/consent/$', views.thesis_change_request_consent, name='thesis-change-request-consent'),
url(r'^hod/thesis/change-requests/$', views.hod_thesis_change_request_dashboard, name='hod-thesis-change-request-dashboard'),
url(r'^hod/thesis/change-request/(?P<pk>\d+)/review/$', views.hod_thesis_change_request_review, name='hod-thesis-change-request-review'),
url(r'^dean/thesis/change-requests/$', views.dean_thesis_change_request_dashboard, name='dean-thesis-change-request-dashboard'),
url(r'^dean/thesis/change-request/(?P<pk>\d+)/review/$', views.dean_thesis_change_request_review, name='dean-thesis-change-request-review'),

# Thesis Committee (RPC) Change Request endpoints
url(r'^supervisor/thesis/committee-change-requests/$', views.supervisor_committee_change_requests, name='supervisor-committee-change-requests'),
url(r'^supervisor/thesis/(?P<thesis_pk>\d+)/committee-change/propose/$', views.supervisor_propose_committee_change, name='supervisor-propose-committee-change'),
url(r'^supervisor/thesis/committee-change/(?P<pk>\d+)/resubmit/$', views.supervisor_resubmit_committee_change, name='supervisor-resubmit-committee-change'),
url(r'^hod/thesis/committee-change-requests/$', views.hod_committee_change_dashboard, name='hod-committee-change-dashboard'),
url(r'^hod/thesis/committee-change/(?P<pk>\d+)/review/$', views.hod_committee_change_review, name='hod-committee-change-review'),
url(r'^dean/thesis/committee-change-requests/$', views.dean_committee_change_dashboard, name='dean-committee-change-dashboard'),
url(r'^dean/thesis/committee-change/(?P<pk>\d+)/review/$', views.dean_committee_change_review, name='dean-committee-change-review'),

# PhD Seminar endpoints

# Student
Expand Down
875 changes: 872 additions & 3 deletions FusionIIIT/applications/academic_procedures/api/views.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Generated by Django 3.1.5 on 2026-09-07 15:38

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('globals', '0012_acad_role_module_access'),
('academic_procedures', '0054_merge_20260828_1758'),
]

operations = [
migrations.CreateModel(
name='ThesisTopicChangeRequest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('new_category', models.CharField(blank=True, choices=[('Regular', 'Regular'), ('Sponsored', 'Sponsored'), ('External', 'External')], max_length=20)),
('new_broad_area', models.CharField(blank=True, max_length=200)),
('new_research_theme', models.TextField(blank=True)),
('current_supervisor_consented', models.BooleanField(default=False)),
('current_co_supervisor_consented', models.BooleanField(default=False)),
('new_supervisor_consented', models.BooleanField(default=False)),
('new_co_supervisor_consented', models.BooleanField(default=False)),
('decline_remarks', models.TextField(blank=True)),
('status', models.CharField(choices=[('pending_consents', 'Pending Consents'), ('declined', 'Declined by a Consenter'), ('hod_pending', 'Pending with HOD'), ('hod_rejected', 'Rejected by HOD'), ('dean_pending', 'Pending with Dean'), ('dean_rejected', 'Rejected by Dean'), ('approved', 'Approved')], default='pending_consents', max_length=20)),
('hod_remarks', models.TextField(blank=True)),
('hod_at', models.DateTimeField(blank=True, null=True)),
('dean_remarks', models.TextField(blank=True)),
('dean_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('dean_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_change_dean_reviews', to=settings.AUTH_USER_MODEL)),
('declined_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_change_declines', to=settings.AUTH_USER_MODEL)),
('hod_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_change_hod_reviews', to=settings.AUTH_USER_MODEL)),
('new_co_supervisor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='thesis_change_requests_as_new_co_supervisor', to='globals.faculty')),
('new_supervisor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='thesis_change_requests_as_new_supervisor', to='globals.faculty')),
('thesis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='change_requests', to='academic_procedures.thesistopic')),
],
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 3.1.5 on 2026-09-08 09:08

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('globals', '0012_acad_role_module_access'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('academic_procedures', '0055_thesistopicchangerequest'),
]

operations = [
migrations.CreateModel(
name='ThesisCommitteeChangeRequest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('hod_pending', 'Pending with HOD'), ('hod_rejected', 'Rejected by HOD, Returned to Supervisor'), ('dean_pending', 'Pending with Dean'), ('dean_rejected', 'Rejected by Dean, Returned to Supervisor'), ('approved', 'Approved')], default='hod_pending', max_length=20)),
('hod_remarks', models.TextField(blank=True)),
('hod_at', models.DateTimeField(blank=True, null=True)),
('dean_remarks', models.TextField(blank=True)),
('dean_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('dean_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_committee_change_dean_reviews', to=settings.AUTH_USER_MODEL)),
('hod_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thesis_committee_change_hod_reviews', to=settings.AUTH_USER_MODEL)),
('members', models.ManyToManyField(related_name='thesis_committee_change_requests', to='globals.Faculty')),
('thesis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='committee_change_requests', to='academic_procedures.thesistopic')),
],
),
]
121 changes: 121 additions & 0 deletions FusionIIIT/applications/academic_procedures/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,127 @@ def __str__(self):
return f"{self.member} on {self.thesis}"


class ThesisTopicChangeRequest(models.Model):
"""Student-initiated request to change an already dean_approved ThesisTopic's
topic (category/broad_area/research_theme) and/or supervisor/co-supervisor.

Follows the same consent -> HOD -> Dean chain as the original ThesisTopic
submission, plus consent from any newly-proposed supervisor/co-supervisor
(the outgoing supervisor/co-supervisor, if any, is covered by the
current_* consent -- they're always asked to sign off on the change,
whatever it is). On Dean approval the changes are applied directly to the
live ThesisTopic (and its CommitteeMember rows kept in sync); this record
stays behind as the audit trail. Rejection at any stage is terminal -- the
student submits a fresh request to retry, rather than editing this one in
place (unlike ThesisTopic itself, which supports resubmission-in-place).
"""
STATUS_CHOICES = [
('pending_consents', 'Pending Consents'),
('declined', 'Declined by a Consenter'),
('hod_pending', 'Pending with HOD'),
('hod_rejected', 'Rejected by HOD'),
('dean_pending', 'Pending with Dean'),
('dean_rejected', 'Rejected by Dean'),
('approved', 'Approved'),
]

thesis = models.ForeignKey(ThesisTopic, related_name='change_requests', on_delete=models.CASCADE)

# Topic-change fields -- blank means "no change requested" for that field.
new_category = models.CharField(max_length=20, blank=True, choices=[
('Regular', 'Regular'),
('Sponsored', 'Sponsored'),
('External', 'External'),
])
new_broad_area = models.CharField(max_length=200, blank=True)
new_research_theme = models.TextField(blank=True)

# Supervisor-change fields -- null means "no change requested".
new_supervisor = models.ForeignKey(
Faculty, related_name='thesis_change_requests_as_new_supervisor',
on_delete=models.CASCADE, null=True, blank=True,
)
new_co_supervisor = models.ForeignKey(
Faculty, related_name='thesis_change_requests_as_new_co_supervisor',
on_delete=models.CASCADE, null=True, blank=True,
)

# Consents. current_* apply to the thesis's supervisor/co-supervisor as of
# the request -- always required (current_co_supervisor_consented is only
# actually checked if the thesis has a co-supervisor). new_* are only
# checked if that field is actually being changed on this request.
current_supervisor_consented = models.BooleanField(default=False)
current_co_supervisor_consented = models.BooleanField(default=False)
new_supervisor_consented = models.BooleanField(default=False)
new_co_supervisor_consented = models.BooleanField(default=False)
decline_remarks = models.TextField(blank=True)
declined_by = models.ForeignKey(
'auth.User', null=True, blank=True,
on_delete=models.SET_NULL, related_name='thesis_change_declines',
)

status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending_consents')
hod_remarks = models.TextField(blank=True)
hod_by = models.ForeignKey(
'auth.User', null=True, blank=True,
on_delete=models.SET_NULL, related_name='thesis_change_hod_reviews',
)
hod_at = models.DateTimeField(null=True, blank=True)
dean_remarks = models.TextField(blank=True)
dean_by = models.ForeignKey(
'auth.User', null=True, blank=True,
on_delete=models.SET_NULL, related_name='thesis_change_dean_reviews',
)
dean_at = models.DateTimeField(null=True, blank=True)

created_at = models.DateTimeField(auto_now_add=True)

def __str__(self):
return f"Change request for {self.thesis} ({self.status})"


class ThesisCommitteeChangeRequest(models.Model):
"""Supervisor-initiated request to change the RPC committee membership
for an already dean_approved ThesisTopic. The supervisor proposes a
complete new committee (replacing the current one, supervisor/
co-supervisor always kept as members regardless of what's submitted) ->
HOD -> Dean Academic approval, which applies the new membership to
CommitteeMember. Rejection (HOD or Dean) sends it back to the supervisor
to edit and resubmit the same record in place -- mirrors ThesisTopic's
own resubmission semantics, not a fresh request like
ThesisTopicChangeRequest.
"""
STATUS_CHOICES = [
('hod_pending', 'Pending with HOD'),
('hod_rejected', 'Rejected by HOD, Returned to Supervisor'),
('dean_pending', 'Pending with Dean'),
('dean_rejected', 'Rejected by Dean, Returned to Supervisor'),
('approved', 'Approved'),
]

thesis = models.ForeignKey(ThesisTopic, related_name='committee_change_requests', on_delete=models.CASCADE)
members = models.ManyToManyField(Faculty, related_name='thesis_committee_change_requests')

status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='hod_pending')
hod_remarks = models.TextField(blank=True)
hod_by = models.ForeignKey(
'auth.User', null=True, blank=True,
on_delete=models.SET_NULL, related_name='thesis_committee_change_hod_reviews',
)
hod_at = models.DateTimeField(null=True, blank=True)
dean_remarks = models.TextField(blank=True)
dean_by = models.ForeignKey(
'auth.User', null=True, blank=True,
on_delete=models.SET_NULL, related_name='thesis_committee_change_dean_reviews',
)
dean_at = models.DateTimeField(null=True, blank=True)

created_at = models.DateTimeField(auto_now_add=True)

def __str__(self):
return f"Committee change request for {self.thesis} ({self.status})"


class ProgressSeminarEntry(models.Model):
"""PhD Seminar reports with versioning and RPC approval."""
thesis = models.ForeignKey(ThesisTopic, on_delete=models.CASCADE, related_name='seminars')
Expand Down
85 changes: 83 additions & 2 deletions FusionIIIT/applications/examination/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,41 @@ def is_valid_grade(grade: str, course_code: str) -> bool:
return grade in ALLOWED_GRADES


SPECIAL_NUMERIC_GRADE_CODES = {"PR4001", "PR4002", "BTP4001"}
LETTER_GRADE_SCHEME = {"O", "A+", "A", "B+", "B", "C+", "C", "D+", "D", "F", "CD"}
PASS_FAIL_GRADE_SCHEME = {"S", "X"}
SCHEME_LABELS = {
"letter": "letter grades (O/A+/A/.../F/CD)",
"pass_fail": "pass/fail grades (S/X)",
}


def grade_scheme(grade):
"""Classify a grade string as 'letter' or 'pass_fail'. Returns None for an
unrecognized value, or for a PR4001/PR4002/BTP4001 numeric grade -- those use
their own separate scheme (checked entirely by is_valid_grade's course-code
branch) and aren't subject to the letter-vs-pass/fail consistency check."""
g = (grade or "").strip().upper()
if g in LETTER_GRADE_SCHEME:
return "letter"
if g in PASS_FAIL_GRADE_SCHEME:
return "pass_fail"
return None


def established_grade_scheme(course_id, academic_year, semester_type):
"""The letter/pass-fail scheme already on file for this course+year+semester
(from any existing graded Student_grades row), or None if nothing is graded
yet -- in which case the first valid row of a new upload sets the scheme."""
existing = (
Student_grades.objects
.filter(course_id=course_id, academic_year=academic_year, semester_type=semester_type)
.exclude(grade__isnull=True).exclude(grade='')
.first()
)
return grade_scheme(existing.grade) if existing else None


# Remark shown next to a grade once a course has more than one linked attempt:
# R = repeat of the same course, qualified by why -- BL after a fail, CD when the
# earlier attempt was dropped, IM on an already-cleared course; S = substitute,
Expand Down Expand Up @@ -2131,6 +2166,10 @@ def post(self, request):
)

# 10) ATOMIC PROCESSING
is_special_numeric_course = course.code.strip().upper() in SPECIAL_NUMERIC_GRADE_CODES
csv_scheme = None if is_special_numeric_course else established_grade_scheme(
course_id, academic_year, semester_type
)
errors = []
with transaction.atomic():
# Reset reSubmit only for this submission's roster (already scoped
Expand Down Expand Up @@ -2209,6 +2248,19 @@ def post(self, request):
)
continue

# c2) SAME SCHEME AS THE REST OF THIS COURSE? (no mixing letter
# grades with pass/fail S/X within one course+year+semester)
if not is_special_numeric_course:
row_scheme = grade_scheme(grade)
if csv_scheme is None:
csv_scheme = row_scheme
elif row_scheme != csv_scheme:
errors.append(
f"Row {idx}: Grade scheme mismatch for {roll_no} — this course is using "
f"{SCHEME_LABELS[csv_scheme]}, but '{grade}' is a {SCHEME_LABELS[row_scheme]} value."
)
continue

# d) DETERMINE SEMESTER & BATCH
semester = sem_csv or stud.curr_semester_no
batch = stud.batch
Expand Down Expand Up @@ -3321,6 +3373,11 @@ def post(self, request):
status=status.HTTP_400_BAD_REQUEST,
)

try:
course_obj = Courses.objects.get(id=course_id)
except Courses.DoesNotExist:
return Response({"error": "Invalid course ID."}, status=status.HTTP_400_BAD_REQUEST)

# Faculty may only preview grades for courses they teach; acadadmin/Dean: any.
if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]):
try:
Expand Down Expand Up @@ -3404,21 +3461,45 @@ def post(self, request):
status=status.HTTP_400_BAD_REQUEST,
)

is_special_numeric_course = course_obj.code.strip().upper() in SPECIAL_NUMERIC_GRADE_CODES
seen_scheme = None if is_special_numeric_course else established_grade_scheme(
course_id, academic_year, semester_type
)

preview_rows = []
for row in reader:
roll_no = row["roll_no"]
# Check if the current roll number is registered.
is_registered = roll_no in registered_rollnos
grade_val = row["grade"]

grade_valid = True
grade_error = None
if not is_valid_grade(grade_val, course_obj.code):
grade_valid = False
grade_error = f"Invalid grade '{grade_val}'."
elif not is_special_numeric_course:
row_scheme = grade_scheme(grade_val)
if seen_scheme is None:
seen_scheme = row_scheme
elif row_scheme != seen_scheme:
grade_valid = False
grade_error = (
f"Grade scheme mismatch: this course is using {SCHEME_LABELS[seen_scheme]}, "
f"but '{grade_val}' is a {SCHEME_LABELS[row_scheme]} value."
)

# Add additional data (e.g. name, grades, remarks, semester, branch) as in CSV
preview_rows.append({
"roll_no": roll_no,
"name": row["name"],
"branch": row.get("branch", ""),
"grades": row["grade"],
"grades": grade_val,
"remarks": row["remarks"],
"semester": row["semester"],
"is_registered": is_registered
"is_registered": is_registered,
"grade_valid": grade_valid,
"grade_error": grade_error,
})

return Response({"preview": preview_rows}, status=status.HTTP_200_OK)
Expand Down
Loading
Loading