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/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/controllers/SigarraController.py b/django/university/controllers/SigarraController.py index 8afd5e59..dc5a7afd 100644 --- a/django/university/controllers/SigarraController.py +++ b/django/university/controllers/SigarraController.py @@ -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) 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/routes/admin/AdminExchangeAdminsView.py b/django/university/routes/admin/AdminExchangeAdminsView.py new file mode 100644 index 00000000..9befabd3 --- /dev/null +++ b/django/university/routes/admin/AdminExchangeAdminsView.py @@ -0,0 +1,88 @@ +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 + + +## 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 = 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') + + q = request.GET.get('q', '').strip() + users_qs = apply_search_filter(users_qs, q) + + page = int(request.GET.get('page', 1)) + page_size = min(int(request.GET.get('page_size', 10)), 100) + paginator = Paginator(users_qs, page_size) + page_obj = paginator.get_page(page) + + admins = [] + 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, + "total_pages": paginator.num_pages, + "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'}) diff --git a/django/university/routes/admin/AdminExchangeCandidatesView.py b/django/university/routes/admin/AdminExchangeCandidatesView.py new file mode 100644 index 00000000..29047acf --- /dev/null +++ b/django/university/routes/admin/AdminExchangeCandidatesView.py @@ -0,0 +1,51 @@ +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 + +## 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): + 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) + users_qs = apply_search_filter(users_qs, q) + + candidates = [] + 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}) \ No newline at end of file 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 368ca298..007daf19 100644 --- a/django/university/routes/admin/AdminExchangeCourseUnitsView.py +++ b/django/university/routes/admin/AdminExchangeCourseUnitsView.py @@ -1,15 +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/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({ diff --git a/django/university/urls.py b/django/university/urls.py index c66f0533..3c5ff6dc 100644 --- a/django/university/urls.py +++ b/django/university/urls.py @@ -32,8 +32,13 @@ 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 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 @@ -81,9 +86,17 @@ 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/', 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())),