From f300f380e1ef763bb8093b98161f5065092a13d9 Mon Sep 17 00:00:00 2001 From: Jose Sousa <2409jmsousa@gmail.com> Date: Mon, 6 Oct 2025 19:16:52 +0100 Subject: [PATCH 1/7] fix: changed auth endpoint --- django/university/controllers/SigarraController.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/django/university/controllers/SigarraController.py b/django/university/controllers/SigarraController.py index 929c95a9..dc5a7afd 100644 --- a/django/university/controllers/SigarraController.py +++ b/django/university/controllers/SigarraController.py @@ -84,9 +84,9 @@ def get_student_festid(self, nmec): def login(self): try: - response = requests.post("https://sigarra.up.pt/feup/pt/mob_val_geral.autentica/", data={ - "pv_login": self.username, - "pv_password": self.password + response = requests.post("https://sigarra.up.pt/feup/pt/vld_validacao.validacao", data={ + "p_user": self.username, + "p_pass": self.password }) self.cookies = response.cookies @@ -97,7 +97,7 @@ def get_student_schedule(self, nmec: int) -> SigarraResponse: (semana_ini, semana_fim) = self.semester_weeks() response = requests.get(self.student_schedule_url( - nmec, + 202208026, semana_ini, semana_fim ), cookies=self.cookies) From 2267e4f448d702cf63afa4d1abbbdc76a002ea16 Mon Sep 17 00:00:00 2001 From: Jose Sousa <2409jmsousa@gmail.com> Date: Tue, 11 Nov 2025 20:58:50 +0000 Subject: [PATCH 2/7] feat: initial admin endpoints --- .../routes/admin/AdminExchangeAdminsView.py | 63 +++++++++++++++++++ .../admin/AdminExchangeCandidatesView.py | 48 ++++++++++++++ .../admin/AdminExchangeCourseUnitsView.py | 3 + django/university/urls.py | 6 ++ 4 files changed, 120 insertions(+) create mode 100644 django/university/routes/admin/AdminExchangeAdminsView.py create mode 100644 django/university/routes/admin/AdminExchangeCandidatesView.py diff --git a/django/university/routes/admin/AdminExchangeAdminsView.py b/django/university/routes/admin/AdminExchangeAdminsView.py new file mode 100644 index 00000000..543845cd --- /dev/null +++ b/django/university/routes/admin/AdminExchangeAdminsView.py @@ -0,0 +1,63 @@ +from django.http import JsonResponse +from django.contrib.auth import get_user_model +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated + +from exchange.models import ExchangeAdmin + + +class AdminExchangeAdminsView(APIView): + """Return user info for current exchange admins. + + Requires authentication and is intended to be protected by the + `exchange_admin_required` middleware when routed. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + UserModel = get_user_model() + + # Get all admin usernames + admin_usernames = list(ExchangeAdmin.objects.values_list("username", flat=True)) + + # Query user rows matching those usernames + users_qs = UserModel.objects.filter(username__in=admin_usernames).values( + "id", "username", "first_name", "last_name", "email", "is_active", "date_joined" + ) + + # Minimal serialization: convert date_joined to a string using str() + admins = [] + for u in users_qs: + u = dict(u) + u["date_joined"] = str(u.get("date_joined")) if u.get("date_joined") else None + admins.append(u) + + return JsonResponse({"admins": admins}, safe=False) + + def post(self, request): + username = request.data.get('username') + if not username: + return JsonResponse({'error': 'Username required'}, status=400) + + UserModel = get_user_model() + try: + user = UserModel.objects.get(username=username) + except UserModel.DoesNotExist: + return JsonResponse({'error': 'User not found'}, status=404) + + if ExchangeAdmin.objects.filter(username=username).exists(): + return JsonResponse({'error': 'User is already an admin'}, status=400) + + ExchangeAdmin.objects.create(username=username) + return JsonResponse({'message': 'Admin added successfully'}) + + def delete(self, request, username): + if not username: + return JsonResponse({'error': 'Username required'}, status=400) + + deleted, _ = ExchangeAdmin.objects.filter(username=username).delete() + if deleted == 0: + return JsonResponse({'error': 'Admin not found'}, status=404) + + return JsonResponse({'message': 'Admin removed successfully'}) diff --git a/django/university/routes/admin/AdminExchangeCandidatesView.py b/django/university/routes/admin/AdminExchangeCandidatesView.py new file mode 100644 index 00000000..16afa219 --- /dev/null +++ b/django/university/routes/admin/AdminExchangeCandidatesView.py @@ -0,0 +1,48 @@ +from django.http import JsonResponse +from django.contrib.auth import get_user_model +from django.db import models +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated + +from exchange.models import ExchangeAdmin + + +class AdminExchangeCandidatesView(APIView): + """Return all auth users whose username is not present in the ExchangeAdmin table. + + This endpoint requires authentication. It returns a JSON object with a + `candidates` array containing basic fields for each user. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + # Use a clearer name for the model class and select only the fields we need + UserModel = get_user_model() + + # Collect all usernames that are registered as exchange admins + admin_usernames = ExchangeAdmin.objects.values_list("username", flat=True) + + # Query all users excluding those usernames and return minimal fields + users_qs = UserModel.objects.exclude(username__in=admin_usernames).values( + "id", "username", "first_name", "last_name", "email", "is_active", "date_joined" + ) + + # Filter by search query if provided + q = request.GET.get('q', '').strip() + if q: + users_qs = users_qs.filter( + models.Q(username__icontains=q) | + models.Q(first_name__icontains=q) | + models.Q(last_name__icontains=q) | + models.Q(email__icontains=q) + )[:10] # Limit to top 10 matches + + # Minimal serialization: convert date_joined to a string using str() + candidates = [] + for u in users_qs: + u = dict(u) + u["date_joined"] = str(u.get("date_joined")) if u.get("date_joined") else None + candidates.append(u) + + return JsonResponse({"candidates": candidates}, safe=False) diff --git a/django/university/routes/admin/AdminExchangeCourseUnitsView.py b/django/university/routes/admin/AdminExchangeCourseUnitsView.py index 368ca298..bc4381b6 100644 --- a/django/university/routes/admin/AdminExchangeCourseUnitsView.py +++ b/django/university/routes/admin/AdminExchangeCourseUnitsView.py @@ -13,3 +13,6 @@ def get(self, request): course_units = list(CourseUnit.objects.filter(id__in=admin_exchange_course_units.values_list("course_unit_id", flat=True)).values()) return JsonResponse(course_units, safe=False) + + + diff --git a/django/university/urls.py b/django/university/urls.py index c66f0533..73d9566e 100644 --- a/django/university/urls.py +++ b/django/university/urls.py @@ -32,6 +32,8 @@ from university.routes.admin.AdminExchangeCoursePeriodsView import AdminExchangeCoursePeriodsView from university.routes.exchange.related.ExchangeRelatedView import ExchangeRelatedView from university.routes.admin.AdminExchangeClassesView import AdminExchangeClassesView +from university.routes.admin.AdminExchangeCandidatesView import AdminExchangeCandidatesView +from university.routes.admin.AdminExchangeAdminsView import AdminExchangeAdminsView from university.middleware.exchange_admin import exchange_admin_required from university.routes.exchange.verify.DirectExchangeValidationView import DirectExchangeValidationView @@ -81,9 +83,13 @@ path('course_unit/enrollment/', CourseUnitEnrollmentView.as_view()), path('oidc-auth/', include('mozilla_django_oidc.urls')), path('exchange/admin/courses/', exchange_admin_required(AdminExchangeCoursesView.as_view())), + ## path('exchange/admin/exchange_admin/', exchange_admin_required(views.add_exchange_admin)), path('exchange/admin/course_units/', AdminExchangeCourseUnitsView.as_view()), path('exchange/admin/classes/', AdminExchangeClassesView.as_view()), path('exchange/admin/marketplace', exchange_admin_required(AdminMarketplaceView.as_view())), + path('exchange/admin/candidates/', exchange_admin_required(AdminExchangeCandidatesView.as_view())), + path('exchange/admin/admins/', exchange_admin_required(AdminExchangeAdminsView.as_view())), + path('exchange/admin/admins//', exchange_admin_required(AdminExchangeAdminsView.as_view())), path('exchange/admin/course_unit/periods/', exchange_admin_required(AdminExchangeCourseUnitPeriodsView.as_view())), path('exchange/admin/courses/periods/', exchange_admin_required(AdminExchangeCoursePeriodsView.as_view())), path('exchange/admin/course_unit//period/', exchange_admin_required(ExchangeCourseUnitPeriodView.as_view())), From 1c65ac67f7517233722e2a339b78a85168464f65 Mon Sep 17 00:00:00 2001 From: Jose Sousa <2409jmsousa@gmail.com> Date: Tue, 11 Nov 2025 22:48:59 +0000 Subject: [PATCH 3/7] feat: course and course unit search --- django/tts_be/settings.py | 2 + .../routes/admin/AdminExchangeAdminsView.py | 30 ++++++---- .../admin/AdminExchangeCandidatesView.py | 9 +-- .../AdminExchangeCourseUnitsSearchView.py | 54 +++++++++++++++++ .../admin/AdminExchangeCourseUnitsView.py | 58 +++++++++++++++++-- .../admin/AdminExchangeCoursesSearchView.py | 44 ++++++++++++++ .../routes/admin/AdminExchangeCoursesView.py | 53 +++++++++++++++-- django/university/urls.py | 6 ++ 8 files changed, 229 insertions(+), 27 deletions(-) create mode 100644 django/university/routes/admin/AdminExchangeCourseUnitsSearchView.py create mode 100644 django/university/routes/admin/AdminExchangeCoursesSearchView.py diff --git a/django/tts_be/settings.py b/django/tts_be/settings.py index d6a8f890..ce2323cf 100644 --- a/django/tts_be/settings.py +++ b/django/tts_be/settings.py @@ -45,6 +45,8 @@ EXCHANGE_SEMESTER = CONFIG["EXCHANGE_SEMESTER"] +EXCHANGE_COURSES_LIST = CONFIG.get("EXCHANGE_COURSES_LIST", "22862,22841") + ALLOWED_HOSTS = ['tts.niaefeup.pt', 'tts-staging.niaefeup.pt'] if DEBUG: diff --git a/django/university/routes/admin/AdminExchangeAdminsView.py b/django/university/routes/admin/AdminExchangeAdminsView.py index 543845cd..2e0092e2 100644 --- a/django/university/routes/admin/AdminExchangeAdminsView.py +++ b/django/university/routes/admin/AdminExchangeAdminsView.py @@ -2,9 +2,10 @@ from django.contrib.auth import get_user_model from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated - from exchange.models import ExchangeAdmin - +from django.http import JsonResponse +from django.core.paginator import Paginator +from exchange.models import ExchangeAdmin class AdminExchangeAdminsView(APIView): """Return user info for current exchange admins. @@ -17,23 +18,30 @@ class AdminExchangeAdminsView(APIView): def get(self, request): UserModel = get_user_model() - - # Get all admin usernames admin_usernames = list(ExchangeAdmin.objects.values_list("username", flat=True)) - - # Query user rows matching those usernames + users_qs = UserModel.objects.filter(username__in=admin_usernames).values( "id", "username", "first_name", "last_name", "email", "is_active", "date_joined" - ) + ).order_by('username') + + # Pagination + page = int(request.GET.get('page', 1)) + page_size = int(request.GET.get('page_size', 10)) + paginator = Paginator(users_qs, page_size) + page_obj = paginator.get_page(page) - # Minimal serialization: convert date_joined to a string using str() admins = [] - for u in users_qs: + for u in page_obj.object_list: u = dict(u) u["date_joined"] = str(u.get("date_joined")) if u.get("date_joined") else None admins.append(u) - return JsonResponse({"admins": admins}, safe=False) + return JsonResponse({ + "admins": admins, + "total_pages": paginator.num_pages, + "current_page": page_obj.number, + "total_count": paginator.count + }, safe=False) def post(self, request): username = request.data.get('username') @@ -42,7 +50,7 @@ def post(self, request): UserModel = get_user_model() try: - user = UserModel.objects.get(username=username) + UserModel.objects.get(username=username) except UserModel.DoesNotExist: return JsonResponse({'error': 'User not found'}, status=404) diff --git a/django/university/routes/admin/AdminExchangeCandidatesView.py b/django/university/routes/admin/AdminExchangeCandidatesView.py index 16afa219..92935693 100644 --- a/django/university/routes/admin/AdminExchangeCandidatesView.py +++ b/django/university/routes/admin/AdminExchangeCandidatesView.py @@ -3,7 +3,6 @@ from django.db import models from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated - from exchange.models import ExchangeAdmin @@ -17,28 +16,24 @@ class AdminExchangeCandidatesView(APIView): permission_classes = [IsAuthenticated] def get(self, request): - # Use a clearer name for the model class and select only the fields we need UserModel = get_user_model() - # Collect all usernames that are registered as exchange admins admin_usernames = ExchangeAdmin.objects.values_list("username", flat=True) - # Query all users excluding those usernames and return minimal fields users_qs = UserModel.objects.exclude(username__in=admin_usernames).values( "id", "username", "first_name", "last_name", "email", "is_active", "date_joined" ) - # Filter by search query if provided q = request.GET.get('q', '').strip() + limit = min(int(request.GET.get('limit', 10)), 100) if q: users_qs = users_qs.filter( models.Q(username__icontains=q) | models.Q(first_name__icontains=q) | models.Q(last_name__icontains=q) | models.Q(email__icontains=q) - )[:10] # Limit to top 10 matches + )[:limit] - # Minimal serialization: convert date_joined to a string using str() candidates = [] for u in users_qs: u = dict(u) diff --git a/django/university/routes/admin/AdminExchangeCourseUnitsSearchView.py b/django/university/routes/admin/AdminExchangeCourseUnitsSearchView.py new file mode 100644 index 00000000..c8ade07d --- /dev/null +++ b/django/university/routes/admin/AdminExchangeCourseUnitsSearchView.py @@ -0,0 +1,54 @@ +from django.http import JsonResponse +from django.db import models +from django.db.models import Case, When, IntegerField +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from django.conf import settings + +from university.models import CourseUnit + + +class AdminExchangeCourseUnitsSearchView(APIView): + """Return course units filtered by search query. + + This endpoint requires authentication. It returns a JSON object with a + `course_units` array containing basic fields for each course unit. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + exchange_courses_list = settings.EXCHANGE_COURSES_LIST + allowed_course_ids = [int(x.strip()) for x in exchange_courses_list.split(',') if x.strip()] + + course_units_qs = CourseUnit.objects.filter(coursemetadata__course__id__in=allowed_course_ids).distinct().values( + "id", "name", "acronym", "course__name", "course__acronym", "semester", "year" + ) + + q = request.GET.get('q', '').strip() + limit = min(int(request.GET.get('limit', 10)), 100) + if q: + course_units_qs = course_units_qs.annotate( + priority=Case( + When(acronym__iexact=q, then=4), + When(course__acronym__iexact=q, then=3), + When(acronym__icontains=q, then=2), + When(course__acronym__icontains=q, then=1), + default=0, + output_field=IntegerField() + ) + ).filter( + models.Q(name__icontains=q) | + models.Q(acronym__icontains=q) | + models.Q(course__name__icontains=q) | + models.Q(course__acronym__icontains=q) + ).order_by('-priority', 'acronym')[:limit] + + course_units = [] + for cu in course_units_qs: + cu = dict(cu) + cu["course_name"] = cu.pop("course__name") + cu["course_acronym"] = cu.pop("course__acronym") + course_units.append(cu) + + return JsonResponse({"course_units": course_units}, safe=False) \ No newline at end of file diff --git a/django/university/routes/admin/AdminExchangeCourseUnitsView.py b/django/university/routes/admin/AdminExchangeCourseUnitsView.py index bc4381b6..007daf19 100644 --- a/django/university/routes/admin/AdminExchangeCourseUnitsView.py +++ b/django/university/routes/admin/AdminExchangeCourseUnitsView.py @@ -1,18 +1,66 @@ from rest_framework.views import APIView from django.http import HttpResponse, JsonResponse +from rest_framework import status +from django.conf import settings -from university.models import CourseUnit +from university.models import CourseUnit, CourseMetadata -from exchange.models import ExchangeAdminCourseUnits +from exchange.models import ExchangeAdmin, ExchangeAdminCourseUnits class AdminExchangeCourseUnitsView(APIView): def get(self, request): - user = request.user - - admin_exchange_course_units = ExchangeAdminCourseUnits.objects.filter(exchange_admin__username=user.username) + # Allow admins to view other admins' course units + admin_username = request.GET.get('admin_username', request.user.username) + + admin_exchange_course_units = ExchangeAdminCourseUnits.objects.filter(exchange_admin__username=admin_username) course_units = list(CourseUnit.objects.filter(id__in=admin_exchange_course_units.values_list("course_unit_id", flat=True)).values()) return JsonResponse(course_units, safe=False) + def post(self, request): + exchange_courses_list = settings.EXCHANGE_COURSES_LIST + allowed_course_ids = [int(x.strip()) for x in exchange_courses_list.split(',') if x.strip()] + + course_unit_id = request.data.get('course_unit_id') + admin_username = request.data.get('admin_username', request.user.username) + if not course_unit_id: + return JsonResponse({'error': 'course_unit_id required'}, status=status.HTTP_400_BAD_REQUEST) + + try: + course_unit = CourseUnit.objects.get(id=course_unit_id) + except CourseUnit.DoesNotExist: + return JsonResponse({'error': 'Course unit not found'}, status=status.HTTP_404_NOT_FOUND) + + # Check if the course unit belongs to an allowed course + course_metadata = CourseMetadata.objects.filter(course_unit=course_unit).first() + if not course_metadata or course_metadata.course.id not in allowed_course_ids: + return JsonResponse({'error': 'Course unit not allowed for exchange'}, status=status.HTTP_400_BAD_REQUEST) + + try: + admin = ExchangeAdmin.objects.get(username=admin_username) + except ExchangeAdmin.DoesNotExist: + return JsonResponse({'error': 'Admin not found'}, status=status.HTTP_404_NOT_FOUND) + + if ExchangeAdminCourseUnits.objects.filter(exchange_admin=admin, course_unit=course_unit).exists(): + return JsonResponse({'error': 'Course unit already assigned'}, status=status.HTTP_400_BAD_REQUEST) + + ExchangeAdminCourseUnits.objects.create(exchange_admin=admin, course_unit=course_unit) + return JsonResponse({'success': True}) + + def delete(self, request, course_unit_id=None): + if not course_unit_id: + return JsonResponse({'error': 'course_unit_id required'}, status=status.HTTP_400_BAD_REQUEST) + + admin_username = request.GET.get('admin_username', request.user.username) + + try: + admin = ExchangeAdmin.objects.get(username=admin_username) + course_unit = CourseUnit.objects.get(id=course_unit_id) + relation = ExchangeAdminCourseUnits.objects.get(exchange_admin=admin, course_unit=course_unit) + relation.delete() + return JsonResponse({'success': True}) + except (ExchangeAdmin.DoesNotExist, CourseUnit.DoesNotExist, ExchangeAdminCourseUnits.DoesNotExist): + return JsonResponse({'error': 'Not found'}, status=status.HTTP_404_NOT_FOUND) + diff --git a/django/university/routes/admin/AdminExchangeCoursesSearchView.py b/django/university/routes/admin/AdminExchangeCoursesSearchView.py new file mode 100644 index 00000000..1c73fc6a --- /dev/null +++ b/django/university/routes/admin/AdminExchangeCoursesSearchView.py @@ -0,0 +1,44 @@ +from django.http import JsonResponse +from django.db import models +from django.db.models import Value +from django.db.models.functions import Replace +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from django.conf import settings +from university.models import Course + + +class AdminExchangeCoursesSearchView(APIView): + """Return courses filtered by search query. + + This endpoint requires authentication. It returns a JSON object with a + `courses` array containing basic fields for each course. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + exchange_courses_list = settings.EXCHANGE_COURSES_LIST + allowed_course_ids = [int(x.strip()) for x in exchange_courses_list.split(',') if x.strip()] + + courses_qs = Course.objects.filter(id__in=allowed_course_ids).values( + "id", "name", "acronym", "course_type", "year", "faculty__acronym", "faculty__name" + ) + + q = request.GET.get('q', '').strip() + limit = min(int(request.GET.get('limit', 10)), 100) + if q: + courses_qs = courses_qs.annotate(clean_acronym=Replace('acronym', Value('.'), Value(''))).filter( + models.Q(name__icontains=q) | + models.Q(acronym__icontains=q) | + models.Q(clean_acronym__icontains=q) + )[:limit] + + courses = [] + for c in courses_qs: + c = dict(c) + c["faculty_acronym"] = c.pop("faculty__acronym") + c["faculty_name"] = c.pop("faculty__name") + courses.append(c) + + return JsonResponse({"courses": courses}, safe=False) \ No newline at end of file diff --git a/django/university/routes/admin/AdminExchangeCoursesView.py b/django/university/routes/admin/AdminExchangeCoursesView.py index 3ce848b1..dc84b4f1 100644 --- a/django/university/routes/admin/AdminExchangeCoursesView.py +++ b/django/university/routes/admin/AdminExchangeCoursesView.py @@ -1,15 +1,60 @@ from rest_framework.views import APIView from django.http import HttpResponse, JsonResponse +from rest_framework import status +from django.conf import settings from university.models import Course -from exchange.models import ExchangeAdminCourses +from exchange.models import ExchangeAdmin, ExchangeAdminCourses class AdminExchangeCoursesView(APIView): def get(self, request): - user = request.user - - admin_exchange_courses = ExchangeAdminCourses.objects.filter(exchange_admin__username=user.username) + admin_username = request.GET.get('admin_username', request.user.username) + + admin_exchange_courses = ExchangeAdminCourses.objects.filter(exchange_admin__username=admin_username) courses = list(Course.objects.filter(id__in=admin_exchange_courses.values_list("course_id", flat=True)).values()) return JsonResponse(courses, safe=False) + + def post(self, request): + exchange_courses_list = settings.EXCHANGE_COURSES_LIST + allowed_course_ids = [int(x.strip()) for x in exchange_courses_list.split(',') if x.strip()] + + course_id = request.data.get('course_id') + admin_username = request.data.get('admin_username', request.user.username) + if not course_id: + return JsonResponse({'error': 'course_id required'}, status=status.HTTP_400_BAD_REQUEST) + + try: + course = Course.objects.get(id=course_id) + except Course.DoesNotExist: + return JsonResponse({'error': 'Course not found'}, status=status.HTTP_404_NOT_FOUND) + + if course.id not in allowed_course_ids: + return JsonResponse({'error': 'Course not allowed for exchange'}, status=status.HTTP_400_BAD_REQUEST) + + try: + admin = ExchangeAdmin.objects.get(username=admin_username) + except ExchangeAdmin.DoesNotExist: + return JsonResponse({'error': 'Admin not found'}, status=status.HTTP_404_NOT_FOUND) + + if ExchangeAdminCourses.objects.filter(exchange_admin=admin, course=course).exists(): + return JsonResponse({'error': 'Course already assigned'}, status=status.HTTP_400_BAD_REQUEST) + + ExchangeAdminCourses.objects.create(exchange_admin=admin, course=course) + return JsonResponse({'success': True}) + + def delete(self, request, course_id=None): + if not course_id: + return JsonResponse({'error': 'course_id required'}, status=status.HTTP_400_BAD_REQUEST) + + admin_username = request.GET.get('admin_username', request.user.username) + + try: + admin = ExchangeAdmin.objects.get(username=admin_username) + course = Course.objects.get(id=course_id) + relation = ExchangeAdminCourses.objects.get(exchange_admin=admin, course=course) + relation.delete() + return JsonResponse({'success': True}) + except (ExchangeAdmin.DoesNotExist, Course.DoesNotExist, ExchangeAdminCourses.DoesNotExist): + return JsonResponse({'error': 'Not found'}, status=status.HTTP_404_NOT_FOUND) diff --git a/django/university/urls.py b/django/university/urls.py index 73d9566e..7dc2cfd1 100644 --- a/django/university/urls.py +++ b/django/university/urls.py @@ -32,6 +32,8 @@ from university.routes.admin.AdminExchangeCoursePeriodsView import AdminExchangeCoursePeriodsView from university.routes.exchange.related.ExchangeRelatedView import ExchangeRelatedView from university.routes.admin.AdminExchangeClassesView import AdminExchangeClassesView +from university.routes.admin.AdminExchangeCoursesSearchView import AdminExchangeCoursesSearchView +from university.routes.admin.AdminExchangeCourseUnitsSearchView import AdminExchangeCourseUnitsSearchView from university.routes.admin.AdminExchangeCandidatesView import AdminExchangeCandidatesView from university.routes.admin.AdminExchangeAdminsView import AdminExchangeAdminsView @@ -83,10 +85,14 @@ path('course_unit/enrollment/', CourseUnitEnrollmentView.as_view()), path('oidc-auth/', include('mozilla_django_oidc.urls')), path('exchange/admin/courses/', exchange_admin_required(AdminExchangeCoursesView.as_view())), + path('exchange/admin/courses//', exchange_admin_required(AdminExchangeCoursesView.as_view())), ## path('exchange/admin/exchange_admin/', exchange_admin_required(views.add_exchange_admin)), path('exchange/admin/course_units/', AdminExchangeCourseUnitsView.as_view()), + path('exchange/admin/course_units//', AdminExchangeCourseUnitsView.as_view()), path('exchange/admin/classes/', AdminExchangeClassesView.as_view()), path('exchange/admin/marketplace', exchange_admin_required(AdminMarketplaceView.as_view())), + path('exchange/admin/courses/search/', exchange_admin_required(AdminExchangeCoursesSearchView.as_view())), + path('exchange/admin/course_units/search/', exchange_admin_required(AdminExchangeCourseUnitsSearchView.as_view())), path('exchange/admin/candidates/', exchange_admin_required(AdminExchangeCandidatesView.as_view())), path('exchange/admin/admins/', exchange_admin_required(AdminExchangeAdminsView.as_view())), path('exchange/admin/admins//', exchange_admin_required(AdminExchangeAdminsView.as_view())), From 78a6155859a78df55327b7803df58293f3703d7b Mon Sep 17 00:00:00 2001 From: Jose Sousa <2409jmsousa@gmail.com> Date: Wed, 12 Nov 2025 01:30:10 +0000 Subject: [PATCH 4/7] feat: improved search a bit --- .../routes/admin/AdminExchangeAdminsView.py | 70 ++++++++----------- .../admin/AdminExchangeCandidatesView.py | 46 +++++++----- 2 files changed, 57 insertions(+), 59 deletions(-) diff --git a/django/university/routes/admin/AdminExchangeAdminsView.py b/django/university/routes/admin/AdminExchangeAdminsView.py index 2e0092e2..66d15f67 100644 --- a/django/university/routes/admin/AdminExchangeAdminsView.py +++ b/django/university/routes/admin/AdminExchangeAdminsView.py @@ -1,32 +1,49 @@ +from django.db.models import Q from django.http import JsonResponse from django.contrib.auth import get_user_model +from django.core.paginator import Paginator from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from exchange.models import ExchangeAdmin -from django.http import JsonResponse -from django.core.paginator import Paginator -from exchange.models import ExchangeAdmin -class AdminExchangeAdminsView(APIView): - """Return user info for current exchange admins. - Requires authentication and is intended to be protected by the - `exchange_admin_required` middleware when routed. - """ +## repeated code may move to better place later +def apply_search_filter(queryset, q): + q = q.strip() + if not q: + return queryset + parts = q.split() + if len(parts) == 2: + first, last = parts + return queryset.filter( + Q(first_name__icontains=first) & Q(last_name__icontains=last) + ) + else: + return queryset.filter( + Q(username__icontains=q) | + Q(first_name__icontains=q) | + Q(last_name__icontains=q) | + Q(email__icontains=q) + ) + + +class AdminExchangeAdminsView(APIView): permission_classes = [IsAuthenticated] def get(self, request): UserModel = get_user_model() - admin_usernames = list(ExchangeAdmin.objects.values_list("username", flat=True)) - + admin_usernames = ExchangeAdmin.objects.values_list("username", flat=True) + users_qs = UserModel.objects.filter(username__in=admin_usernames).values( "id", "username", "first_name", "last_name", "email", "is_active", "date_joined" ).order_by('username') - # Pagination + q = request.GET.get('q', '').strip() + users_qs = apply_search_filter(users_qs, q) + page = int(request.GET.get('page', 1)) - page_size = int(request.GET.get('page_size', 10)) + page_size = min(int(request.GET.get('page_size', 10)), 100) paginator = Paginator(users_qs, page_size) page_obj = paginator.get_page(page) @@ -41,31 +58,4 @@ def get(self, request): "total_pages": paginator.num_pages, "current_page": page_obj.number, "total_count": paginator.count - }, safe=False) - - def post(self, request): - username = request.data.get('username') - if not username: - return JsonResponse({'error': 'Username required'}, status=400) - - UserModel = get_user_model() - try: - UserModel.objects.get(username=username) - except UserModel.DoesNotExist: - return JsonResponse({'error': 'User not found'}, status=404) - - if ExchangeAdmin.objects.filter(username=username).exists(): - return JsonResponse({'error': 'User is already an admin'}, status=400) - - ExchangeAdmin.objects.create(username=username) - return JsonResponse({'message': 'Admin added successfully'}) - - def delete(self, request, username): - if not username: - return JsonResponse({'error': 'Username required'}, status=400) - - deleted, _ = ExchangeAdmin.objects.filter(username=username).delete() - if deleted == 0: - return JsonResponse({'error': 'Admin not found'}, status=404) - - return JsonResponse({'message': 'Admin removed successfully'}) + }) diff --git a/django/university/routes/admin/AdminExchangeCandidatesView.py b/django/university/routes/admin/AdminExchangeCandidatesView.py index 92935693..29047acf 100644 --- a/django/university/routes/admin/AdminExchangeCandidatesView.py +++ b/django/university/routes/admin/AdminExchangeCandidatesView.py @@ -1,43 +1,51 @@ +from django.db.models import Q from django.http import JsonResponse from django.contrib.auth import get_user_model -from django.db import models +from django.core.paginator import Paginator from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from exchange.models import ExchangeAdmin +## repeated code may move to better place later +def apply_search_filter(queryset, q): + q = q.strip() + if not q: + return queryset + + parts = q.split() + if len(parts) == 2: + first, last = parts + return queryset.filter( + Q(first_name__icontains=first) & Q(last_name__icontains=last) + ) + else: + return queryset.filter( + Q(username__icontains=q) | + Q(first_name__icontains=q) | + Q(last_name__icontains=q) | + Q(email__icontains=q) + ) -class AdminExchangeCandidatesView(APIView): - """Return all auth users whose username is not present in the ExchangeAdmin table. - - This endpoint requires authentication. It returns a JSON object with a - `candidates` array containing basic fields for each user. - """ +class AdminExchangeCandidatesView(APIView): permission_classes = [IsAuthenticated] def get(self, request): UserModel = get_user_model() - admin_usernames = ExchangeAdmin.objects.values_list("username", flat=True) users_qs = UserModel.objects.exclude(username__in=admin_usernames).values( "id", "username", "first_name", "last_name", "email", "is_active", "date_joined" - ) + ).order_by('username') q = request.GET.get('q', '').strip() - limit = min(int(request.GET.get('limit', 10)), 100) - if q: - users_qs = users_qs.filter( - models.Q(username__icontains=q) | - models.Q(first_name__icontains=q) | - models.Q(last_name__icontains=q) | - models.Q(email__icontains=q) - )[:limit] + limit = min(int(request.GET.get('limit', 10)), 100) + users_qs = apply_search_filter(users_qs, q) candidates = [] - for u in users_qs: + for u in users_qs[:limit]: u = dict(u) u["date_joined"] = str(u.get("date_joined")) if u.get("date_joined") else None candidates.append(u) - return JsonResponse({"candidates": candidates}, safe=False) + return JsonResponse({"candidates": candidates}) \ No newline at end of file From bd03c6f27b7da106b1092e0842417a8772cd2cc2 Mon Sep 17 00:00:00 2001 From: Jose Sousa <2409jmsousa@gmail.com> Date: Wed, 12 Nov 2025 01:39:54 +0000 Subject: [PATCH 5/7] feat: added migration to cascade delete --- ...ngeadmincourses_exchange_admin_and_more.py | 24 +++++++++++++++++ django/exchange/models.py | 10 ++++--- .../routes/admin/AdminExchangeAdminsView.py | 27 +++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 django/exchange/migrations/0009_alter_exchangeadmincourses_exchange_admin_and_more.py diff --git a/django/exchange/migrations/0009_alter_exchangeadmincourses_exchange_admin_and_more.py b/django/exchange/migrations/0009_alter_exchangeadmincourses_exchange_admin_and_more.py new file mode 100644 index 00000000..f1efd926 --- /dev/null +++ b/django/exchange/migrations/0009_alter_exchangeadmincourses_exchange_admin_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.12 on 2025-11-12 01:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('exchange', '0008_directexchange_last_validated'), + ] + + operations = [ + migrations.AlterField( + model_name='exchangeadmincourses', + name='exchange_admin', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exchange.exchangeadmin'), + ), + migrations.AlterField( + model_name='exchangeadmincourseunits', + name='exchange_admin', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exchange.exchangeadmin'), + ), + ] diff --git a/django/exchange/models.py b/django/exchange/models.py index 4ee13cef..348f376f 100644 --- a/django/exchange/models.py +++ b/django/exchange/models.py @@ -63,9 +63,10 @@ class Meta: managed = True db_table = 'exchange_admin' + class ExchangeAdminCourseUnits(models.Model): - exchange_admin = models.ForeignKey(ExchangeAdmin, models.DO_NOTHING) - course_unit = models.ForeignKey(CourseUnit, models.DO_NOTHING) + exchange_admin = models.ForeignKey(ExchangeAdmin, on_delete=models.CASCADE) + course_unit = models.ForeignKey(CourseUnit, on_delete=models.DO_NOTHING) class Meta: managed = True @@ -73,14 +74,15 @@ class Meta: class ExchangeAdminCourses(models.Model): - exchange_admin = models.ForeignKey(ExchangeAdmin, models.DO_NOTHING) - course = models.ForeignKey(Course, models.DO_NOTHING) + exchange_admin = models.ForeignKey(ExchangeAdmin, on_delete=models.CASCADE) + course = models.ForeignKey(Course, on_delete=models.DO_NOTHING) class Meta: managed = True db_table = 'exchange_admin_courses' + class ExchangeExpirations(models.Model): course_unit = models.ForeignKey(CourseUnit, models.DO_NOTHING) active_date = models.DateTimeField() diff --git a/django/university/routes/admin/AdminExchangeAdminsView.py b/django/university/routes/admin/AdminExchangeAdminsView.py index 66d15f67..9befabd3 100644 --- a/django/university/routes/admin/AdminExchangeAdminsView.py +++ b/django/university/routes/admin/AdminExchangeAdminsView.py @@ -59,3 +59,30 @@ def get(self, request): "current_page": page_obj.number, "total_count": paginator.count }) + + def post(self, request): + username = request.data.get('username') + if not username: + return JsonResponse({'error': 'Username required'}, status=400) + + UserModel = get_user_model() + try: + UserModel.objects.get(username=username) + except UserModel.DoesNotExist: + return JsonResponse({'error': 'User not found'}, status=404) + + if ExchangeAdmin.objects.filter(username=username).exists(): + return JsonResponse({'error': 'User is already an admin'}, status=400) + + ExchangeAdmin.objects.create(username=username) + return JsonResponse({'message': 'Admin added successfully'}) + + def delete(self, request, username): + if not username: + return JsonResponse({'error': 'Username required'}, status=400) + + deleted, _ = ExchangeAdmin.objects.filter(username=username).delete() + if deleted == 0: + return JsonResponse({'error': 'Admin not found'}, status=404) + + return JsonResponse({'message': 'Admin removed successfully'}) From 70e0767837f3e14261e59babb9978acd17748c6c Mon Sep 17 00:00:00 2001 From: Jose Sousa <2409jmsousa@gmail.com> Date: Mon, 29 Dec 2025 17:09:06 +0000 Subject: [PATCH 6/7] feat: added issuper middleware to critical routes --- django/university/middleware/is_superuser.py | 10 ++++++++++ django/university/urls.py | 7 ++++--- 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 django/university/middleware/is_superuser.py diff --git a/django/university/middleware/is_superuser.py b/django/university/middleware/is_superuser.py new file mode 100644 index 00000000..6e91e5cb --- /dev/null +++ b/django/university/middleware/is_superuser.py @@ -0,0 +1,10 @@ +from django.http import HttpResponseForbidden +from functools import wraps + +def superuser_required(view_func): + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + if not request.user.is_authenticated or not request.user.is_superuser: + return HttpResponseForbidden("Superuser permissions required") + return view_func(request, *args, **kwargs) + return _wrapped_view \ No newline at end of file diff --git a/django/university/urls.py b/django/university/urls.py index 7dc2cfd1..3c5ff6dc 100644 --- a/django/university/urls.py +++ b/django/university/urls.py @@ -38,6 +38,7 @@ from university.routes.admin.AdminExchangeAdminsView import AdminExchangeAdminsView from university.middleware.exchange_admin import exchange_admin_required +from university.middleware.is_superuser import superuser_required from university.routes.exchange.verify.DirectExchangeValidationView import DirectExchangeValidationView from tts_be.settings import FEDERATED_AUTH @@ -93,9 +94,9 @@ path('exchange/admin/marketplace', exchange_admin_required(AdminMarketplaceView.as_view())), path('exchange/admin/courses/search/', exchange_admin_required(AdminExchangeCoursesSearchView.as_view())), path('exchange/admin/course_units/search/', exchange_admin_required(AdminExchangeCourseUnitsSearchView.as_view())), - path('exchange/admin/candidates/', exchange_admin_required(AdminExchangeCandidatesView.as_view())), - path('exchange/admin/admins/', exchange_admin_required(AdminExchangeAdminsView.as_view())), - path('exchange/admin/admins//', exchange_admin_required(AdminExchangeAdminsView.as_view())), + path('exchange/admin/candidates/', superuser_required(exchange_admin_required(AdminExchangeCandidatesView.as_view()))), + path('exchange/admin/admins/', superuser_required(exchange_admin_required(AdminExchangeAdminsView.as_view()))), + path('exchange/admin/admins//', superuser_required(exchange_admin_required(AdminExchangeAdminsView.as_view()))), path('exchange/admin/course_unit/periods/', exchange_admin_required(AdminExchangeCourseUnitPeriodsView.as_view())), path('exchange/admin/courses/periods/', exchange_admin_required(AdminExchangeCoursePeriodsView.as_view())), path('exchange/admin/course_unit//period/', exchange_admin_required(ExchangeCourseUnitPeriodView.as_view())), From deec41ceb5b6b41376c8f901f4de32e8e58dbd62 Mon Sep 17 00:00:00 2001 From: Jose Sousa <2409jmsousa@gmail.com> Date: Mon, 29 Dec 2025 17:46:50 +0000 Subject: [PATCH 7/7] feat: add is super in login response --- django/university/routes/auth/InfoView.py | 1 + 1 file changed, 1 insertion(+) diff --git a/django/university/routes/auth/InfoView.py b/django/university/routes/auth/InfoView.py index 1bf3d3d2..198b2b36 100644 --- a/django/university/routes/auth/InfoView.py +++ b/django/university/routes/auth/InfoView.py @@ -34,6 +34,7 @@ def get(self, request): "name": f"{request.user.first_name} {request.user.last_name}", "eligible_exchange": True if bool(DEBUG) else eligible_exchange, "is_admin": is_admin, + "is_superuser": request.user.is_superuser }, safe=False) else: return JsonResponse({