From b0979afa0bcbe2cc7db43a4e2509fc7969d20792 Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Date: Sat, 27 Jun 2026 13:48:52 +0530 Subject: [PATCH] Implement server-side role checks and enhance result announcement functionality - Refactored role checking in academic procedures to use actual user designations instead of client-supplied roles. - Added new API endpoints for managing result announcements, including per-student selection for published results. - Introduced a new model PublishedResultStudent to track which students have their results published. - Updated existing views and permissions to enforce role-based access control using the new role-checking methods. - Enhanced curriculum management views with role-based access restrictions for academic admins and deans. - Added migration for new fields in the ResultAnnouncement model to support the new functionality. --- .../academic_procedures/api/views.py | 20 +- .../applications/examination/api/urls.py | 2 + .../applications/examination/api/views.py | 225 ++++++++++++++---- .../migrations/0004_auto_20260625_1637.py | 29 +++ FusionIIIT/applications/examination/models.py | 28 +++ FusionIIIT/applications/globals/access.py | 128 ++++++++++ .../programme_curriculum/api/views.py | 56 +++-- .../api/views_student_management.py | 9 +- 8 files changed, 438 insertions(+), 59 deletions(-) create mode 100644 FusionIIIT/applications/examination/migrations/0004_auto_20260625_1637.py create mode 100644 FusionIIIT/applications/globals/access.py diff --git a/FusionIIIT/applications/academic_procedures/api/views.py b/FusionIIIT/applications/academic_procedures/api/views.py index 7ad04ed03..b02bb85a2 100644 --- a/FusionIIIT/applications/academic_procedures/api/views.py +++ b/FusionIIIT/applications/academic_procedures/api/views.py @@ -3767,8 +3767,24 @@ def upload_excel_replacement(request): } def check_role(request, required_role): - role = request.query_params.get('role') if request.method=='GET' else request.data.get('role') - return role == required_role + # Authorize from the user's real designation, never a client-supplied role + from django.db.models import Q + user = request.user + if not getattr(user, "is_authenticated", False): + return False + held = HoldsDesignation.objects.filter(Q(working=user) | Q(user=user)) + if required_role == 'hod': + return held.filter(designation__name__istartswith='HOD').exists() + if required_role == 'faculty': + return ( + Faculty.objects.filter(id__user=user).exists() + or held.filter( + designation__name__in=[ + 'Professor', 'Associate Professor', 'Assistant Professor', + ] + ).exists() + ) + return held.filter(designation__name=required_role).exists() def get_allowed_specs(user): """ diff --git a/FusionIIIT/applications/examination/api/urls.py b/FusionIIIT/applications/examination/api/urls.py index 8061400e2..f0499996a 100644 --- a/FusionIIIT/applications/examination/api/urls.py +++ b/FusionIIIT/applications/examination/api/urls.py @@ -32,6 +32,8 @@ url(r'result-announcements/', views.ResultAnnouncementListAPI.as_view(), name="result-announcements"), url(r'update-announcement/', views.UpdateAnnouncementAPI.as_view(), name="update-announcement"), url(r'create-announcement/', views.CreateAnnouncementAPI.as_view(), name="create-announcement"), + url(r'^announcement-students/$', views.AnnouncementStudentsAPI.as_view(), name="announcement-students"), + url(r'^publish-result-selected/$', views.PublishResultSelectedAPI.as_view(), name="publish-result-selected"), url(r'unique-course-reg-years/', views.UniqueRegistrationYearsView.as_view(), name="unique-course-reg-years"), url(r'unique-stu-grades-years/', views.UniqueStudentGradeYearsView.as_view(), name="unique-stu-grades-years"), url(r'^student/result_semesters/$', views.StudentSemesterListView.as_view(), name='get_student_semesters'), diff --git a/FusionIIIT/applications/examination/api/views.py b/FusionIIIT/applications/examination/api/views.py index 35b302eed..4c430660f 100644 --- a/FusionIIIT/applications/examination/api/views.py +++ b/FusionIIIT/applications/examination/api/views.py @@ -5,7 +5,8 @@ from decimal import Decimal, ROUND_HALF_UP from applications.academic_procedures.models import(course_registration, course_replacement) from applications.programme_curriculum.models import Course as Courses , Batch, CourseInstructor -from applications.examination.models import(hidden_grades , ResultAnnouncement, authentication) +from applications.examination.models import(hidden_grades , ResultAnnouncement, authentication, PublishedResultStudent) +from applications.globals.access import user_holds_role, user_holds_any_role from applications.academic_information.models import(Student) from applications.online_cms.models import(Student_grades) from rest_framework import status @@ -286,11 +287,11 @@ def exam_view(request): if not role: return Response({"error": "Role parameter is required."}, status=status.HTTP_400_BAD_REQUEST) - if role in ["Associate Professor", "Professor", "Assistant Professor"]: + if user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): return Response({"redirect_url": "/examination/submitGradesProf/"}) - elif role == "acadadmin": + elif user_holds_role(request.user, "acadadmin"): return Response({"redirect_url": "/examination/updateGrades/"}) - elif role == "Dean Academic": + elif user_holds_role(request.user, "Dean Academic"): return Response({"redirect_url": "/examination/verifyGradesDean/"}) else: return Response({"redirect_url": "/dashboard/"}) @@ -390,7 +391,7 @@ def download_template(request): "acadadmin", "Associate Professor", "Professor", "Assistant Professor", "Dean Academic" ] - if role not in allowed_roles: + if not user_holds_any_role(request.user, allowed_roles): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) User = get_user_model() @@ -514,7 +515,7 @@ def check_course_students(request): "acadadmin", "Associate Professor", "Professor", "Assistant Professor", "Dean Academic" ] - if role not in allowed_roles: + if not user_holds_any_role(request.user, allowed_roles): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) course_info_query = course_registration.objects.filter( @@ -576,7 +577,7 @@ def post(self, request): semester_type = request.data.get("semester_type") # Only allow access to 'acadadmin' - if designation != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN @@ -614,7 +615,7 @@ class UploadGradesAPI(APIView): def post(self, request): # Validate the role (only allow "acadadmin" in this example). des = request.data.get("Role") - if des != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -804,7 +805,7 @@ def post(self, request): academic_year = request.data.get("academic_year") semester_type = request.data.get("semester_type") - if role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"success": False, "error": "Access denied."}, status=403, @@ -890,7 +891,7 @@ def post(self, request): des = request.data.get("Role") - if des != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -974,7 +975,7 @@ class ModerateStudentGradesAPI(APIView): def post(self, request): des = request.data.get("Role") - if des not in ["acadadmin", "Dean Academic"]: + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -1097,7 +1098,7 @@ def post(self, request): semester_number = semester.get('no') semester_type = semester.get('type') - if des != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) if not student_id or not semester: @@ -1205,7 +1206,7 @@ class GenerateTranscriptForm(APIView): def get(self, request): role = request.GET.get("role") - if not role or role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"error": "Access denied. Invalid or missing role."}, status=status.HTTP_403_FORBIDDEN @@ -1244,7 +1245,7 @@ def get(self, request): def post(self, request): role = request.data.get("Role") - if not role or role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"error": "Access denied. Invalid or missing role."}, status=status.HTTP_403_FORBIDDEN @@ -1294,7 +1295,7 @@ class GenerateResultAPI(APIView): def post(self, request): try: role = request.data.get("Role") - if role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response({"error": "Access denied."}, status=403) semester = request.data.get("semester") @@ -1622,7 +1623,7 @@ class SubmitAPI(APIView): def post(self, request): role = request.data.get("Role") - if role not in ["acadadmin", "Dean Academic"]: + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response( {"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -1724,7 +1725,7 @@ def post(self, request): semester_type = request.data.get("semester_type") programme_type = request.data.get("programme_type") - if role not in ["Associate Professor", "Professor", "Assistant Professor"]: + if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -1821,7 +1822,7 @@ def post(self, request): try: # 1) ROLE CHECK role = request.data.get("Role") - if role not in ["Associate Professor", "Professor", "Assistant Professor"]: + if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) @@ -2114,7 +2115,7 @@ def post(self, request): semester_type = request.data.get("semester_type") programme_type = request.data.get("programme_type") - if role not in ["Associate Professor", "Professor", "Assistant Professor"]: + if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) if not academic_year or not semester_type: @@ -2182,7 +2183,7 @@ def post(self, request): return self.generate_student_result_pdf(request) # Faculty role check for course grade sheets - if role not in ["Associate Professor", "Professor", "Assistant Professor", "acadadmin"]: + if not user_holds_any_role(request.user, ["Associate Professor", "Professor", "Assistant Professor", "acadadmin"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) # Existing faculty course grade sheet logic @@ -2219,7 +2220,7 @@ def post(self, request): grades = grades.order_by("roll_no") - if role == "acadadmin": + if user_holds_role(request.user, "acadadmin"): ci = CourseInstructor.objects.filter( course_id_id=course_id, year=working_year, @@ -2236,7 +2237,7 @@ def post(self, request): return Response({"success": False, "error": "Course not found."}, status=404) # semester = ci.first().semester_no - if role == "acadadmin": + if user_holds_role(request.user, "acadadmin"): _User = get_user_model() ci_obj = ci.first() instr_user = _User.objects.filter(username=ci_obj.instructor_id_id).first() @@ -2637,7 +2638,7 @@ def post(self, request): academic_year = request.data.get("academic_year") semester_type = request.data.get("semester_type") - if role != "Dean Academic": + if not user_holds_role(request.user, "Dean Academic"): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) if not academic_year or not semester_type: return Response({"error": "Both academic_year and semester_type are required."}, @@ -2709,7 +2710,7 @@ def post(self, request): year = request.data.get("year") semester_type = request.data.get("semester_type") - if role != "Dean Academic": + if not user_holds_role(request.user, "Dean Academic"): return Response({"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) qs = Student_grades.objects.filter(course_id=course_id, academic_year=year, semester_type = semester_type) @@ -2767,7 +2768,7 @@ class ValidateDeanView(APIView): def post(self, request): role = request.data.get("Role") - if role != "Dean Academic": + if not user_holds_role(request.user, "Dean Academic"): return Response( {"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN @@ -2810,7 +2811,7 @@ class ValidateDeanSubmitView(APIView): def post(self, request): role = request.data.get("Role") - if role != "Dean Academic": + if not user_holds_role(request.user, "Dean Academic"): return Response( {"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN @@ -2981,7 +2982,7 @@ def post(self, request, *args, **kwargs): semester_type=semester_type, ).first() - if not ann or not ann.announced: + if not ann or not ann.announced or not _is_result_published_for(ann, roll_number): return JsonResponse( {"success": False, "message": "Results not announced yet."}, status=200, @@ -3047,7 +3048,7 @@ class PreviewGradesAPI(APIView): def post(self, request): # Validate user role user_role = request.data.get("Role") - if user_role != "acadadmin" and user_role!='Assistant Professor' and user_role != 'Professor' and user_role!='Associate Professor': + if not user_holds_any_role(request.user, ["acadadmin", "Assistant Professor", "Professor", "Associate Professor"]): return Response( {"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN, @@ -3171,11 +3172,16 @@ class ResultAnnouncementListAPI(APIView): def get(self, request): role = request.query_params.get("role") - if role != "acadadmin" and role != "Dean Academic": + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) - # Get announcements sorted by creation date (most recent first) - announcements = ResultAnnouncement.objects.all().order_by("-created_at") + # Get announcements sorted by creation date (most recent first). + # select_related pulls batch + discipline in one query (avoids N+1). + announcements = ( + ResultAnnouncement.objects + .select_related("batch__discipline") + .order_by("-created_at") + ) ann_data = [] for ann in announcements: # Compute the batch label. @@ -3203,7 +3209,7 @@ def get(self, request): }) batch_objs = sorted( - Batch.objects.filter(running_batch=True), + Batch.objects.filter(running_batch=True).select_related("discipline"), key=lambda b: (b.name, -b.year, b.discipline.acronym), ) batch_options = [ @@ -3226,7 +3232,7 @@ class UpdateAnnouncementAPI(APIView): def post(self, request): role = request.data.get("Role") - if role != "acadadmin" and role != "Dean Academic": + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) announcement_id = request.data.get("id") announced = request.data.get("announced") @@ -3255,7 +3261,7 @@ class CreateAnnouncementAPI(APIView): def post(self, request): try: role = request.data.get("Role") - if role != "acadadmin" and role != "Dean Academic": + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) batch_id = request.data.get("batch") @@ -3303,6 +3309,141 @@ def post(self, request): traceback.print_exc() return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) +def _is_result_published_for(ann, roll_number): + """Whether this announcement's result is published for ``roll_number``. + + Legacy / whole-batch publish (``per_student_selection`` False) is published + for everyone. Per-student publish shows the result only to the students + explicitly selected (rows in ``PublishedResultStudent``). + """ + if not getattr(ann, "per_student_selection", False): + return True + return ann.published_students.filter(roll_no=roll_number).exists() + + +def _user_has_exam_admin_role(user, allowed=("acadadmin", "Dean Academic")): + """Authorize off the user's actual held designation (server-side). + + The rest of this module trusts a client-supplied ``Role`` field, which is + spoofable; these result-publishing endpoints verify the real designation + instead so a non-admin cannot publish/hide results or read the roster. + """ + from applications.globals.models import HoldsDesignation + + return HoldsDesignation.objects.filter( + Q(working=user) | Q(user=user), + designation__name__in=allowed, + ).exists() + + +class AnnouncementStudentsAPI(APIView): + """GET /api/announcement-students/?id=&role=acadadmin + + Returns the students of the announcement's batch with their current publish + selection. Before any per-student publish, every student defaults to + selected (checked). + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + if not _user_has_exam_admin_role(request.user): + return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) + + try: + ann = ResultAnnouncement.objects.select_related("batch__discipline").get( + id=request.query_params.get("id") + ) + except (ResultAnnouncement.DoesNotExist, ValueError): + return Response({"error": "Announcement not found."}, status=status.HTTP_404_NOT_FOUND) + + students = ( + Student.objects.filter(batch_id=ann.batch) + .select_related("id__user") + .order_by("id__user__username") + ) + published_set = set(ann.published_students.values_list("roll_no", flat=True)) + # Reflect the saved per-student selection only while it is actively + # published; otherwise (fresh, or fully reverted) default to all checked. + has_selection = ann.announced and ann.per_student_selection + discipline = ann.batch.discipline.acronym if ann.batch.discipline else "" + + rows = [] + for idx, stu in enumerate(students, start=1): + user = stu.id.user + roll = user.username + full_name = "{} {}".format(user.first_name, user.last_name).strip() or roll + rows.append({ + "s_no": idx, + "roll_no": roll, + "name": full_name, + "discipline": discipline, + # Default everyone to checked until a per-student publish happens. + "published": (roll in published_set) if has_selection else True, + }) + + batch = ann.batch + sem_label = ( + "Summer {}".format(ann.semester // 2) + if ann.semester_type == "Summer Semester" + else "Semester {}".format(ann.semester) + ) + return Response({ + "id": ann.id, + "batch_label": "{} - {} {}".format(batch.name, discipline, batch.year), + "semester_label": sem_label, + "announced": ann.announced, + "students": rows, + }, status=status.HTTP_200_OK) + + +class PublishResultSelectedAPI(APIView): + """POST /api/publish-result-selected/ + + Body: ``{ "id": , "roll_numbers": [...], "Role": "acadadmin" }`` + + Publishes the announcement for exactly the selected students. Unselected + students of the batch will not see their result. + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + if not _user_has_exam_admin_role(request.user): + return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) + + roll_numbers = request.data.get("roll_numbers", []) + if not isinstance(roll_numbers, list): + return Response({"error": "roll_numbers must be a list."}, status=status.HTTP_400_BAD_REQUEST) + + try: + ann = ResultAnnouncement.objects.get(id=request.data.get("id")) + except (ResultAnnouncement.DoesNotExist, ValueError): + return Response({"error": "Announcement not found."}, status=status.HTTP_404_NOT_FOUND) + + # Keep only roll numbers that actually belong to the batch. + valid_rolls = set( + Student.objects.filter(batch_id=ann.batch) + .values_list("id__user__username", flat=True) + ) + selected = [r for r in roll_numbers if r in valid_rolls] + + with transaction.atomic(): + ann.published_students.all().delete() + PublishedResultStudent.objects.bulk_create( + [PublishedResultStudent(announcement=ann, roll_no=r) for r in selected] + ) + ann.per_student_selection = True + # Publishing zero students reverts the announcement (nobody sees it). + ann.announced = bool(selected) + ann.save(update_fields=["per_student_selection", "announced"]) + + return Response({ + "success": True, + "announced": ann.announced, + "published_count": len(selected), + "total": len(valid_rolls), + }, status=status.HTTP_200_OK) + + from collections import OrderedDict @@ -3370,7 +3511,7 @@ def post(self, request): semester_type = request.data.get("semester_type") # Role-based access control - if role not in ["acadadmin", "Dean Academic"]: + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN @@ -3539,7 +3680,7 @@ def post(self, request): semester_type=semester_type, ).first() - if not ann or not ann.announced: + if not ann or not ann.announced or not _is_result_published_for(ann, roll_number): return JsonResponse( {"success": False, "message": "Results not announced yet."}, status=200, @@ -3828,7 +3969,7 @@ def post(self, request): student_id = request.data.get("student") raw_semester = request.data.get("semester") - if des != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) if not student_id or not raw_semester: @@ -4034,7 +4175,7 @@ class GenerateGradeSheetForm(APIView): def get(self, request): role = request.GET.get("role") - if not role or role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"error": "Access denied. Invalid or missing role."}, status=status.HTTP_403_FORBIDDEN) @@ -4060,7 +4201,7 @@ def get(self, request): def post(self, request): role = request.data.get("Role") - if not role or role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response( {"error": "Access denied. Invalid or missing role."}, status=status.HTTP_403_FORBIDDEN) @@ -4098,7 +4239,7 @@ def post(self, request): academic_year = request.data.get("academic_year") semester_type = request.data.get("semester_type") - if role not in ["acadadmin", "Dean Academic"]: + if not user_holds_any_role(request.user, ["acadadmin", "Dean Academic"]): return Response( {"success": False, "error": "Access denied."}, status=status.HTTP_403_FORBIDDEN @@ -4184,7 +4325,7 @@ class GradeValidationView(APIView): def get(self, request): """Return batch list for the dropdown (same format as GenerateGradeSheetForm).""" role = request.GET.get("role") - if role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) batches_qs = Batch.objects.select_related("discipline").order_by("-year", "name") @@ -4196,7 +4337,7 @@ def get(self, request): def post(self, request): role = request.data.get("Role") - if role != "acadadmin": + if not user_holds_role(request.user, "acadadmin"): return Response({"error": "Access denied."}, status=status.HTTP_403_FORBIDDEN) action = request.data.get("action") diff --git a/FusionIIIT/applications/examination/migrations/0004_auto_20260625_1637.py b/FusionIIIT/applications/examination/migrations/0004_auto_20260625_1637.py new file mode 100644 index 000000000..1282fd4a3 --- /dev/null +++ b/FusionIIIT/applications/examination/migrations/0004_auto_20260625_1637.py @@ -0,0 +1,29 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('examination', '0003_resultannouncement_semester_type'), + ] + + operations = [ + migrations.AddField( + model_name='resultannouncement', + name='per_student_selection', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='PublishedResultStudent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('roll_no', models.CharField(max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('announcement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='published_students', to='examination.resultannouncement')), + ], + options={ + 'unique_together': {('announcement', 'roll_no')}, + }, + ), + ] diff --git a/FusionIIIT/applications/examination/models.py b/FusionIIIT/applications/examination/models.py index 88e59a756..59a28f3c3 100644 --- a/FusionIIIT/applications/examination/models.py +++ b/FusionIIIT/applications/examination/models.py @@ -53,6 +53,10 @@ class ResultAnnouncement(models.Model): blank=True, ) announced = models.BooleanField(default=False) + # True once the result is published via per-student selection. When True, + # only students listed in PublishedResultStudent see their result; when + # False the announcement is published for the whole batch (legacy). + per_student_selection = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) class Meta: @@ -65,3 +69,27 @@ def __str__(self): else: sem_label = f"Sem {self.semester}" return f"{self.batch.name} - {sem_label} - {status}" + + +class PublishedResultStudent(models.Model): + """Per-student selection for a result announcement. + + When the academic admin publishes a result they can pick exactly which + students of the batch are included. A row here means "this student's result + for this announcement is published". If an announcement has no rows at all, + it is treated as published for the whole batch (legacy behaviour). + """ + + announcement = models.ForeignKey( + ResultAnnouncement, + on_delete=models.CASCADE, + related_name="published_students", + ) + roll_no = models.CharField(max_length=20) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = [("announcement", "roll_no")] + + def __str__(self): + return f"{self.announcement_id} - {self.roll_no}" diff --git a/FusionIIIT/applications/globals/access.py b/FusionIIIT/applications/globals/access.py new file mode 100644 index 000000000..f715fdce2 --- /dev/null +++ b/FusionIIIT/applications/globals/access.py @@ -0,0 +1,128 @@ + + + + + + + + + + + +"""Server-side role / designation checks. +Authorization must be derived from the authenticated user's real designation +""" + +from django.db.models import Q + + +def user_holds_role(user, role): + """True if ``user`` actually holds the designation named ``role``.""" + if not role or not getattr(user, "is_authenticated", False): + return False + from applications.globals.models import HoldsDesignation + + return HoldsDesignation.objects.filter( + Q(working=user) | Q(user=user), + designation__name=role, + ).exists() + + +def user_holds_any_role(user, roles): + """True if ``user`` holds any designation in ``roles``.""" + if not getattr(user, "is_authenticated", False): + return False + names = [r for r in roles if r] + if not names: + return False + from applications.globals.models import HoldsDesignation + + return HoldsDesignation.objects.filter( + Q(working=user) | Q(user=user), + designation__name__in=names, + ).exists() + + +# --- Reusable DRF permission classes (declarative, fail-closed) ------------- +# Prefer these on admin views: `permission_classes = [IsAcadAdminOrDean]` or +# `permission_classes = [has_any_role("acadadmin", "Dean Academic")]`. They +# authorize from the real designation and deny by default. +from rest_framework.permissions import BasePermission # noqa: E402 + + +class HasDesignation(BasePermission): + """Allow only users who hold one of ``allowed_roles`` (designation names).""" + + allowed_roles = () + message = "You do not have permission to perform this action." + + def has_permission(self, request, view): + return user_holds_any_role(request.user, self.allowed_roles) + + +def has_any_role(*roles): + """Factory: return a permission class allowing any of ``roles``.""" + + class _HasAnyRole(HasDesignation): + allowed_roles = roles + + return _HasAnyRole + + +class IsAcadAdmin(HasDesignation): + allowed_roles = ("acadadmin",) + + +class IsDeanAcademic(HasDesignation): + allowed_roles = ("Dean Academic",) + + +class IsAcadAdminOrDean(HasDesignation): + allowed_roles = ("acadadmin", "Dean Academic") + + +# --- Decorator for PLAIN Django views (non-DRF) ----------------------------- +# Plain function views (e.g. those using @require_http_methods) bypass DRF, so +# DRF auth/permission don't apply and the token header is ignored. This +# decorator authenticates the token itself and enforces the designation, so +# such views are no longer effectively open. Apply it as the OUTERMOST decorator. +from functools import wraps # noqa: E402 + +from django.http import JsonResponse # noqa: E402 + + +def _user_from_request(request): + """Resolve the user from a DRF-authenticated request or a Token header.""" + existing = getattr(request, "user", None) + if getattr(existing, "is_authenticated", False): + return existing + auth = request.META.get("HTTP_AUTHORIZATION", "") + if not auth.startswith("Token "): + return None + key = auth.split(" ", 1)[1].strip() + from rest_framework.authtoken.models import Token + + try: + return Token.objects.select_related("user").get(key=key).user + except Token.DoesNotExist: + return None + + +def require_designation(*roles): + """Gate a plain Django view to users holding one of ``roles`` (server-side).""" + + def decorator(view_func): + @wraps(view_func) + def _wrapped(request, *args, **kwargs): + user = _user_from_request(request) + if user is None or not user_holds_any_role(user, roles): + return JsonResponse( + {"error": "You do not have permission to perform this action."}, + status=403, + ) + request.user = user + return view_func(request, *args, **kwargs) + + return _wrapped + + return decorator diff --git a/FusionIIIT/applications/programme_curriculum/api/views.py b/FusionIIIT/applications/programme_curriculum/api/views.py index dec3ea7ce..3e518432e 100644 --- a/FusionIIIT/applications/programme_curriculum/api/views.py +++ b/FusionIIIT/applications/programme_curriculum/api/views.py @@ -33,6 +33,7 @@ from notification.views import prog_and_curr_notif # from applications.academic_information.models import Student from applications.globals.models import (DepartmentInfo, Designation,ExtraInfo, Faculty, HoldsDesignation) +from applications.globals.access import IsAcadAdminOrDean, require_designation # ------------module-functions---------------# @login_required(login_url='/accounts/login') @@ -467,6 +468,7 @@ def view_all_batches(request): # @api_view(['GET']) # @login_required(login_url='/accounts/login') +@require_designation("acadadmin", "Dean Academic") def admin_view_all_programmes(request): """ API to return all programmes (UG, PG, PhD) for an admin user. @@ -493,6 +495,7 @@ def admin_view_all_programmes(request): return JsonResponse(response_data, status=200, safe=False) @api_view(['GET']) +@permission_classes([IsAcadAdminOrDean]) def admin_view_curriculums_of_a_programme(request, programme_id): program = get_object_or_404(Programme, id=programme_id) curriculums = program.curriculums.all() @@ -514,6 +517,7 @@ def admin_view_curriculums_of_a_programme(request, programme_id): return JsonResponse(data) @api_view(['GET']) +@permission_classes([IsAcadAdminOrDean]) def Admin_view_all_working_curriculums(request): """API view to return all working curriculums offered by the institute as JSON""" @@ -569,6 +573,7 @@ def Admin_view_all_working_curriculums(request): # Return the data as JSON response return JsonResponse({'curriculums': curriculum_data}, safe=False) +@require_designation("acadadmin", "Dean Academic") def admin_view_semesters_of_a_curriculum(request, curriculum_id): """API endpoint to get all semesters of a specific curriculum for React frontend.""" @@ -640,6 +645,7 @@ def admin_view_semesters_of_a_curriculum(request, curriculum_id): return JsonResponse(curriculum_data) +@require_designation("acadadmin", "Dean Academic") def admin_view_a_semester_of_a_curriculum(request, semester_id): # user_details = ExtraInfo.objects.get(user=request.user) # des = HoldsDesignation.objects.filter(user=request.user).first() @@ -690,6 +696,7 @@ def admin_view_a_semester_of_a_curriculum(request, semester_id): return JsonResponse(semester_data, safe=False) +@require_designation("acadadmin", "Dean Academic") def admin_view_a_courseslot(request, courseslot_id): """API to view a course slot""" @@ -758,6 +765,7 @@ def admin_view_a_courseslot(request, courseslot_id): }) @api_view(['GET']) +@permission_classes([IsAcadAdminOrDean]) def admin_view_all_courses(request): """Returns all courses with required fields as JSON data.""" @@ -791,6 +799,7 @@ def admin_view_all_courses(request): return JsonResponse({'courses': courses_data}) @api_view(['GET']) +@permission_classes([IsAcadAdminOrDean]) def admin_view_a_course(request, course_id): """View to handle the details of a Course as an API""" @@ -835,6 +844,7 @@ def admin_view_a_course(request, course_id): # disciplines = Discipline.objects.all() # return render(request, 'programme_curriculum/acad_admin/admin_view_all_disciplines.html', {'disciplines': disciplines}) @api_view(['GET']) +@permission_classes([IsAcadAdminOrDean]) def admin_view_all_discplines(request): """API to view all disciplines with related programmes""" @@ -872,6 +882,7 @@ def admin_view_all_discplines(request): @api_view(['GET']) +@permission_classes([IsAcadAdminOrDean]) def admin_view_all_batches(request): """ views the details of a Course """ @@ -987,7 +998,7 @@ def admin_view_all_batches(request): @csrf_exempt @api_view(['POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def add_discipline_form(request): if request.method == 'POST': try: @@ -1007,6 +1018,7 @@ def add_discipline_form(request): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def edit_discipline_form(request, discipline_id): # user_details = ExtraInfo.objects.get(user = request.user) @@ -1085,6 +1097,7 @@ def edit_discipline_form(request, discipline_id): # @permission_classes([IsAuthenticated]) # @api_view(['POST']) @csrf_exempt +@require_designation("acadadmin", "Dean Academic") def add_programme_form(request): if request.method == 'POST': data = json.loads(request.body) @@ -1114,6 +1127,7 @@ def add_programme_form(request): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def edit_programme_form(request, programme_id): # user_details = ExtraInfo.objects.get(user = request.user) @@ -1166,7 +1180,7 @@ def edit_programme_form(request, programme_id): }, status=405) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) @api_view(['POST']) def add_curriculum_form(request): """ @@ -1236,7 +1250,7 @@ def add_curriculum_form(request): return JsonResponse({'error': 'Invalid request method.'}, status=405) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) @api_view(['GET', 'PUT']) def edit_curriculum_form(request, curriculum_id): """ @@ -1330,6 +1344,7 @@ def edit_curriculum_form(request, curriculum_id): return JsonResponse({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) return JsonResponse({'error': 'Invalid request method.'}, status=status.HTTP_405_METHOD_NOT_ALLOWED) @csrf_exempt +@require_designation("acadadmin", "Dean Academic") def add_course_form(request): # user_details = ExtraInfo.objects.get(user = request.user) @@ -1422,7 +1437,7 @@ def add_course_form(request): @csrf_exempt @api_view(['GET', 'PUT']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def update_course_form(request, course_id): """ Handle getting and updating Course through an API endpoint. @@ -1614,7 +1629,7 @@ def update_course_form(request, course_id): @csrf_exempt @api_view(['GET']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def course_audit_logs(request, course_id): """ Get audit logs for a specific course @@ -1662,7 +1677,7 @@ def course_audit_logs(request, course_id): ) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) @api_view(['POST']) def add_courseslot_form(request): try: @@ -1708,6 +1723,7 @@ def add_courseslot_form(request): @csrf_exempt # Use this decorator if you're not using CSRF tokens in your API calls @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def edit_courseslot_form(request, courseslot_id): courseslot = get_object_or_404(CourseSlot, Q(id=courseslot_id)) @@ -1749,7 +1765,7 @@ def edit_courseslot_form(request, courseslot_id): @csrf_exempt @api_view(['DELETE']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def delete_courseslot(request, courseslot_id): try: # Check if the user has the required session key @@ -1786,7 +1802,7 @@ def delete_courseslot(request, courseslot_id): return JsonResponse({'error': 'Internal server error'}, status=500) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) @api_view(['POST']) @csrf_exempt # Use this decorator if CSRF is not handled elsewhere def add_batch_form(request): @@ -1818,6 +1834,7 @@ def add_batch_form(request): @csrf_exempt # Use this decorator if you're not using CSRF tokens in your API calls @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def edit_batch_form(request, batch_id): # user_details = ExtraInfo.objects.get(user = request.user) @@ -1947,6 +1964,7 @@ def edit_batch_form(request, batch_id): return JsonResponse({'status': 'error', 'message': str(e)}, status=500) return JsonResponse({'error': 'Invalid request method'}, status=405) +@require_designation("acadadmin", "Dean Academic") def instigate_semester(request, semester_id): """ This function is used to add the semester information. @@ -1990,6 +2008,7 @@ def instigate_semester(request, semester_id): @csrf_exempt # Use this decorator if you're not using CSRF tokens in your API calls @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def replicate_curriculum(request, curriculum_id): """ This function is used to replicate the previous curriculum into a new curriculum. @@ -3268,6 +3287,7 @@ def file_unarchive(request,FileId): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def course_slot_type_choices(request): """ API endpoint to return the list of course slot type choices from the CourseSlot model. @@ -3277,6 +3297,7 @@ def course_slot_type_choices(request): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def semester_details(request): curriculum_id = request.GET.get('curriculum_id') @@ -3309,7 +3330,7 @@ def semester_details(request): @api_view(['GET']) @csrf_exempt -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def get_programme(request, programme_id): program = get_object_or_404(Programme, id=programme_id) # curriculums = program.curriculums.all() @@ -3332,12 +3353,14 @@ def get_programme(request, programme_id): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def get_batch_names(request): choices = [{'value': key, 'label': label} for key, label in Batch._meta.get_field('name').choices] return JsonResponse({'choices': choices}) @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def get_all_disciplines(request): # Fetch all disciplines from the database disciplines = Discipline.objects.all() @@ -3357,6 +3380,7 @@ def get_all_disciplines(request): return JsonResponse(disciplines_data, safe=False) @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def get_unused_curriculam(request): used_curriculum_ids = Batch.objects.exclude(curriculum__isnull=True).values_list('curriculum_id', flat=True) unused_curricula = Curriculum.objects.exclude(id__in=used_curriculum_ids) @@ -3376,6 +3400,7 @@ def get_unused_curriculam(request): return JsonResponse(unused_curricula_data, safe=False) @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def admin_view_all_course_instructor(request): # Fetch all records from the CourseInstructor table course_instructors = CourseInstructor.objects.select_related( @@ -3406,6 +3431,7 @@ def admin_view_all_course_instructor(request): return JsonResponse({'course_instructors': course_instructors_data}) @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def admin_view_all_faculties(request): # Fetch all faculties with their user details faculties = Faculty.objects.select_related('id__user').annotate( @@ -3445,6 +3471,7 @@ def parse_academic_year(academic_year, semester_type): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def add_course_instructor(request): if request.method != "POST": return JsonResponse({"error": "Invalid request method"}, status=405) @@ -3544,6 +3571,7 @@ def add_course_instructor(request): @csrf_exempt @permission_classes([IsAuthenticated]) +@require_designation("acadadmin", "Dean Academic") def update_course_instructor_form(request, instructor_id): # Retrieve the CourseInstructor object or return 404 if not found course_instructor = get_object_or_404(CourseInstructor, id=instructor_id) @@ -3661,7 +3689,7 @@ def get_superior_data(request): @csrf_exempt @api_view(['DELETE']) @authentication_classes([TokenAuthentication]) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def admin_delete_course_instructor(request, instructor_id): """ Delete a course instructor assignment @@ -3733,7 +3761,7 @@ def admin_delete_course_instructor(request, instructor_id): @csrf_exempt @api_view(['DELETE']) @authentication_classes([TokenAuthentication]) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def admin_delete_course(request, course_id): """ Delete a course @@ -3809,7 +3837,7 @@ def admin_delete_course(request, course_id): @csrf_exempt @api_view(['DELETE']) @authentication_classes([TokenAuthentication]) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def admin_delete_programme(request, programme_id): """ Delete a programme @@ -3878,7 +3906,7 @@ def admin_delete_programme(request, programme_id): @csrf_exempt @api_view(['DELETE']) @authentication_classes([TokenAuthentication]) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def admin_delete_curriculum(request, curriculum_id): """ Delete a curriculum @@ -3954,7 +3982,7 @@ def admin_delete_curriculum(request, curriculum_id): @csrf_exempt @api_view(['DELETE']) @authentication_classes([TokenAuthentication]) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def admin_delete_discipline(request, discipline_id): """ Delete a discipline diff --git a/FusionIIIT/applications/programme_curriculum/api/views_student_management.py b/FusionIIIT/applications/programme_curriculum/api/views_student_management.py index cd449fa6b..4310ffa68 100644 --- a/FusionIIIT/applications/programme_curriculum/api/views_student_management.py +++ b/FusionIIIT/applications/programme_curriculum/api/views_student_management.py @@ -25,6 +25,7 @@ from applications.academic_information.models import Student as AcademicStudent from applications.globals.models import ExtraInfo, Designation, HoldsDesignation +from applications.globals.access import IsAcadAdminOrDean, require_designation from django.contrib.auth.models import User from applications.programme_curriculum.models import ( Programme, Curriculum, Batch, Discipline @@ -368,6 +369,7 @@ def get_display_branch_name(discipline): @csrf_exempt @require_http_methods(["POST"]) +@require_designation("acadadmin", "Dean Academic") def process_excel_upload(request): try: if 'file' not in request.FILES: @@ -647,6 +649,7 @@ def check_student_duplicate(student, duplicate_check_fields): @csrf_exempt @require_http_methods(["POST"]) +@require_designation("acadadmin", "Dean Academic") def save_students_batch(request): try: data = json.loads(request.body) @@ -1027,6 +1030,7 @@ def get_allocation_summary(students, programme_type): @csrf_exempt @require_http_methods(["POST"]) +@require_designation("acadadmin", "Dean Academic") def add_single_student(request): try: data = json.loads(request.body) @@ -1199,6 +1203,7 @@ def add_single_student(request): @csrf_exempt @require_http_methods(["PUT"]) +@require_designation("acadadmin", "Dean Academic") def set_total_seats(request): try: data = json.loads(request.body) @@ -2253,6 +2258,7 @@ def list_students(request): @csrf_exempt @require_http_methods(["POST"]) +@require_designation("acadadmin", "Dean Academic") def create_batch(request): """ Create new batch @@ -3876,6 +3882,7 @@ def check_transfer_status(request): @csrf_exempt @require_http_methods(["GET"]) +@require_designation("acadadmin", "Dean Academic") def get_batch_students(request, batch_id): """ Get students for a specific batch - ONLY from StudentBatchUpload table @@ -4104,7 +4111,7 @@ def get_batch_students(request, batch_id): # ============================================================================= @api_view(['GET']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAcadAdminOrDean]) def admin_batches_unified(request): """ UNIFIED API for both 'Batches' tab and 'Upcoming Batches' tab