diff --git a/logify-backend/apps/accounts/test_user_detail.py b/logify-backend/apps/accounts/test_user_detail.py index 8aed934..bf63130 100644 --- a/logify-backend/apps/accounts/test_user_detail.py +++ b/logify-backend/apps/accounts/test_user_detail.py @@ -87,7 +87,7 @@ def test_academic_supervisor_can_view_assigned_student(self): address="123 Main St", industry="Tech", city="Kampala", - contact_phone="123456789" + contact_phone="123456789", ) InternshipPlacements.objects.create( intern=self.student, diff --git a/logify-backend/apps/evaluations/migrations/0004_auto_20260429_2011.py b/logify-backend/apps/evaluations/migrations/0004_auto_20260429_2011.py index 1c42ae2..ec9f69a 100644 --- a/logify-backend/apps/evaluations/migrations/0004_auto_20260429_2011.py +++ b/logify-backend/apps/evaluations/migrations/0004_auto_20260429_2011.py @@ -2,9 +2,10 @@ from django.db import migrations + def create_global_rubric(apps, schema_editor): - EvaluationRubrics = apps.get_model('evaluations', 'EvaluationRubrics') - EvaluationCriteria = apps.get_model('evaluations', 'EvaluationCriteria') + EvaluationRubrics = apps.get_model("evaluations", "EvaluationRubrics") + EvaluationCriteria = apps.get_model("evaluations", "EvaluationCriteria") # Create global rubric rubric, created = EvaluationRubrics.objects.get_or_create( @@ -16,7 +17,7 @@ def create_global_rubric(apps, schema_editor): "version": "1.0", "is_current": True, "is_active": True, - } + }, ) # Create default criteria @@ -56,15 +57,15 @@ def create_global_rubric(apps, schema_editor): ] for c in criteria: - EvaluationCriteria.objects.get_or_create( - rubric=rubric, - name=c["name"], - defaults=c - ) + EvaluationCriteria.objects.get_or_create(rubric=rubric, name=c["name"], defaults=c) + def remove_global_rubric(apps, schema_editor): - EvaluationRubrics = apps.get_model('evaluations', 'EvaluationRubrics') - EvaluationRubrics.objects.filter(institution=None, programme=None, name="Default Final Evaluation Rubric").delete() + EvaluationRubrics = apps.get_model("evaluations", "EvaluationRubrics") + EvaluationRubrics.objects.filter( + institution=None, programme=None, name="Default Final Evaluation Rubric" + ).delete() + class Migration(migrations.Migration): diff --git a/logify-backend/apps/evaluations/test_evaluation.py b/logify-backend/apps/evaluations/test_evaluation.py index e65a920..db508d4 100644 --- a/logify-backend/apps/evaluations/test_evaluation.py +++ b/logify-backend/apps/evaluations/test_evaluation.py @@ -1,6 +1,7 @@ from datetime import date from apps.academics.models import Colleges, Departments, Institutions, Programmes +from apps.accounts.models import StaffProfiles from apps.evaluations.models import ( EvaluationCriteria, EvaluationRubrics, @@ -252,6 +253,237 @@ def test_create_evaluation_uses_authenticated_user_as_evaluator(self): self.assertEqual(response.data["evaluator_type"], self.user.role) # type: ignore +class TestEvaluationCollegeScope(APITestCase): + def setUp(self): + User = get_user_model() + self.institution = Institutions.objects.create( + name="Scoped University", email_domain="scoped.edu" + ) + self.college_a = Colleges.objects.create(institution=self.institution, name="Engineering") + self.college_b = Colleges.objects.create(institution=self.institution, name="Business") + self.department_a = Departments.objects.create( + college=self.college_a, name="Computer Science" + ) + self.department_b = Departments.objects.create(college=self.college_b, name="Accounting") + self.programme_a = Programmes.objects.create( + department=self.department_a, + name="Software Engineering", + level="BSc", + duration_years=4, + ) + self.programme_b = Programmes.objects.create( + department=self.department_b, + name="Finance", + level="BSc", + duration_years=3, + ) + self.organization = Organizations.objects.create( + name="Scoped Org", + industry="Tech", + city="Test City", + address="123 Test St", + contact_email="org@example.com", + contact_phone="1234567890", + ) + self.admin_a = User.objects.create_user( + email="admin.a@scoped.edu", + password="testpassword", + first_name="Admin", + last_name="A", + role="internship_admin", + institution_id=str(self.institution.id), + ) + self.admin_b = User.objects.create_user( + email="admin.b@scoped.edu", + password="testpassword", + first_name="Admin", + last_name="B", + role="internship_admin", + institution_id=str(self.institution.id), + ) + StaffProfiles.objects.create( + user=self.admin_a, + staff_number="ADM-A", + department=self.department_a, + title="College Admin", + ) + StaffProfiles.objects.create( + user=self.admin_b, + staff_number="ADM-B", + department=self.department_b, + title="College Admin", + ) + self.student_a = User.objects.create_user( + email="student.a@scoped.edu", + password="testpassword", + first_name="Student", + last_name="A", + role="student", + institution_id=str(self.institution.id), + programme_id=str(self.programme_a.id), + ) + self.student_b = User.objects.create_user( + email="student.b@scoped.edu", + password="testpassword", + first_name="Student", + last_name="B", + role="student", + institution_id=str(self.institution.id), + programme_id=str(self.programme_b.id), + ) + self.supervisor = User.objects.create_user( + email="supervisor@scoped.edu", + password="testpassword", + first_name="Academic", + last_name="Supervisor", + role="academic_supervisor", + institution_id=str(self.institution.id), + ) + self.rubric_a = EvaluationRubrics.objects.create( + institution=self.institution, + programme=self.programme_a, + name="Engineering Rubric", + is_current=True, + ) + self.rubric_b = EvaluationRubrics.objects.create( + institution=self.institution, + programme=self.programme_b, + name="Business Rubric", + is_current=True, + ) + self.criteria_a = EvaluationCriteria.objects.create( + rubric=self.rubric_a, + name="Engineering Criteria", + description="Engineering criteria", + max_score=10, + weight_percent=100.0, + evaluator_type="academic_supervisor", + ) + self.criteria_b = EvaluationCriteria.objects.create( + rubric=self.rubric_b, + name="Business Criteria", + description="Business criteria", + max_score=10, + weight_percent=100.0, + evaluator_type="academic_supervisor", + ) + self.placement_a = InternshipPlacements.objects.create( + intern=self.student_a, + institution=self.institution, + programme=self.programme_a, + organization=self.organization, + academic_supervisor=self.supervisor, + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 14), + work_mode="On-site", + internship_title="Engineering Intern", + department_at_company="IT", + status="active", + ) + self.placement_b = InternshipPlacements.objects.create( + intern=self.student_b, + institution=self.institution, + programme=self.programme_b, + organization=self.organization, + academic_supervisor=self.supervisor, + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 14), + work_mode="On-site", + internship_title="Business Intern", + department_at_company="Finance", + status="active", + ) + self.evaluation_a = Evaluations.objects.create( + placement=self.placement_a, + rubric=self.rubric_a, + evaluator=self.supervisor, + evaluator_type="academic_supervisor", + status="reviewed", + total_score=80.0, + ) + self.evaluation_b = Evaluations.objects.create( + placement=self.placement_b, + rubric=self.rubric_b, + evaluator=self.supervisor, + evaluator_type="academic_supervisor", + status="reviewed", + total_score=90.0, + ) + self.score_a = EvaluationScores.objects.create( + evaluation=self.evaluation_a, + criterion=self.criteria_a, + score=8, + ) + self.score_b = EvaluationScores.objects.create( + evaluation=self.evaluation_b, + criterion=self.criteria_b, + score=9, + ) + self.result_a = FinalResults.objects.create( + placement=self.placement_a, + rubric=self.rubric_a, + logbook_score=70.0, + academic_score=80.0, + final_score=77.0, + final_grade="B", + ) + self.result_b = FinalResults.objects.create( + placement=self.placement_b, + rubric=self.rubric_b, + logbook_score=80.0, + academic_score=90.0, + final_score=87.0, + final_grade="A", + ) + + def test_internship_admin_lists_only_evaluations_in_own_college(self): + self.client.force_authenticate(user=self.admin_a) + + response = self.client.get("/api/v1/evaluations/evaluations/") + + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data], [self.evaluation_a.id]) # type: ignore + + def test_internship_admin_cannot_retrieve_evaluation_from_other_college(self): + self.client.force_authenticate(user=self.admin_a) + + response = self.client.get(f"/api/v1/evaluations/evaluations/{self.evaluation_b.id}/") + + self.assertEqual(response.status_code, 404) + + def test_internship_admin_lists_only_scores_in_own_college(self): + self.client.force_authenticate(user=self.admin_a) + + response = self.client.get("/api/v1/evaluations/scores/") + + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data], [self.score_a.id]) # type: ignore + + def test_internship_admin_cannot_filter_scores_from_other_college(self): + self.client.force_authenticate(user=self.admin_a) + + response = self.client.get(f"/api/v1/evaluations/scores/?evaluation={self.evaluation_b.id}") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, []) # type: ignore + + def test_internship_admin_lists_only_final_results_in_own_college(self): + self.client.force_authenticate(user=self.admin_a) + + response = self.client.get("/api/v1/evaluations/results/") + + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data], [self.result_a.id]) # type: ignore + + def test_other_college_admin_sees_their_own_college_evaluations(self): + self.client.force_authenticate(user=self.admin_b) + + response = self.client.get("/api/v1/evaluations/evaluations/") + + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data], [self.evaluation_b.id]) # type: ignore + + class TestEvaluationCriteriaViewSet(APITestCase): def setUp(self): User = get_user_model() diff --git a/logify-backend/apps/evaluations/views.py b/logify-backend/apps/evaluations/views.py index bd21f78..2a10c0a 100644 --- a/logify-backend/apps/evaluations/views.py +++ b/logify-backend/apps/evaluations/views.py @@ -1,3 +1,8 @@ +from apps.accounts.access import ( + get_programme_ids_for_college, + get_user_college_id, + get_user_institution_id, +) from apps.accounts.models import User from apps.accounts.permissions import ( IsAcademicSupervisor, @@ -51,6 +56,22 @@ def has_permission(self, request, view): ) or IsInternshipAdmin().has_permission(request, view) +def get_admin_college_placement_filter(user): + institution_id = get_user_institution_id(user) + admin_college_id = get_user_college_id(user) + if institution_id is None or admin_college_id is None: + return None + + programme_ids = get_programme_ids_for_college(admin_college_id) + if not programme_ids: + return None + + return { + "placement__institution_id": institution_id, + "placement__programme_id__in": programme_ids, + } + + class EvaluationRubricsViewSet(viewsets.ModelViewSet): queryset = EvaluationRubrics.objects.all() serializer_class = EvaluationRubricsSerializer @@ -92,6 +113,8 @@ class EvaluationsViewSet(viewsets.ModelViewSet): def get_queryset(self): user = self.request.user if user.is_authenticated: + if user.is_superuser: + return Evaluations.objects.all() if user.role == User.STUDENT: # type: ignore return Evaluations.objects.filter(placement__intern=user) elif user.role == User.ACADEMIC_SUPERVISOR: # type: ignore @@ -99,7 +122,10 @@ def get_queryset(self): elif user.role == User.WORKPLACE_SUPERVISOR: # type: ignore return Evaluations.objects.filter(placement__workplace_supervisor=user) elif user.role == User.INTERNSHIP_ADMIN: # type: ignore - return Evaluations.objects.all() + placement_filter = get_admin_college_placement_filter(user) + if placement_filter is None: + return Evaluations.objects.none() + return Evaluations.objects.filter(**placement_filter) return Evaluations.objects.none() @@ -113,13 +139,21 @@ def get_queryset(self): queryset = self.queryset if not user or not user.is_authenticated: return EvaluationScores.objects.none() - if user.role == User.STUDENT: + if user.is_superuser: + queryset = EvaluationScores.objects.all() + elif user.role == User.STUDENT: queryset = queryset.filter(evaluation__placement__intern=user) elif user.role == User.ACADEMIC_SUPERVISOR: queryset = queryset.filter(evaluation__placement__academic_supervisor=user) elif user.role == User.WORKPLACE_SUPERVISOR: queryset = queryset.filter(evaluation__placement__workplace_supervisor=user) - elif user.role != User.INTERNSHIP_ADMIN: + elif user.role == User.INTERNSHIP_ADMIN: + placement_filter = get_admin_college_placement_filter(user) + if placement_filter is None: + return EvaluationScores.objects.none() + score_filter = {f"evaluation__{key}": value for key, value in placement_filter.items()} + queryset = queryset.filter(**score_filter) + else: return EvaluationScores.objects.none() evaluation_id = self.request.query_params.get("evaluation") @@ -137,6 +171,8 @@ def get_queryset(self): user = self.request.user if not user or not user.is_authenticated: return FinalResults.objects.none() + if user.is_superuser: + return FinalResults.objects.all() user_role = getattr(user, "role", None) if user_role == User.STUDENT: return FinalResults.objects.filter(placement__intern=user) @@ -145,5 +181,8 @@ def get_queryset(self): elif user_role == User.WORKPLACE_SUPERVISOR: return FinalResults.objects.filter(placement__workplace_supervisor=user) elif user_role == User.INTERNSHIP_ADMIN: - return FinalResults.objects.all() + placement_filter = get_admin_college_placement_filter(user) + if placement_filter is None: + return FinalResults.objects.none() + return FinalResults.objects.filter(**placement_filter) return FinalResults.objects.none() diff --git a/logify-frontend/src/pages/dashboards/InternshipAdmin/pages/Dashboard.jsx b/logify-frontend/src/pages/dashboards/InternshipAdmin/pages/Dashboard.jsx index b3f786b..476796a 100644 --- a/logify-frontend/src/pages/dashboards/InternshipAdmin/pages/Dashboard.jsx +++ b/logify-frontend/src/pages/dashboards/InternshipAdmin/pages/Dashboard.jsx @@ -176,9 +176,8 @@ const Dashboard = () => { }; }, []); - const activePlacements = placements.filter( - (placement) => - ["approved", "active"].includes(String(placement.status).toLowerCase()), + const activePlacements = placements.filter((placement) => + ["approved", "active"].includes(String(placement.status).toLowerCase()), ).length; const pendingReviews = evaluations.filter((evaluation) => diff --git a/logify-frontend/src/pages/dashboards/InternshipAdmin/pages/Students.jsx b/logify-frontend/src/pages/dashboards/InternshipAdmin/pages/Students.jsx index 24a41ce..4062872 100644 --- a/logify-frontend/src/pages/dashboards/InternshipAdmin/pages/Students.jsx +++ b/logify-frontend/src/pages/dashboards/InternshipAdmin/pages/Students.jsx @@ -127,7 +127,9 @@ const isPlacementForStudent = (placement, student) => { placement.intern?.email || "", ).toLowerCase(); - const studentEmail = String(student.webmail || student.email || "").toLowerCase(); + const studentEmail = String( + student.webmail || student.email || "", + ).toLowerCase(); return ( (studentPlacementId && placementId === studentPlacementId) || @@ -161,17 +163,13 @@ const Students = () => { setError(""); } - const [ - studentsResult, - programmesResult, - placementsResult, - resultsResult, - ] = await Promise.allSettled([ - api.registry.getStudents(), - api.academics.getProgrammes(), - api.placements.getPlacements(), - api.evaluations.getResults(), - ]); + const [studentsResult, programmesResult, placementsResult, resultsResult] = + await Promise.allSettled([ + api.registry.getStudents(), + api.academics.getProgrammes(), + api.placements.getPlacements(), + api.evaluations.getResults(), + ]); if (studentsResult.status === "fulfilled") { setStudents(normalizeCollection(studentsResult.value, "students")); @@ -228,7 +226,6 @@ const Students = () => { }; }, [fetchData]); - const programmeMap = programmes.reduce((acc, programme) => { acc[String(programme.id)] = programme.name || programme.title || programme.code || "Programme"; @@ -303,8 +300,7 @@ const Students = () => { id: student.student_number || student.id || "--", name: toDisplayName(student), email: student.webmail || student.email || "--", - programme: - getProgrammeName(student, programmeMap), + programme: getProgrammeName(student, programmeMap), placement: getPlacementTitle(placement, student), placementStatus: toTitleCase(placementStatus), approvalStatus, @@ -445,7 +441,6 @@ const Students = () => { : "pending" } /> - {student.score > 0 ? ( diff --git a/logify-frontend/src/tests/setupTests.js b/logify-frontend/src/tests/setupTests.js index 0bf8d82..fcbc7bc 100644 --- a/logify-frontend/src/tests/setupTests.js +++ b/logify-frontend/src/tests/setupTests.js @@ -2,6 +2,21 @@ import "@testing-library/jest-dom"; import { TextDecoder, TextEncoder } from "util"; +jest.mock( + "react-toastify", + () => ({ + ToastContainer: () => null, + toast: { + success: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + dismiss: jest.fn(), + }, + }), + { virtual: true }, +); + if (!globalThis.TextEncoder) { globalThis.TextEncoder = TextEncoder; }