mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-10-31 17:13:08 +00:00 
			
		
		
		
	replace drf by django-ninja
This commit is contained in:
		| @@ -1,14 +0,0 @@ | ||||
| # | ||||
| # Copyright 2023 © AE UTBM | ||||
| # ae@utbm.fr / ae.info@utbm.fr | ||||
| # | ||||
| # This file is part of the website of the UTBM Student Association (AE UTBM), | ||||
| # https://ae.utbm.fr. | ||||
| # | ||||
| # You can find the source code of the website at https://github.com/ae-utbm/sith3 | ||||
| # | ||||
| # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) | ||||
| # SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE | ||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | ||||
| # | ||||
| # | ||||
							
								
								
									
										16
									
								
								api/admin.py
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								api/admin.py
									
									
									
									
									
								
							| @@ -1,16 +0,0 @@ | ||||
| # | ||||
| # Copyright 2023 © AE UTBM | ||||
| # ae@utbm.fr / ae.info@utbm.fr | ||||
| # | ||||
| # This file is part of the website of the UTBM Student Association (AE UTBM), | ||||
| # https://ae.utbm.fr. | ||||
| # | ||||
| # You can find the source code of the website at https://github.com/ae-utbm/sith3 | ||||
| # | ||||
| # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) | ||||
| # SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE | ||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | ||||
| # | ||||
| # | ||||
|  | ||||
| # Register your models here. | ||||
| @@ -1,16 +0,0 @@ | ||||
| # | ||||
| # Copyright 2023 © AE UTBM | ||||
| # ae@utbm.fr / ae.info@utbm.fr | ||||
| # | ||||
| # This file is part of the website of the UTBM Student Association (AE UTBM), | ||||
| # https://ae.utbm.fr. | ||||
| # | ||||
| # You can find the source code of the website at https://github.com/ae-utbm/sith3 | ||||
| # | ||||
| # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) | ||||
| # SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE | ||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | ||||
| # | ||||
| # | ||||
|  | ||||
| # Create your models here. | ||||
							
								
								
									
										16
									
								
								api/tests.py
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								api/tests.py
									
									
									
									
									
								
							| @@ -1,16 +0,0 @@ | ||||
| # | ||||
| # Copyright 2023 © AE UTBM | ||||
| # ae@utbm.fr / ae.info@utbm.fr | ||||
| # | ||||
| # This file is part of the website of the UTBM Student Association (AE UTBM), | ||||
| # https://ae.utbm.fr. | ||||
| # | ||||
| # You can find the source code of the website at https://github.com/ae-utbm/sith3 | ||||
| # | ||||
| # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) | ||||
| # SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE | ||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | ||||
| # | ||||
| # | ||||
|  | ||||
| # Create your tests here. | ||||
							
								
								
									
										49
									
								
								api/urls.py
									
									
									
									
									
								
							
							
						
						
									
										49
									
								
								api/urls.py
									
									
									
									
									
								
							| @@ -1,49 +0,0 @@ | ||||
| # | ||||
| # Copyright 2023 © AE UTBM | ||||
| # ae@utbm.fr / ae.info@utbm.fr | ||||
| # | ||||
| # This file is part of the website of the UTBM Student Association (AE UTBM), | ||||
| # https://ae.utbm.fr. | ||||
| # | ||||
| # You can find the source code of the website at https://github.com/ae-utbm/sith3 | ||||
| # | ||||
| # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) | ||||
| # SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE | ||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | ||||
| # | ||||
| # | ||||
|  | ||||
| from django.urls import include, path, re_path | ||||
| from rest_framework import routers | ||||
|  | ||||
| from api.views import * | ||||
|  | ||||
| # Router config | ||||
| router = routers.DefaultRouter() | ||||
| router.register(r"counter", CounterViewSet, basename="api_counter") | ||||
| router.register(r"user", UserViewSet, basename="api_user") | ||||
| router.register(r"club", ClubViewSet, basename="api_club") | ||||
| router.register(r"group", GroupViewSet, basename="api_group") | ||||
|  | ||||
| # Launderette | ||||
| router.register( | ||||
|     r"launderette/place", LaunderettePlaceViewSet, basename="api_launderette_place" | ||||
| ) | ||||
| router.register( | ||||
|     r"launderette/machine", | ||||
|     LaunderetteMachineViewSet, | ||||
|     basename="api_launderette_machine", | ||||
| ) | ||||
| router.register( | ||||
|     r"launderette/token", LaunderetteTokenViewSet, basename="api_launderette_token" | ||||
| ) | ||||
|  | ||||
| urlpatterns = [ | ||||
|     # API | ||||
|     re_path(r"^", include(router.urls)), | ||||
|     re_path(r"^login/", include("rest_framework.urls", namespace="rest_framework")), | ||||
|     re_path(r"^markdown$", RenderMarkdown, name="api_markdown"), | ||||
|     re_path(r"^mailings$", FetchMailingLists, name="mailings_fetch"), | ||||
|     re_path(r"^uv$", uv_endpoint, name="uv_endpoint"), | ||||
|     path("sas/<int:user>", all_pictures_of_user_endpoint, name="all_pictures_of_user"), | ||||
| ] | ||||
| @@ -1,70 +0,0 @@ | ||||
| # | ||||
| # Copyright 2023 © AE UTBM | ||||
| # ae@utbm.fr / ae.info@utbm.fr | ||||
| # | ||||
| # This file is part of the website of the UTBM Student Association (AE UTBM), | ||||
| # https://ae.utbm.fr. | ||||
| # | ||||
| # You can find the source code of the website at https://github.com/ae-utbm/sith3 | ||||
| # | ||||
| # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) | ||||
| # SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE | ||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | ||||
| # | ||||
| # | ||||
|  | ||||
| from django.core.exceptions import PermissionDenied | ||||
| from django.db.models.query import QuerySet | ||||
| from rest_framework import viewsets | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| from core.views import can_edit, can_view | ||||
|  | ||||
|  | ||||
| def check_if(obj, user, test): | ||||
|     """Detect if it's a single object or a queryset. | ||||
|  | ||||
|     Apply a given test on individual object and return global permission. | ||||
|     """ | ||||
|     if isinstance(obj, QuerySet): | ||||
|         for o in obj: | ||||
|             if test(o, user) is False: | ||||
|                 return False | ||||
|         return True | ||||
|     else: | ||||
|         return test(obj, user) | ||||
|  | ||||
|  | ||||
| class ManageModelMixin: | ||||
|     @action(detail=True) | ||||
|     def id(self, request, pk=None): | ||||
|         """Get by id (api/v1/router/{pk}/id/).""" | ||||
|         self.queryset = get_object_or_404(self.queryset.filter(id=pk)) | ||||
|         serializer = self.get_serializer(self.queryset) | ||||
|         return Response(serializer.data) | ||||
|  | ||||
|  | ||||
| class RightModelViewSet(ManageModelMixin, viewsets.ModelViewSet): | ||||
|     def dispatch(self, request, *arg, **kwargs): | ||||
|         res = super().dispatch(request, *arg, **kwargs) | ||||
|         obj = self.queryset | ||||
|         user = self.request.user | ||||
|         try: | ||||
|             if request.method == "GET" and check_if(obj, user, can_view): | ||||
|                 return res | ||||
|             if request.method != "GET" and check_if(obj, user, can_edit): | ||||
|                 return res | ||||
|         except: | ||||
|             pass  # To prevent bug with Anonymous user | ||||
|         raise PermissionDenied | ||||
|  | ||||
|  | ||||
| from .api import * | ||||
| from .club import * | ||||
| from .counter import * | ||||
| from .group import * | ||||
| from .launderette import * | ||||
| from .sas import * | ||||
| from .user import * | ||||
| from .uv import * | ||||
| @@ -1,31 +0,0 @@ | ||||
| # | ||||
| # Copyright 2023 © AE UTBM | ||||
| # ae@utbm.fr / ae.info@utbm.fr | ||||
| # | ||||
| # This file is part of the website of the UTBM Student Association (AE UTBM), | ||||
| # https://ae.utbm.fr. | ||||
| # | ||||
| # You can find the source code of the website at https://github.com/ae-utbm/sith3 | ||||
| # | ||||
| # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) | ||||
| # SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE | ||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | ||||
| # | ||||
| # | ||||
|  | ||||
| from rest_framework.decorators import api_view, renderer_classes | ||||
| from rest_framework.renderers import StaticHTMLRenderer | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| from core.templatetags.renderer import markdown | ||||
|  | ||||
|  | ||||
| @api_view(["POST"]) | ||||
| @renderer_classes((StaticHTMLRenderer,)) | ||||
| def RenderMarkdown(request): | ||||
|     """Render Markdown.""" | ||||
|     try: | ||||
|         data = markdown(request.POST["text"]) | ||||
|     except: | ||||
|         data = "Error" | ||||
|     return Response(data) | ||||
| @@ -1,51 +0,0 @@ | ||||
| # | ||||
| # Copyright 2023 © AE UTBM | ||||
| # ae@utbm.fr / ae.info@utbm.fr | ||||
| # | ||||
| # This file is part of the website of the UTBM Student Association (AE UTBM), | ||||
| # https://ae.utbm.fr. | ||||
| # | ||||
| # You can find the source code of the website at https://github.com/ae-utbm/sith3 | ||||
| # | ||||
| # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) | ||||
| # SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE | ||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | ||||
| # | ||||
| # | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.core.exceptions import PermissionDenied | ||||
| from rest_framework import serializers | ||||
| from rest_framework.decorators import api_view, renderer_classes | ||||
| from rest_framework.renderers import StaticHTMLRenderer | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| from api.views import RightModelViewSet | ||||
| from club.models import Club, Mailing | ||||
|  | ||||
|  | ||||
| class ClubSerializer(serializers.ModelSerializer): | ||||
|     class Meta: | ||||
|         model = Club | ||||
|         fields = ("id", "name", "unix_name", "address", "members") | ||||
|  | ||||
|  | ||||
| class ClubViewSet(RightModelViewSet): | ||||
|     """Manage Clubs (api/v1/club/).""" | ||||
|  | ||||
|     serializer_class = ClubSerializer | ||||
|     queryset = Club.objects.all() | ||||
|  | ||||
|  | ||||
| @api_view(["GET"]) | ||||
| @renderer_classes((StaticHTMLRenderer,)) | ||||
| def FetchMailingLists(request): | ||||
|     key = request.GET.get("key", "") | ||||
|     if key != settings.SITH_MAILING_FETCH_KEY: | ||||
|         raise PermissionDenied | ||||
|     data = "" | ||||
|     for mailing in Mailing.objects.filter( | ||||
|         is_moderated=True, club__is_active=True | ||||
|     ).all(): | ||||
|         data += mailing.fetch_format() + "\n" | ||||
|     return Response(data) | ||||
| @@ -1,46 +0,0 @@ | ||||
| # | ||||
| # Copyright 2023 © AE UTBM | ||||
| # ae@utbm.fr / ae.info@utbm.fr | ||||
| # | ||||
| # This file is part of the website of the UTBM Student Association (AE UTBM), | ||||
| # https://ae.utbm.fr. | ||||
| # | ||||
| # You can find the source code of the website at https://github.com/ae-utbm/sith3 | ||||
| # | ||||
| # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) | ||||
| # SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE | ||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | ||||
| # | ||||
| # | ||||
|  | ||||
| from rest_framework import serializers | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| from api.views import RightModelViewSet | ||||
| from counter.models import Counter | ||||
|  | ||||
|  | ||||
| class CounterSerializer(serializers.ModelSerializer): | ||||
|     is_open = serializers.BooleanField(read_only=True) | ||||
|     barman_list = serializers.ListField( | ||||
|         child=serializers.IntegerField(), read_only=True | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Counter | ||||
|         fields = ("id", "name", "type", "club", "products", "is_open", "barman_list") | ||||
|  | ||||
|  | ||||
| class CounterViewSet(RightModelViewSet): | ||||
|     """Manage Counters (api/v1/counter/).""" | ||||
|  | ||||
|     serializer_class = CounterSerializer | ||||
|     queryset = Counter.objects.all() | ||||
|  | ||||
|     @action(detail=False) | ||||
|     def bar(self, request): | ||||
|         """Return all bars (api/v1/counter/bar/).""" | ||||
|         self.queryset = self.queryset.filter(type="BAR") | ||||
|         serializer = self.get_serializer(self.queryset, many=True) | ||||
|         return Response(serializer.data) | ||||
| @@ -1,31 +0,0 @@ | ||||
| # | ||||
| # Copyright 2023 © AE UTBM | ||||
| # ae@utbm.fr / ae.info@utbm.fr | ||||
| # | ||||
| # This file is part of the website of the UTBM Student Association (AE UTBM), | ||||
| # https://ae.utbm.fr. | ||||
| # | ||||
| # You can find the source code of the website at https://github.com/ae-utbm/sith3 | ||||
| # | ||||
| # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) | ||||
| # SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE | ||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | ||||
| # | ||||
| # | ||||
|  | ||||
| from rest_framework import serializers | ||||
|  | ||||
| from api.views import RightModelViewSet | ||||
| from core.models import RealGroup | ||||
|  | ||||
|  | ||||
| class GroupSerializer(serializers.ModelSerializer): | ||||
|     class Meta: | ||||
|         model = RealGroup | ||||
|  | ||||
|  | ||||
| class GroupViewSet(RightModelViewSet): | ||||
|     """Manage Groups (api/v1/group/).""" | ||||
|  | ||||
|     serializer_class = GroupSerializer | ||||
|     queryset = RealGroup.objects.all() | ||||
| @@ -1,112 +0,0 @@ | ||||
| # | ||||
| # Copyright 2023 © AE UTBM | ||||
| # ae@utbm.fr / ae.info@utbm.fr | ||||
| # | ||||
| # This file is part of the website of the UTBM Student Association (AE UTBM), | ||||
| # https://ae.utbm.fr. | ||||
| # | ||||
| # You can find the source code of the website at https://github.com/ae-utbm/sith3 | ||||
| # | ||||
| # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) | ||||
| # SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE | ||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | ||||
| # | ||||
| # | ||||
|  | ||||
| from rest_framework import serializers | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| from api.views import RightModelViewSet | ||||
| from launderette.models import Launderette, Machine, Token | ||||
|  | ||||
|  | ||||
| class LaunderettePlaceSerializer(serializers.ModelSerializer): | ||||
|     machine_list = serializers.ListField( | ||||
|         child=serializers.IntegerField(), read_only=True | ||||
|     ) | ||||
|     token_list = serializers.ListField(child=serializers.IntegerField(), read_only=True) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Launderette | ||||
|         fields = ( | ||||
|             "id", | ||||
|             "name", | ||||
|             "counter", | ||||
|             "machine_list", | ||||
|             "token_list", | ||||
|             "get_absolute_url", | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class LaunderetteMachineSerializer(serializers.ModelSerializer): | ||||
|     class Meta: | ||||
|         model = Machine | ||||
|         fields = ("id", "name", "type", "is_working", "launderette") | ||||
|  | ||||
|  | ||||
| class LaunderetteTokenSerializer(serializers.ModelSerializer): | ||||
|     class Meta: | ||||
|         model = Token | ||||
|         fields = ( | ||||
|             "id", | ||||
|             "name", | ||||
|             "type", | ||||
|             "launderette", | ||||
|             "borrow_date", | ||||
|             "user", | ||||
|             "is_avaliable", | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class LaunderettePlaceViewSet(RightModelViewSet): | ||||
|     """Manage Launderette (api/v1/launderette/place/).""" | ||||
|  | ||||
|     serializer_class = LaunderettePlaceSerializer | ||||
|     queryset = Launderette.objects.all() | ||||
|  | ||||
|  | ||||
| class LaunderetteMachineViewSet(RightModelViewSet): | ||||
|     """Manage Washing Machines (api/v1/launderette/machine/).""" | ||||
|  | ||||
|     serializer_class = LaunderetteMachineSerializer | ||||
|     queryset = Machine.objects.all() | ||||
|  | ||||
|  | ||||
| class LaunderetteTokenViewSet(RightModelViewSet): | ||||
|     """Manage Launderette's tokens (api/v1/launderette/token/).""" | ||||
|  | ||||
|     serializer_class = LaunderetteTokenSerializer | ||||
|     queryset = Token.objects.all() | ||||
|  | ||||
|     @action(detail=False) | ||||
|     def washing(self, request): | ||||
|         """Return all washing tokens (api/v1/launderette/token/washing).""" | ||||
|         self.queryset = self.queryset.filter(type="WASHING") | ||||
|         serializer = self.get_serializer(self.queryset, many=True) | ||||
|         return Response(serializer.data) | ||||
|  | ||||
|     @action(detail=False) | ||||
|     def drying(self, request): | ||||
|         """Return all drying tokens (api/v1/launderette/token/drying).""" | ||||
|         self.queryset = self.queryset.filter(type="DRYING") | ||||
|         serializer = self.get_serializer(self.queryset, many=True) | ||||
|         return Response(serializer.data) | ||||
|  | ||||
|     @action(detail=False) | ||||
|     def avaliable(self, request): | ||||
|         """Return all avaliable tokens (api/v1/launderette/token/avaliable).""" | ||||
|         self.queryset = self.queryset.filter( | ||||
|             borrow_date__isnull=True, user__isnull=True | ||||
|         ) | ||||
|         serializer = self.get_serializer(self.queryset, many=True) | ||||
|         return Response(serializer.data) | ||||
|  | ||||
|     @action(detail=False) | ||||
|     def unavaliable(self, request): | ||||
|         """Return all unavaliable tokens (api/v1/launderette/token/unavaliable).""" | ||||
|         self.queryset = self.queryset.filter( | ||||
|             borrow_date__isnull=False, user__isnull=False | ||||
|         ) | ||||
|         serializer = self.get_serializer(self.queryset, many=True) | ||||
|         return Response(serializer.data) | ||||
| @@ -1,43 +0,0 @@ | ||||
| from typing import List | ||||
|  | ||||
| from rest_framework.decorators import api_view, renderer_classes | ||||
| from rest_framework.exceptions import PermissionDenied | ||||
| from rest_framework.generics import get_object_or_404 | ||||
| from rest_framework.renderers import JSONRenderer | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| from core.models import User | ||||
| from core.views import can_edit | ||||
| from sas.models import Picture | ||||
|  | ||||
|  | ||||
| def all_pictures_of_user(user: User) -> List[Picture]: | ||||
|     return [ | ||||
|         relation.picture | ||||
|         for relation in user.pictures.exclude(picture=None) | ||||
|         .order_by("-picture__parent__date", "id") | ||||
|         .select_related("picture__parent") | ||||
|     ] | ||||
|  | ||||
|  | ||||
| @api_view(["GET"]) | ||||
| @renderer_classes((JSONRenderer,)) | ||||
| def all_pictures_of_user_endpoint(request: Request, user: int): | ||||
|     requested_user: User = get_object_or_404(User, pk=user) | ||||
|     if not can_edit(requested_user, request.user): | ||||
|         raise PermissionDenied | ||||
|  | ||||
|     return Response( | ||||
|         [ | ||||
|             { | ||||
|                 "name": f"{picture.parent.name} - {picture.name}", | ||||
|                 "date": picture.date, | ||||
|                 "author": str(picture.owner), | ||||
|                 "full_size_url": picture.get_download_url(), | ||||
|                 "compressed_url": picture.get_download_compressed_url(), | ||||
|                 "thumb_url": picture.get_download_thumb_url(), | ||||
|             } | ||||
|             for picture in all_pictures_of_user(requested_user) | ||||
|         ] | ||||
|     ) | ||||
| @@ -1,56 +0,0 @@ | ||||
| # | ||||
| # Copyright 2023 © AE UTBM | ||||
| # ae@utbm.fr / ae.info@utbm.fr | ||||
| # | ||||
| # This file is part of the website of the UTBM Student Association (AE UTBM), | ||||
| # https://ae.utbm.fr. | ||||
| # | ||||
| # You can find the source code of the website at https://github.com/ae-utbm/sith3 | ||||
| # | ||||
| # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) | ||||
| # SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE | ||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | ||||
| # | ||||
| # | ||||
|  | ||||
| import datetime | ||||
|  | ||||
| from rest_framework import serializers | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| from api.views import RightModelViewSet | ||||
| from core.models import User | ||||
|  | ||||
|  | ||||
| class UserSerializer(serializers.ModelSerializer): | ||||
|     class Meta: | ||||
|         model = User | ||||
|         fields = ( | ||||
|             "id", | ||||
|             "first_name", | ||||
|             "last_name", | ||||
|             "email", | ||||
|             "date_of_birth", | ||||
|             "nick_name", | ||||
|             "is_active", | ||||
|             "date_joined", | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class UserViewSet(RightModelViewSet): | ||||
|     """Manage Users (api/v1/user/). | ||||
|  | ||||
|     Only show active users. | ||||
|     """ | ||||
|  | ||||
|     serializer_class = UserSerializer | ||||
|     queryset = User.objects.filter(is_active=True) | ||||
|  | ||||
|     @action(detail=False) | ||||
|     def birthday(self, request): | ||||
|         """Return all users born today (api/v1/user/birstdays).""" | ||||
|         date = datetime.datetime.today() | ||||
|         self.queryset = self.queryset.filter(date_of_birth=date) | ||||
|         serializer = self.get_serializer(self.queryset, many=True) | ||||
|         return Response(serializer.data) | ||||
							
								
								
									
										126
									
								
								api/views/uv.py
									
									
									
									
									
								
							
							
						
						
									
										126
									
								
								api/views/uv.py
									
									
									
									
									
								
							| @@ -1,126 +0,0 @@ | ||||
| import json | ||||
| import urllib.request | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.core.exceptions import PermissionDenied | ||||
| from rest_framework import serializers | ||||
| from rest_framework.decorators import api_view, renderer_classes | ||||
| from rest_framework.renderers import JSONRenderer | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| from pedagogy.views import CanCreateUVFunctionMixin | ||||
|  | ||||
|  | ||||
| @api_view(["GET"]) | ||||
| @renderer_classes((JSONRenderer,)) | ||||
| def uv_endpoint(request): | ||||
|     if not CanCreateUVFunctionMixin.can_create_uv(request.user): | ||||
|         raise PermissionDenied | ||||
|  | ||||
|     params = request.query_params | ||||
|     if "year" not in params or "code" not in params: | ||||
|         raise serializers.ValidationError("Missing query parameter") | ||||
|  | ||||
|     short_uv, full_uv = find_uv("fr", params["year"], params["code"]) | ||||
|     if short_uv is None or full_uv is None: | ||||
|         return Response(status=204) | ||||
|  | ||||
|     return Response(make_clean_uv(short_uv, full_uv)) | ||||
|  | ||||
|  | ||||
| def find_uv(lang: str, year: int | str, code: str) -> tuple[dict | None, dict | None]: | ||||
|     """Uses the UTBM API to find an UV. | ||||
|  | ||||
|     Short_uv is the UV entry in the UV list. It is returned as it contains | ||||
|     information which are not in full_uv. | ||||
|     full_uv is the detailed representation of an UV. | ||||
|     """ | ||||
|     # query the UV list | ||||
|     uvs_url = settings.SITH_PEDAGOGY_UTBM_API + "/uvs/{}/{}".format(lang, year) | ||||
|     response = urllib.request.urlopen(uvs_url) | ||||
|     uvs = json.loads(response.read().decode("utf-8")) | ||||
|  | ||||
|     try: | ||||
|         # find the first UV which matches the code | ||||
|         short_uv = next(uv for uv in uvs if uv["code"] == code) | ||||
|     except StopIteration: | ||||
|         return None, None | ||||
|  | ||||
|     # get detailed information about the UV | ||||
|     uv_url = settings.SITH_PEDAGOGY_UTBM_API + "/uv/{}/{}/{}/{}".format( | ||||
|         lang, year, code, short_uv["codeFormation"] | ||||
|     ) | ||||
|     response = urllib.request.urlopen(uv_url) | ||||
|     full_uv = json.loads(response.read().decode("utf-8")) | ||||
|  | ||||
|     return short_uv, full_uv | ||||
|  | ||||
|  | ||||
| def make_clean_uv(short_uv: dict, full_uv: dict): | ||||
|     """Cleans the data up so that it corresponds to our data representation.""" | ||||
|     res = {} | ||||
|  | ||||
|     res["credit_type"] = short_uv["codeCategorie"] | ||||
|  | ||||
|     # probably wrong on a few UVs as we pick the first UV we find but | ||||
|     # availability depends on the formation | ||||
|     semesters = { | ||||
|         (True, True): "AUTUMN_AND_SPRING", | ||||
|         (True, False): "AUTUMN", | ||||
|         (False, True): "SPRING", | ||||
|     } | ||||
|     res["semester"] = semesters.get( | ||||
|         (short_uv["ouvertAutomne"], short_uv["ouvertPrintemps"]), "CLOSED" | ||||
|     ) | ||||
|  | ||||
|     langs = {"es": "SP", "en": "EN", "de": "DE"} | ||||
|     res["language"] = langs.get(full_uv["codeLangue"], "FR") | ||||
|  | ||||
|     if full_uv["departement"] == "Pôle Humanités": | ||||
|         res["department"] = "HUMA" | ||||
|     else: | ||||
|         departments = { | ||||
|             "AL": "IMSI", | ||||
|             "AE": "EE", | ||||
|             "GI": "GI", | ||||
|             "GC": "EE", | ||||
|             "GM": "MC", | ||||
|             "TC": "TC", | ||||
|             "GP": "IMSI", | ||||
|             "ED": "EDIM", | ||||
|             "AI": "GI", | ||||
|             "AM": "MC", | ||||
|         } | ||||
|         res["department"] = departments.get(full_uv["codeFormation"], "NA") | ||||
|  | ||||
|     res["credits"] = full_uv["creditsEcts"] | ||||
|  | ||||
|     activities = ("CM", "TD", "TP", "THE", "TE") | ||||
|     for activity in activities: | ||||
|         res["hours_{}".format(activity)] = 0 | ||||
|     for activity in full_uv["activites"]: | ||||
|         if activity["code"] in activities: | ||||
|             res["hours_{}".format(activity["code"])] += activity["nbh"] // 60 | ||||
|  | ||||
|     # wrong if the manager changes depending on the semester | ||||
|     semester = full_uv.get("automne", None) | ||||
|     if not semester: | ||||
|         semester = full_uv.get("printemps", {}) | ||||
|     res["manager"] = semester.get("responsable", "") | ||||
|  | ||||
|     res["title"] = full_uv["libelle"] | ||||
|  | ||||
|     descriptions = { | ||||
|         "objectives": "objectifs", | ||||
|         "program": "programme", | ||||
|         "skills": "acquisitionCompetences", | ||||
|         "key_concepts": "acquisitionNotions", | ||||
|     } | ||||
|  | ||||
|     for res_key, full_uv_key in descriptions.items(): | ||||
|         res[res_key] = full_uv[full_uv_key] | ||||
|         # if not found or the API did not return a string | ||||
|         if type(res[res_key]) != str: | ||||
|             res[res_key] = "" | ||||
|  | ||||
|     return res | ||||
| @@ -486,10 +486,8 @@ class Mailing(models.Model): | ||||
|         super().delete() | ||||
|  | ||||
|     def fetch_format(self): | ||||
|         resp = self.email + ": " | ||||
|         for sub in self.subscriptions.all(): | ||||
|             resp += sub.fetch_format() | ||||
|         return resp | ||||
|         destination = "".join(s.fetch_format() for s in self.subscriptions.all()) | ||||
|         return f"{self.email}: {destination}" | ||||
|  | ||||
|  | ||||
| class MailingSubscription(models.Model): | ||||
|   | ||||
							
								
								
									
										29
									
								
								core/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								core/api.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| from django.conf import settings | ||||
| from django.http import HttpResponse | ||||
| from ninja_extra import ControllerBase, api_controller, route | ||||
| from ninja_extra.exceptions import PermissionDenied | ||||
|  | ||||
| from club.models import Mailing | ||||
| from core.schemas import MarkdownSchema | ||||
| from core.templatetags.renderer import markdown | ||||
|  | ||||
|  | ||||
| @api_controller("/markdown") | ||||
| class MarkdownController(ControllerBase): | ||||
|     @route.post("", url_name="markdown") | ||||
|     def render_markdown(self, body: MarkdownSchema): | ||||
|         """Convert the markdown text into html.""" | ||||
|         return HttpResponse(markdown(body.text), content_type="text/html") | ||||
|  | ||||
|  | ||||
| @api_controller("/mailings") | ||||
| class MailingListController(ControllerBase): | ||||
|     @route.get("", response=str) | ||||
|     def fetch_mailing_lists(self, key: str): | ||||
|         if key != settings.SITH_MAILING_FETCH_KEY: | ||||
|             raise PermissionDenied | ||||
|         mailings = Mailing.objects.filter( | ||||
|             is_moderated=True, club__is_active=True | ||||
|         ).prefetch_related("subscriptions") | ||||
|         data = "\n".join(m.fetch_format() for m in mailings) | ||||
|         return data | ||||
							
								
								
									
										96
									
								
								core/api_permissions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								core/api_permissions.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | ||||
| """Permission classes to be used within ninja-extra controllers. | ||||
|  | ||||
| Some permissions are global (like `IsInGroup` or `IsRoot`), | ||||
| and some others are per-object (like `CanView` or `CanEdit`). | ||||
|  | ||||
| Examples: | ||||
|     ```python | ||||
|     # restrict all the routes of this controller | ||||
|     # to subscribed users | ||||
|     @api_controller("/foo", permissions=[IsSubscriber]) | ||||
|     class FooController(ControllerBase): | ||||
|         @route.get("/bar") | ||||
|         def bar_get(self): | ||||
|             # This route inherits the permissions of the controller | ||||
|             # ... | ||||
|  | ||||
|         @route.bar("/bar/{bar_id}", permissions=[CanView]) | ||||
|         def bar_get_one(self, bar_id: int): | ||||
|             # per-object permission resolution happens | ||||
|             # when calling either the `get_object_or_exception` | ||||
|             # or `get_object_or_none` method. | ||||
|             bar = self.get_object_or_exception(Counter, pk=bar_id) | ||||
|  | ||||
|             # you can also call the `check_object_permission` manually | ||||
|             other_bar = Counter.objects.first() | ||||
|             self.check_object_permissions(other_bar) | ||||
|  | ||||
|             # ... | ||||
|  | ||||
|         # This route is restricted to counter admins and root users | ||||
|         @route.delete( | ||||
|             "/bar/{bar_id}", | ||||
|             permissions=[IsRoot | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID) | ||||
|         ] | ||||
|         def bar_delete(self, bar_id: int): | ||||
|             # ... | ||||
| """ | ||||
|  | ||||
| from typing import Any | ||||
|  | ||||
| from django.http import HttpRequest | ||||
| from ninja_extra import ControllerBase | ||||
| from ninja_extra.permissions import BasePermission | ||||
|  | ||||
|  | ||||
| class IsInGroup(BasePermission): | ||||
|     def __init__(self, group_pk: int): | ||||
|         self._group_pk = group_pk | ||||
|  | ||||
|     def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: | ||||
|         return request.user.is_in_group(pk=self._group_pk) | ||||
|  | ||||
|  | ||||
| class IsRoot(BasePermission): | ||||
|     def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: | ||||
|         return request.user.is_root | ||||
|  | ||||
|  | ||||
| class IsSubscriber(BasePermission): | ||||
|     def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: | ||||
|         return request.user.is_subscribed | ||||
|  | ||||
|  | ||||
| class IsOldSubscriber(BasePermission): | ||||
|     def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: | ||||
|         return request.user.was_subscribed | ||||
|  | ||||
|  | ||||
| class CanView(BasePermission): | ||||
|     def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: | ||||
|         return True | ||||
|  | ||||
|     def has_object_permission( | ||||
|         self, request: HttpRequest, controller: ControllerBase, obj: Any | ||||
|     ) -> bool: | ||||
|         return request.user.can_view(obj) | ||||
|  | ||||
|  | ||||
| class CanEdit(BasePermission): | ||||
|     def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: | ||||
|         return True | ||||
|  | ||||
|     def has_object_permission( | ||||
|         self, request: HttpRequest, controller: ControllerBase, obj: Any | ||||
|     ) -> bool: | ||||
|         return request.user.can_edit(obj) | ||||
|  | ||||
|  | ||||
| class IsOwner(BasePermission): | ||||
|     def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: | ||||
|         return True | ||||
|  | ||||
|     def has_object_permission( | ||||
|         self, request: HttpRequest, controller: ControllerBase, obj: Any | ||||
|     ) -> bool: | ||||
|         return request.user.is_owner(obj) | ||||
							
								
								
									
										15
									
								
								core/schemas.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								core/schemas.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| from ninja import ModelSchema, Schema | ||||
|  | ||||
| from core.models import User | ||||
|  | ||||
|  | ||||
| class SimpleUserSchema(ModelSchema): | ||||
|     """A schema with the minimum amount of information to represent a user.""" | ||||
|  | ||||
|     class Meta: | ||||
|         model = User | ||||
|         fields = ["id", "nick_name", "first_name", "last_name"] | ||||
|  | ||||
|  | ||||
| class MarkdownSchema(Schema): | ||||
|     text: str | ||||
| @@ -84,10 +84,10 @@ | ||||
|         } | ||||
|         function download_pictures() { | ||||
|             $("#download_all_pictures").prop("disabled", true); | ||||
|             var xhr = new XMLHttpRequest(); | ||||
|             const xhr = new XMLHttpRequest(); | ||||
|             $.ajax({ | ||||
|                 type: "GET", | ||||
|                 url: "{{ url('api:all_pictures_of_user', user=object.id) }}", | ||||
|                 url: "{{ url('api:pictures') }}?users_identified={{ object.id }}", | ||||
|                 tryCount: 0, | ||||
|                 xhr: function(){ | ||||
|                      return xhr; | ||||
|   | ||||
| @@ -65,7 +65,7 @@ class TestUserRegistration: | ||||
|                 {"password2": "not the same as password1"}, | ||||
|                 "Les deux mots de passe ne correspondent pas.", | ||||
|             ), | ||||
|             ({"email": "not-an-email"}, "Saisissez une adresse e-mail valide."), | ||||
|             ({"email": "not-an-email"}, "Saisissez une adresse de courriel valide."), | ||||
|             ({"first_name": ""}, "Ce champ est obligatoire."), | ||||
|             ({"last_name": ""}, "Ce champ est obligatoire."), | ||||
|             ({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"), | ||||
| @@ -310,7 +310,6 @@ http://git.an | ||||
|         ) | ||||
|         response = self.client.get(reverse("core:page", kwargs={"page_name": "guy"})) | ||||
|         assert response.status_code == 200 | ||||
|         print(response.content.decode()) | ||||
|         expected = """ | ||||
|             <p>Guy <em>bibou</em></p> | ||||
|             <p><a href="http://git.an">http://git.an</a></p> | ||||
|   | ||||
| @@ -25,6 +25,7 @@ | ||||
| import types | ||||
| from typing import Any | ||||
|  | ||||
| from django.contrib.auth.mixins import AccessMixin | ||||
| from django.core.exceptions import ( | ||||
|     ImproperlyConfigured, | ||||
|     PermissionDenied, | ||||
| @@ -234,7 +235,7 @@ class UserIsRootMixin(GenericContentPermissionMixinBuilder): | ||||
|     permission_function = lambda obj, user: user.is_root | ||||
|  | ||||
|  | ||||
| class FormerSubscriberMixin(View): | ||||
| class FormerSubscriberMixin(AccessMixin): | ||||
|     """Check if the user was at least an old subscriber. | ||||
|  | ||||
|     Raises: | ||||
| @@ -247,16 +248,10 @@ class FormerSubscriberMixin(View): | ||||
|         return super().dispatch(request, *args, **kwargs) | ||||
|  | ||||
|  | ||||
| class UserIsLoggedMixin(View): | ||||
|     """Check if the user is logged. | ||||
|  | ||||
|     Raises: | ||||
|         PermissionDenied: | ||||
|     """ | ||||
|  | ||||
| class SubscriberMixin(AccessMixin): | ||||
|     def dispatch(self, request, *args, **kwargs): | ||||
|         if request.user.is_anonymous: | ||||
|             raise PermissionDenied | ||||
|         if not request.user.is_subscribed: | ||||
|             return self.handle_no_permission() | ||||
|         return super().dispatch(request, *args, **kwargs) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -104,7 +104,7 @@ class MarkdownInput(Textarea): | ||||
|             "fullscreen": _("Toggle fullscreen"), | ||||
|             "guide": _("Markdown guide"), | ||||
|         } | ||||
|         context["markdown_api_url"] = reverse("api:api_markdown") | ||||
|         context["markdown_api_url"] = reverse("api:markdown") | ||||
|         return context | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -29,6 +29,7 @@ from smtplib import SMTPException | ||||
| from django.conf import settings | ||||
| from django.contrib.auth import login, views | ||||
| from django.contrib.auth.forms import PasswordChangeForm | ||||
| from django.contrib.auth.mixins import LoginRequiredMixin | ||||
| from django.core.exceptions import PermissionDenied, ValidationError | ||||
| from django.forms import CheckboxSelectMultiple | ||||
| from django.forms.models import modelform_factory | ||||
| @@ -50,7 +51,6 @@ from django.views.generic.dates import MonthMixin, YearMixin | ||||
| from django.views.generic.edit import FormView, UpdateView | ||||
| from honeypot.decorators import check_honeypot | ||||
|  | ||||
| from api.views.sas import all_pictures_of_user | ||||
| from core.models import Gift, Preferences, SithFile, User | ||||
| from core.views import ( | ||||
|     CanEditMixin, | ||||
| @@ -58,7 +58,6 @@ from core.views import ( | ||||
|     CanViewMixin, | ||||
|     QuickNotifMixin, | ||||
|     TabedViewMixin, | ||||
|     UserIsLoggedMixin, | ||||
| ) | ||||
| from core.views.forms import ( | ||||
|     GiftForm, | ||||
| @@ -68,6 +67,7 @@ from core.views.forms import ( | ||||
|     UserProfileForm, | ||||
| ) | ||||
| from counter.forms import StudentCardForm | ||||
| from sas.models import Picture | ||||
| from subscription.models import Subscription | ||||
| from trombi.views import UserTrombiForm | ||||
|  | ||||
| @@ -313,7 +313,11 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView): | ||||
|         kwargs = super().get_context_data(**kwargs) | ||||
|         kwargs["albums"] = [] | ||||
|         kwargs["pictures"] = {} | ||||
|         picture_qs = all_pictures_of_user(self.object) | ||||
|         picture_qs = ( | ||||
|             Picture.objects.filter(people__user_id=self.object.id) | ||||
|             .order_by("parent__date", "id") | ||||
|             .all() | ||||
|         ) | ||||
|         last_album = None | ||||
|         for picture in picture_qs: | ||||
|             album = picture.parent | ||||
| @@ -720,7 +724,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView): | ||||
|     current_tab = "groups" | ||||
|  | ||||
|  | ||||
| class UserToolsView(QuickNotifMixin, UserTabsMixin, UserIsLoggedMixin, TemplateView): | ||||
| class UserToolsView(LoginRequiredMixin, QuickNotifMixin, UserTabsMixin, TemplateView): | ||||
|     """Displays the logged user's tools.""" | ||||
|  | ||||
|     template_name = "core/user_tools.jinja" | ||||
|   | ||||
							
								
								
									
										37
									
								
								counter/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								counter/api.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| # | ||||
| # Copyright 2024 AE UTBM | ||||
| # ae@utbm.fr / ae.info@utbm.fr | ||||
| # | ||||
| # This file is part of the website of the UTBM Student Association (AE UTBM), | ||||
| # https://ae.utbm.fr. | ||||
| # | ||||
| # You can find the source code of the website at https://github.com/ae-utbm/sith3 | ||||
| # | ||||
| # LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3) | ||||
| # SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE | ||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | ||||
| # | ||||
| # | ||||
| from ninja_extra import ControllerBase, api_controller, route | ||||
|  | ||||
| from core.api_permissions import CanView, IsRoot | ||||
| from counter.models import Counter | ||||
| from counter.schemas import CounterSchema | ||||
|  | ||||
|  | ||||
| @api_controller("/counter") | ||||
| class CounterController(ControllerBase): | ||||
|     @route.get("", response=list[CounterSchema], permissions=[IsRoot]) | ||||
|     def fetch_all(self): | ||||
|         return Counter.objects.all() | ||||
|  | ||||
|     @route.get("{counter_id}/", response=CounterSchema, permissions=[CanView]) | ||||
|     def fetch_one(self, counter_id: int): | ||||
|         return self.get_object_or_exception(Counter, pk=counter_id) | ||||
|  | ||||
|     @route.get("bar/", response=list[CounterSchema], permissions=[CanView]) | ||||
|     def fetch_bars(self): | ||||
|         counters = list(Counter.objects.filter(type="BAR")) | ||||
|         for c in counters: | ||||
|             self.check_object_permissions(c) | ||||
|         return counters | ||||
							
								
								
									
										13
									
								
								counter/schemas.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								counter/schemas.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| from ninja import ModelSchema | ||||
|  | ||||
| from core.schemas import SimpleUserSchema | ||||
| from counter.models import Counter | ||||
|  | ||||
|  | ||||
| class CounterSchema(ModelSchema): | ||||
|     barmen_list: list[SimpleUserSchema] | ||||
|     is_open: bool | ||||
|  | ||||
|     class Meta: | ||||
|         model = Counter | ||||
|         fields = ["id", "name", "type", "club", "products"] | ||||
| @@ -26,6 +26,7 @@ import math | ||||
| from ajax_select import make_ajax_field | ||||
| from django import forms | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.mixins import LoginRequiredMixin | ||||
| from django.core.exceptions import PermissionDenied | ||||
| from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator | ||||
| from django.db import IntegrityError | ||||
| @@ -43,7 +44,6 @@ from core.views import ( | ||||
|     CanEditMixin, | ||||
|     CanEditPropMixin, | ||||
|     CanViewMixin, | ||||
|     UserIsLoggedMixin, | ||||
|     can_view, | ||||
| ) | ||||
| from core.views.forms import MarkdownInput | ||||
| @@ -273,7 +273,7 @@ class ForumTopicEditView(CanEditMixin, UpdateView): | ||||
|  | ||||
|  | ||||
| class ForumTopicSubscribeView( | ||||
|     CanViewMixin, UserIsLoggedMixin, SingleObjectMixin, RedirectView | ||||
|     LoginRequiredMixin, CanViewMixin, SingleObjectMixin, RedirectView | ||||
| ): | ||||
|     model = ForumTopic | ||||
|     pk_url_kwarg = "topic_id" | ||||
|   | ||||
							
								
								
									
										36
									
								
								pedagogy/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								pedagogy/api.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| from typing import Annotated | ||||
|  | ||||
| from annotated_types import Ge | ||||
| from django.conf import settings | ||||
| from ninja import Query | ||||
| from ninja_extra import ControllerBase, api_controller, route | ||||
| from ninja_extra.exceptions import NotFound | ||||
|  | ||||
| from core.api_permissions import IsInGroup, IsRoot, IsSubscriber | ||||
| from pedagogy.models import UV | ||||
| from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema | ||||
| from pedagogy.utbm_api import find_uv | ||||
|  | ||||
|  | ||||
| @api_controller("/uv", permissions=[IsSubscriber]) | ||||
| class UvController(ControllerBase): | ||||
|     @route.get( | ||||
|         "/{year}/{code}", | ||||
|         permissions=[IsRoot | IsInGroup(settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)], | ||||
|         url_name="fetch_uv_from_utbm", | ||||
|         response=UvSchema, | ||||
|     ) | ||||
|     def fetch_from_utbm_api( | ||||
|         self, year: Annotated[int, Ge(2010)], code: str, lang: Query[str] = "fr" | ||||
|     ): | ||||
|         """Fetch UV data from the UTBM API and returns it after some parsing.""" | ||||
|         res = find_uv(lang, year, code) | ||||
|         if res is None: | ||||
|             raise NotFound | ||||
|         return res | ||||
|  | ||||
|     @route.get("", response=list[SimpleUvSchema], url_name="fetch_uvs") | ||||
|     def fetch_uv_list(self, search: Query[UvFilterSchema]): | ||||
|         # le `[:50]`, c'est de la pagination eco+ | ||||
|         # si quelqu'un est motivé, il peut faire une vraie pagination | ||||
|         return search.filter(UV.objects.all())[:50] | ||||
| @@ -28,7 +28,6 @@ from django.urls import reverse | ||||
| from django.utils import timezone | ||||
| from django.utils.functional import cached_property | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from rest_framework import serializers | ||||
|  | ||||
| from core.models import User | ||||
|  | ||||
| @@ -327,30 +326,3 @@ class UVCommentReport(models.Model): | ||||
|     def is_owned_by(self, user): | ||||
|         """Can be created by a pedagogy admin, a superuser or a subscriber.""" | ||||
|         return user.is_subscribed or user.is_owner(self.comment.uv) | ||||
|  | ||||
|  | ||||
| # Custom serializers | ||||
|  | ||||
|  | ||||
| class UVSerializer(serializers.ModelSerializer): | ||||
|     """Custom seralizer for UVs. | ||||
|  | ||||
|     Allow adding more informations like absolute_url. | ||||
|     """ | ||||
|  | ||||
|     class Meta: | ||||
|         model = UV | ||||
|         fields = "__all__" | ||||
|  | ||||
|     absolute_url = serializers.SerializerMethodField() | ||||
|     update_url = serializers.SerializerMethodField() | ||||
|     delete_url = serializers.SerializerMethodField() | ||||
|  | ||||
|     def get_absolute_url(self, obj): | ||||
|         return obj.get_absolute_url() | ||||
|  | ||||
|     def get_update_url(self, obj): | ||||
|         return reverse("pedagogy:uv_update", kwargs={"uv_id": obj.id}) | ||||
|  | ||||
|     def get_delete_url(self, obj): | ||||
|         return reverse("pedagogy:uv_delete", kwargs={"uv_id": obj.id}) | ||||
|   | ||||
							
								
								
									
										132
									
								
								pedagogy/schemas.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								pedagogy/schemas.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,132 @@ | ||||
| from typing import Literal | ||||
|  | ||||
| from django.db.models import Q | ||||
| from ninja import FilterSchema, ModelSchema, Schema | ||||
| from pydantic import AliasPath, ConfigDict, Field, TypeAdapter | ||||
| from pydantic.alias_generators import to_camel | ||||
|  | ||||
| from pedagogy.models import UV | ||||
|  | ||||
|  | ||||
| class UtbmShortUvSchema(Schema): | ||||
|     """Short representation of an UV in the UTBM API. | ||||
|  | ||||
|     Notes: | ||||
|         This schema holds only the fields we actually need. | ||||
|         The UTBM API returns more data than that. | ||||
|     """ | ||||
|  | ||||
|     model_config = ConfigDict(alias_generator=to_camel) | ||||
|  | ||||
|     code: str | ||||
|     code_formation: str | ||||
|     code_categorie: str | None | ||||
|     code_langue: str | ||||
|     ouvert_automne: bool | ||||
|     ouvert_printemps: bool | ||||
|  | ||||
|  | ||||
| class WorkloadSchema(Schema): | ||||
|     model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) | ||||
|  | ||||
|     code: Literal["TD", "TP", "CM", "THE", "TE"] | ||||
|     nbh: int | ||||
|  | ||||
|  | ||||
| class SemesterUvState(Schema): | ||||
|     """The state of the UV during either autumn or spring semester""" | ||||
|  | ||||
|     model_config = ConfigDict(alias_generator=to_camel) | ||||
|  | ||||
|     responsable: str | ||||
|     ouvert: bool | ||||
|  | ||||
|  | ||||
| ShortUvList = TypeAdapter(list[UtbmShortUvSchema]) | ||||
|  | ||||
|  | ||||
| class UtbmFullUvSchema(Schema): | ||||
|     """Long representation of an UV in the UTBM API.""" | ||||
|  | ||||
|     model_config = ConfigDict(alias_generator=to_camel) | ||||
|  | ||||
|     code: str | ||||
|     departement: str = "NA" | ||||
|     libelle: str | ||||
|     objectifs: str | ||||
|     programme: str | ||||
|     acquisition_competences: str | ||||
|     acquisition_notions: str | ||||
|     langue: str | ||||
|     code_langue: str | ||||
|     credits_ects: int | ||||
|     activites: list[WorkloadSchema] | ||||
|     respo_automne: str | None = Field( | ||||
|         None, validation_alias=AliasPath("automne", "responsable") | ||||
|     ) | ||||
|     respo_printemps: str | None = Field( | ||||
|         None, validation_alias=AliasPath("printemps", "responsable") | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class SimpleUvSchema(ModelSchema): | ||||
|     """Our minimal representation of an UV.""" | ||||
|  | ||||
|     class Meta: | ||||
|         model = UV | ||||
|         fields = [ | ||||
|             "id", | ||||
|             "title", | ||||
|             "code", | ||||
|             "credit_type", | ||||
|             "semester", | ||||
|             "department", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class UvSchema(ModelSchema): | ||||
|     """Our complete representation of an UV""" | ||||
|  | ||||
|     class Meta: | ||||
|         model = UV | ||||
|         fields = [ | ||||
|             "id", | ||||
|             "title", | ||||
|             "code", | ||||
|             "hours_THE", | ||||
|             "hours_TD", | ||||
|             "hours_TP", | ||||
|             "hours_TE", | ||||
|             "hours_CM", | ||||
|             "credit_type", | ||||
|             "semester", | ||||
|             "language", | ||||
|             "department", | ||||
|             "credits", | ||||
|             "manager", | ||||
|             "skills", | ||||
|             "key_concepts", | ||||
|             "objectives", | ||||
|             "program", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class UvFilterSchema(FilterSchema): | ||||
|     search: str | None = Field(None, q="code__icontains") | ||||
|     semester: set[Literal["AUTUMN", "SPRING"]] | None = None | ||||
|     credit_type: set[Literal["CS", "TM", "EC", "OM", "QC"]] | None = Field( | ||||
|         None, q="credit_type__in" | ||||
|     ) | ||||
|     language: str = "FR" | ||||
|     department: set[str] | None = Field(None, q="department__in") | ||||
|  | ||||
|     def filter_semester(self, value: set[str] | None) -> Q: | ||||
|         """Special filter for the semester. | ||||
|  | ||||
|         If both "SPRING" and "AUTUMN" are given, UV that are available | ||||
|         during "AUTUMN_AND_SPRING" will be filtered. | ||||
|         """ | ||||
|         if not value: | ||||
|             return Q() | ||||
|         value.add("AUTUMN_AND_SPRING") | ||||
|         return Q(semester__in=value) | ||||
| @@ -5,52 +5,79 @@ | ||||
| {% trans %}UV Guide{% endtrans %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block additional_js %} | ||||
|     <script src="{{ static('core/js/alpinejs.min.js') }}" defer></script> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block head %} | ||||
|     {{ super() }} | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=0.6, maximum-scale=2"> | ||||
| {% endblock head %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="pedagogy"> | ||||
| 	<form id="search_form" action="{{ url('pedagogy:guide') }}" method="get"> | ||||
| 		<div class="search-form-container"> | ||||
| 			{% if can_create_uv(user) %} | ||||
| 			<div class="action-bar"> | ||||
| {% if can_create_uv %} | ||||
| <div class="action-bar"> | ||||
|     <p> | ||||
|         <a href="{{ url('pedagogy:uv_create') }}">{% trans %}Create UV{% endtrans %}</a> | ||||
|     </p> | ||||
|     <p> | ||||
|         <a href="{{ url('pedagogy:moderation') }}">{% trans %}Moderate comments{% endtrans %}</a> | ||||
|     </p> | ||||
| 			</div> | ||||
| 			{% endif %} | ||||
| </div> | ||||
| <br/> | ||||
| {% endif %} | ||||
| <div class="pedagogy" x-data="uv_search" x-cloak> | ||||
| 	<form id="search_form"> | ||||
| 		<div class="search-form-container"> | ||||
| 			<div class="search-bar"> | ||||
| 				<input id="search_input" class="search-bar-input" type="text" name="search"> | ||||
| 				<button class="search-bar-button">{% trans %}Search{% endtrans %}</button> | ||||
| 				<input | ||||
|                     id="search_input" | ||||
|                     class="search-bar-input" | ||||
|                     type="text" | ||||
|                     name="search" | ||||
|                     x-model.debounce.500ms="search" | ||||
|                 /> | ||||
| 			</div> | ||||
| 			<div class="radio-department"> | ||||
| 				<div class="radio-guide"> | ||||
| 					{% for (display_name, real_name) in [("EDIM", "EDIM"), ("ENERGIE", "EE"), ("IMSI", "IMSI"), ("INFO", "GI"), ("GMC", "MC"), ("HUMA", "HUMA"), ("TC", "TC")] %} | ||||
| 						<input type="radio" name="department" id="radio{{ real_name }}" value="{{ real_name }}"><label for="radio{{ real_name }}">{% trans %}{{ display_name }}{% endtrans %}</label> | ||||
| 					{% for (display_name, real_name) in [ | ||||
| 					    ("EDIM", "EDIM"), ("ENERGIE", "EE"), ("IMSI", "IMSI"), | ||||
| 					    ("INFO", "GI"), ("GMC", "MC"), ("HUMA", "HUMA"), ("TC", "TC") | ||||
| 					] %} | ||||
| 						<input | ||||
|                             type="checkbox" | ||||
|                             name="department" | ||||
|                             id="radio{{ real_name }}" | ||||
|                             value="{{ real_name }}" | ||||
|                             x-model="department" | ||||
|                         /> | ||||
|                         <label for="radio{{ real_name }}">{% trans %}{{ display_name }}{% endtrans %}</label> | ||||
| 					{% endfor %} | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div class="radio-credit-type"> | ||||
| 				<div class="radio-guide"> | ||||
| 					{% for credit_type in ["CS", "TM", "EC", "QC", "OM"] %} | ||||
| 						<input type="radio" name="credit_type" id="radio{{ credit_type }}" value="{{ credit_type }}"><label for="radio{{ credit_type }}">{% trans %}{{ credit_type }}{% endtrans %}</label> | ||||
| 						<input | ||||
|                             type="checkbox" | ||||
|                             name="credit_type" | ||||
|                             id="radio{{ credit_type }}" | ||||
|                             value="{{ credit_type }}" | ||||
|                             x-model="credit_type" | ||||
|                         /> | ||||
|                         <label for="radio{{ credit_type }}">{% trans %}{{ credit_type }}{% endtrans %}</label> | ||||
| 					{% endfor %} | ||||
| 				</div> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="radio-semester"> | ||||
| 				<div class="radio-guide"> | ||||
| 					<input type="checkbox" name="semester" id="radioAUTUMN" value="AUTUMN"><label for="radioAUTUMN"><i class="fa fa-leaf"></i></label> | ||||
| 					<input type="checkbox" name="semester" id="radioSPRING" value="SPRING"><label for="radioSPRING"><i class="fa fa-sun-o"></i></label> | ||||
| 					<span><input type="checkbox" name="semester" id="radioAP" value="AUTUMN_AND_SPRING"><label for="radioAP">AP</label></span> | ||||
| 					<input type="checkbox" name="semester" id="radioAUTUMN" value="AUTUMN" x-model="semester"/> | ||||
|                     <label for="radioAUTUMN"><i class="fa fa-sun-o"></i></label> | ||||
| 					<input type="checkbox" name="semester" id="radioSPRING" value="SPRING" x-model="semester"/> | ||||
|                     <label for="radioSPRING"><i class="fa fa-leaf"></i></label> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<input type="text" name="json" hidden> | ||||
| 		</div> | ||||
| 	</form> | ||||
| 	<table id="dynamic_view"> | ||||
| @@ -62,185 +89,84 @@ | ||||
| 				<td>{% trans %}Credit type{% endtrans %}</td> | ||||
| 				<td><i class="fa fa-leaf"></i></td> | ||||
| 				<td><i class="fa fa-sun-o"></i></td> | ||||
| 				{% if can_create_uv(user) %} | ||||
| 				{% if can_create_uv %} | ||||
|                     <td>{% trans %}Edit{% endtrans %}</td> | ||||
|                     <td>{% trans %}Delete{% endtrans %}</td> | ||||
| 				{% endif %} | ||||
| 			</tr> | ||||
| 		</thead> | ||||
| 		<tbody id="dynamic_view_content"> | ||||
| 			{% for uv in object_list %} | ||||
| 			<tr onclick="window.location.href = `{{ url('pedagogy:uv_detail', uv_id=uv.id) }}`"> | ||||
| 				<td><a href="{{ url('pedagogy:uv_detail', uv_id=uv.id) }}">{{ uv.code }}</a></td> | ||||
| 				<td>{{ uv.title }}</td> | ||||
| 				<td>{{ uv.department }}</td> | ||||
| 				<td>{{ uv.credit_type }}</td> | ||||
| 				<td> | ||||
| 					{% if uv.semester in ["AUTUMN", "AUTUMN_AND_SPRING"] %} | ||||
| 						<i class="fa fa-leaf"></i> | ||||
| 					{% endif %} | ||||
| 				</td> | ||||
| 				<td> | ||||
| 					{% if uv.semester in ["SPRING", "AUTUMN_AND_SPRING"] %} | ||||
| 						<i class="fa fa-sun-o"></i> | ||||
| 					{% endif %} | ||||
| 				</td> | ||||
| 				{% if user.is_owner(uv) -%} | ||||
| 				<td><a href="{{ url('pedagogy:uv_update', uv_id=uv.id) }}">{% trans %}Edit{% endtrans %}</a></td> | ||||
| 				<td><a href="{{ url('pedagogy:uv_delete', uv_id=uv.id) }}">{% trans %}Delete{% endtrans %}</a></td> | ||||
|             <template x-for="uv in uvs" :key="uv.id"> | ||||
|                 <tr @click="window.location.href = `/pedagogy/uv/${uv.id}`"> | ||||
|                     <td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td> | ||||
|                     <td x-text="uv.title"></td> | ||||
|                     <td x-text="uv.department"></td> | ||||
|                     <td x-text="uv.credit_type"></td> | ||||
|                     <td><i :class="[uv.semester].includes('AUTUMN') && 'fa fa-leaf'"></i></td> | ||||
|                     <td><i :class="[uv.semester].includes('SPRING') && 'fa fa-sun-o'"></i></td> | ||||
|                     {% if can_create_uv -%} | ||||
|                         <td><a :href="`/pedagogy/uv/${uv.id}/update`">{% trans %}Edit{% endtrans %}</a></td> | ||||
|                         <td><a :href="`/pedagogy/uv/${uv.id}/delete`">{% trans %}Delete{% endtrans %}</a></td> | ||||
|                     {%- endif -%} | ||||
|                 </tr> | ||||
| 			{% endfor %} | ||||
|             </template> | ||||
| 		</tbody> | ||||
| 	</table> | ||||
| </div> | ||||
| <script> | ||||
| 	function autofillCheckboxRadio(name){ | ||||
| 			if (urlParams.has(name)){ $("input[name='" + name + "']").each(function(){ | ||||
| 				if ($(this).attr("value") == urlParams.get(name)) | ||||
| 					$(this).prop("checked", true); | ||||
|     const initialUrlParams = new URLSearchParams(window.location.search); | ||||
|  | ||||
|     function update_query_string(key, value) { | ||||
|         const url = new URL(window.location.href); | ||||
|         console.log(value) | ||||
|         console.log(!!value) | ||||
|         if (!value) { | ||||
|             url.searchParams.delete(key) | ||||
|         } else if (Array.isArray(value)) { | ||||
|             url.searchParams.delete(key) | ||||
|             value.forEach((v) => url.searchParams.append(key, v)) | ||||
|         } else { | ||||
|             url.searchParams.set(key, value); | ||||
|         } | ||||
|         history.pushState(null, document.title, url.toString()); | ||||
|     } | ||||
|  | ||||
|     {# | ||||
|     How does this work : | ||||
|  | ||||
|     The page contains two main elements : the form and the results. | ||||
|     The form contains multiple inputs, allowing the user to apply the filter of its choice. | ||||
|     Each modification of those filters will modify the GET parameters of the URL, | ||||
|     then fetch the corresponding data from the API. | ||||
|     This data will then be displayed on the result part of the page. | ||||
|     #} | ||||
|     document.addEventListener("alpine:init", () => { | ||||
|         Alpine.data("uv_search", () => ({ | ||||
|             uvs: [], | ||||
|             search: initialUrlParams.get("search") || "", | ||||
|             department: initialUrlParams.getAll("department"), | ||||
|             credit_type: initialUrlParams.getAll("credit_type"), | ||||
|             {# The semester is easier to use on the backend as an enum (spring/autumn/both/none) | ||||
|             and easier to use on the frontend as an array ([spring, autumn]). | ||||
|             Thus there is some conversion involved when both communicate together #} | ||||
|             semester: initialUrlParams.has("semester") ? | ||||
|                 initialUrlParams.get("semester").split("_AND_") : [], | ||||
|  | ||||
|             async init() { | ||||
|                 ["search", "department", "credit_type", "semester"].forEach((param) => { | ||||
|                     this.$watch(param, async (value) => { | ||||
|                         update_query_string(param, value); | ||||
|                         await this.fetch_data();  {# reload data on form change #} | ||||
|                     }); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	function uvJSONToHTML(uv){ | ||||
| 		var autumn = ""; | ||||
| 		var spring = ""; | ||||
| 		if (uv.semester == "AUTUMN" || uv.semester == "AUTUMN_AND_SPRING") | ||||
| 			autumn = "<i class='fa fa-leaf'></i>"; | ||||
| 		if (uv.semester == "SPRING" || uv.semester == "AUTUMN_AND_SPRING") | ||||
| 			spring = "<i class='fa fa-sun-o'></i>"; | ||||
|  | ||||
| 		var html = ` | ||||
| 			<tr onclick="window.location.href = '${uv.absolute_url}';"> | ||||
| 				<td><a href="${uv.absolute_url}">${uv.code}</a></td> | ||||
| 				<td>${uv.title}</td> | ||||
| 				<td>${uv.department}</td> | ||||
| 				<td>${uv.credit_type}</td> | ||||
| 				<td>${autumn}</td> | ||||
| 				<td>${spring}</td> | ||||
| 		`; | ||||
| 		{% if can_create_uv(user) %} | ||||
| 		html += ` | ||||
| 			<td><a href="${uv.update_url}">{% trans %}Edit{% endtrans %}</a></td> | ||||
| 			<td><a href="${uv.delete_url}">{% trans %}Delete{% endtrans %}</a></td> | ||||
| 		`; | ||||
| 		{% endif %} | ||||
| 		return html + "</td>"; | ||||
| 	} | ||||
|  | ||||
| 	var lastTypedLetter; | ||||
| 	$("#search_input").on("keyup", function(){ | ||||
| 		// Auto submit when user pauses it's typing | ||||
| 		clearTimeout(lastTypedLetter); | ||||
| 		lastTypedLetter = setTimeout(function (){ | ||||
| 			$("#search_form").submit(); | ||||
| 		}, 300); | ||||
| 	}); | ||||
| 	$("#search_input").on("change", function(e){ | ||||
| 		// Don't send request when leaving the text area | ||||
| 		// It has already been send by the keypress event | ||||
| 		e.preventDefault(); | ||||
| 	}); | ||||
|  | ||||
| 	// Auto fill from get arguments | ||||
| 	var urlParams = new URLSearchParams(window.location.search); | ||||
| 	if (urlParams.has("search")) | ||||
| 		$("input[name='search']").first().prop("value", urlParams.get("search")); | ||||
| 	autofillCheckboxRadio("department"); | ||||
| 	autofillCheckboxRadio("credit_type"); | ||||
| 	autofillCheckboxRadio("semester"); | ||||
|  | ||||
| 	// Allow unchecking a radio button when we click on it | ||||
| 	// Keep a state of what is checked | ||||
| 	var formStates = {}; | ||||
| 	function radioCheckToggle(e){ | ||||
| 		if (formStates[this.name] == this.value){ | ||||
| 			this.checked = false; | ||||
| 			formStates[this.name] = ""; | ||||
| 			// Fire an update since the browser does not do it in this situation | ||||
| 			$("#search_form").submit(); | ||||
| 			return; | ||||
| 		} | ||||
| 		formStates[this.name] = this.value; | ||||
| 	} | ||||
|  | ||||
| 	$("input[type='radio']").each(function() { | ||||
| 		$(this).on("click", radioCheckToggle); | ||||
|     	// Get current state | ||||
|     	if ($(this).prop("checked")){ | ||||
|     		formStates[$(this).attr("name")] = $(this).attr("value"); | ||||
|     	} | ||||
|     }); | ||||
|  | ||||
| 	var autumn_and_spring = $("input[value='AUTUMN_AND_SPRING']").first(); | ||||
| 	var autumn = $("input[value='AUTUMN']").first(); | ||||
| 	var spring = $("input[value='SPRING']").first(); | ||||
|  | ||||
|     // Make autumn and spring hidden if js is enabled | ||||
|     autumn_and_spring.parent().hide(); | ||||
|  | ||||
|     // Fill json field if js is enabled | ||||
|     $("input[name='json']").first().prop("value", "true"); | ||||
|  | ||||
|     // Set correctly state of what is checked | ||||
|     if (autumn_and_spring.prop("checked")){ | ||||
|     	autumn.prop("checked", true); | ||||
|     	spring.prop("checked", true); | ||||
|     	autumn_and_spring.prop("checked", false); | ||||
|     } | ||||
|  | ||||
|     // Handle submit here and modify autumn and spring here | ||||
| 	$("#search_form").submit(function(e) { | ||||
| 		e.preventDefault(); | ||||
| 		if (autumn.prop("checked") && spring.prop("checked")){ | ||||
| 			autumn_and_spring.prop("checked", true); | ||||
| 			autumn.prop("checked", false); | ||||
| 			spring.prop("checked", false); | ||||
| 		} | ||||
|  | ||||
| 		// Do query | ||||
| 		var xhr = new XMLHttpRequest(); | ||||
| 		$.ajax({ | ||||
| 			type: "GET", | ||||
| 			url: "{{ url('pedagogy:guide') }}", | ||||
| 			data: $(this).serialize(), | ||||
| 			tryCount: 0, | ||||
| 			retryLimit: 10, | ||||
| 			xhr: function(){ | ||||
| 				 return xhr; | ||||
|                 }) | ||||
|                 await this.fetch_data();  {# load initial data #} | ||||
|             }, | ||||
| 			success: function(data){ | ||||
| 				// Update URL | ||||
| 				history.pushState({}, null, xhr.responseURL.replace("&json=true", "")); | ||||
| 				// Update content | ||||
| 				$("#dynamic_view_content").html(""); | ||||
| 				for (key in data){ | ||||
| 					$("#dynamic_view_content").append(uvJSONToHTML(data[key])); | ||||
| 				} | ||||
| 			}, | ||||
| 			error: function(){ | ||||
| 				console.log(`try ${this.tryCount}`); | ||||
| 				if (this.tryCount++ <= this.retryLimit){ | ||||
| 					$("dynamic_view_content").html(""); | ||||
| 					$.ajax(this); | ||||
| 					return; | ||||
| 				} | ||||
| 				$("#dynamic_view_content").html("<tr><td></td><td>{% trans %}Error connecting to the server{% endtrans %}</td></tr>"); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		// Restore autumn and spring for perfect illusion | ||||
| 		if (autumn_and_spring.prop("checked")){ | ||||
| 			autumn_and_spring.prop("checked", false); | ||||
| 			autumn.prop("checked", true); | ||||
| 			spring.prop("checked", true); | ||||
|             async fetch_data() { | ||||
|                 const url = "{{ url("api:fetch_uvs") }}" + window.location.search; | ||||
|                 this.uvs = await (await fetch(url)).json(); | ||||
|             } | ||||
|     }); | ||||
|  | ||||
|     // Auto send on change | ||||
|     $("#search_form").on("change", function(e){ | ||||
|     	$(this).submit(); | ||||
|     }); | ||||
|         })) | ||||
|     }) | ||||
| </script> | ||||
| {% endblock content %} | ||||
| @@ -51,29 +51,31 @@ | ||||
|             if (today.getMonth() < 7) {  // student year starts in september | ||||
|                 year-- | ||||
|             } | ||||
|             const url = "{{ url('api:uv_endpoint') }}?year=" + year + "&code=" + codeInput.value | ||||
|             const url = `/api/uv/${year}/${codeInput.value}`; | ||||
|             deleteQuickNotifs() | ||||
|  | ||||
|             $.ajax({ | ||||
|                 dataType: "json", | ||||
|                 url: url, | ||||
|                 success: function(data, _, xhr) { | ||||
|                     if (xhr.status != 200) { | ||||
|                     if (xhr.status !== 200) { | ||||
|                         createQuickNotif("{% trans %}Unknown UE code{% endtrans %}") | ||||
|                         return | ||||
|                     } | ||||
|                     for (let key in data) { | ||||
|                         if (data.hasOwnProperty(key)) { | ||||
|                             const el = document.querySelector('[name="' + key + '"]') | ||||
|                             if (el.tagName == 'TEXTAREA') { | ||||
|                                 el.parentNode.querySelector('.CodeMirror').CodeMirror.setValue(data[key]) | ||||
|                     Object.entries(data) | ||||
|                         .filter(([_, val]) => !!val)  // skip entries with null or undefined value | ||||
|                         .map(([key, val]) => {  // convert keys to DOM elements | ||||
|                             return [document.querySelector('[name="' + key + '"]'), val]; | ||||
|                         }) | ||||
|                         .filter(([elem, _]) => !!elem)  // skip non-existing DOM elements | ||||
|                         .forEach(([elem, val]) => {  // write the value in the form field | ||||
|                             if (elem.tagName === 'TEXTAREA') { | ||||
|                                 // MD editor text input | ||||
|                                 elem.parentNode.querySelector('.CodeMirror').CodeMirror.setValue(val); | ||||
|                             } else { | ||||
|                                 el.value = data[key] | ||||
|                                 elem.value = val; | ||||
|                             } | ||||
|  | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                         }); | ||||
|                     createQuickNotif('{% trans %}Successful autocomplete{% endtrans %}') | ||||
|                 }, | ||||
|                 error: function(_, _, statusMessage) { | ||||
|   | ||||
| @@ -20,10 +20,11 @@ | ||||
| # Place - Suite 330, Boston, MA 02111-1307, USA. | ||||
| # | ||||
| # | ||||
| import json | ||||
|  | ||||
| import pytest | ||||
| from django.conf import settings | ||||
| from django.core.management import call_command | ||||
| from django.test import TestCase | ||||
| from django.test import Client, TestCase | ||||
| from django.urls import reverse | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| @@ -141,41 +142,27 @@ class UVCreation(TestCase): | ||||
|         assert not UV.objects.filter(code="IFC1").exists() | ||||
|  | ||||
|  | ||||
| class UVListTest(TestCase): | ||||
|     """Test guide display rights.""" | ||||
| @pytest.mark.django_db | ||||
| @pytest.mark.parametrize( | ||||
|     ("username", "expected_code"), | ||||
|     [ | ||||
|         ("root", 200), | ||||
|         ("tutu", 200), | ||||
|         ("sli", 200), | ||||
|         ("old_subscriber", 200), | ||||
|         ("public", 403), | ||||
|     ], | ||||
| ) | ||||
| def test_guide_permissions(client: Client, username: str, expected_code: int): | ||||
|     client.force_login(User.objects.get(username=username)) | ||||
|     res = client.get(reverse("pedagogy:guide")) | ||||
|     assert res.status_code == expected_code | ||||
|  | ||||
|     @classmethod | ||||
|     def setUpTestData(cls): | ||||
|         cls.bibou = User.objects.get(username="root") | ||||
|         cls.tutu = User.objects.get(username="tutu") | ||||
|         cls.sli = User.objects.get(username="sli") | ||||
|         cls.guy = User.objects.get(username="guy") | ||||
|  | ||||
|     def test_uv_list_display_success(self): | ||||
|         # Display for root | ||||
|         self.client.force_login(self.bibou) | ||||
|         response = self.client.get(reverse("pedagogy:guide")) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         # Display for pedagogy admin | ||||
|         self.client.force_login(self.tutu) | ||||
|         response = self.client.get(reverse("pedagogy:guide")) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         # Display for simple subscriber | ||||
|         self.client.force_login(self.sli) | ||||
|         response = self.client.get(reverse("pedagogy:guide")) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|     def test_uv_list_display_fail(self): | ||||
|         # Don't display for anonymous user | ||||
|         response = self.client.get(reverse("pedagogy:guide")) | ||||
|         assert response.status_code == 403 | ||||
|  | ||||
|         # Don't display for none subscribed users | ||||
|         self.client.force_login(self.guy) | ||||
|         response = self.client.get(reverse("pedagogy:guide")) | ||||
|         assert response.status_code == 403 | ||||
| @pytest.mark.django_db | ||||
| def test_guide_anonymous_permission_denied(client: Client): | ||||
|     res = client.get(reverse("pedagogy:guide")) | ||||
|     assert res.status_code == 302 | ||||
|  | ||||
|  | ||||
| class UVDeleteTest(TestCase): | ||||
| @@ -577,141 +564,111 @@ class UVSearchTest(TestCase): | ||||
|         cls.tutu = User.objects.get(username="tutu") | ||||
|         cls.sli = User.objects.get(username="sli") | ||||
|         cls.guy = User.objects.get(username="guy") | ||||
|         cls.url = reverse("api:fetch_uvs") | ||||
|         uvs = [ | ||||
|             UV(code="AP4A", credit_type="CS", semester="AUTUMN", department="GI"), | ||||
|             UV(code="MT01", credit_type="CS", semester="AUTUMN", department="TC"), | ||||
|             UV(code="PHYS11", credit_type="CS", semester="AUTUMN", department="TC"), | ||||
|             UV(code="TNEV", credit_type="TM", semester="SPRING", department="TC"), | ||||
|             UV(code="MT10", credit_type="TM", semester="AUTUMN", department="IMSI"), | ||||
|             UV( | ||||
|                 code="DA50", | ||||
|                 credit_type="TM", | ||||
|                 semester="AUTUMN_AND_SPRING", | ||||
|                 department="GI", | ||||
|             ), | ||||
|         ] | ||||
|         for uv in uvs: | ||||
|             uv.author = cls.bibou | ||||
|             uv.title = "" | ||||
|             uv.manager = "" | ||||
|             uv.language = "FR" | ||||
|             uv.objectives = "" | ||||
|             uv.program = "" | ||||
|             uv.skills = "" | ||||
|             uv.key_concepts = "" | ||||
|             uv.credits = 6 | ||||
|         UV.objects.bulk_create(uvs) | ||||
|  | ||||
|     def setUp(self): | ||||
|         call_command("update_index", "pedagogy") | ||||
|     def fetch_uvs(self, **kwargs): | ||||
|         params = "&".join(f"{key}={val}" for key, val in kwargs.items()) | ||||
|         return json.loads(f"{self.url}?{params}") | ||||
|  | ||||
|     def test_get_page_authorized_success(self): | ||||
|         # Test with root user | ||||
|         self.client.force_login(self.bibou) | ||||
|         response = self.client.get(reverse("pedagogy:guide")) | ||||
|         assert response.status_code == 200 | ||||
|  | ||||
|         # Test with pedagogy admin | ||||
|         self.client.force_login(self.tutu) | ||||
|         response = self.client.get(reverse("pedagogy:guide")) | ||||
|         assert response.status_code == 200 | ||||
|  | ||||
|         # Test with subscribed user | ||||
|         self.client.force_login(self.sli) | ||||
|         response = self.client.get(reverse("pedagogy:guide")) | ||||
|         assert response.status_code == 200 | ||||
|  | ||||
|     def test_get_page_unauthorized_fail(self): | ||||
|     def test_permissions(self): | ||||
|         # Test with anonymous user | ||||
|         response = self.client.get(reverse("pedagogy:guide")) | ||||
|         response = self.client.get(self.url) | ||||
|         assert response.status_code == 403 | ||||
|  | ||||
|         # Test with not subscribed user | ||||
|         self.client.force_login(self.guy) | ||||
|         response = self.client.get(reverse("pedagogy:guide")) | ||||
|         response = self.client.get(self.url) | ||||
|         assert response.status_code == 403 | ||||
|  | ||||
|     def test_search_pa00_success(self): | ||||
|         self.client.force_login(self.sli) | ||||
|         for user in self.bibou, self.tutu, self.sli: | ||||
|             # users that have right | ||||
|             with self.subTest(): | ||||
|                 self.client.force_login(user) | ||||
|                 response = self.client.get(self.url) | ||||
|                 assert response.status_code == 200 | ||||
|  | ||||
|         # Search with UV code | ||||
|         response = self.client.get(reverse("pedagogy:guide"), {"search": "PA00"}) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         # Search with first letter of UV code | ||||
|         response = self.client.get(reverse("pedagogy:guide"), {"search": "P"}) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         # Search with first letter of UV code in lowercase | ||||
|         response = self.client.get(reverse("pedagogy:guide"), {"search": "p"}) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         # Search with UV title | ||||
|         response = self.client.get( | ||||
|             reverse("pedagogy:guide"), {"search": "participation"} | ||||
|         ) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         # Search with UV manager | ||||
|         response = self.client.get(reverse("pedagogy:guide"), {"search": "HEYBERGER"}) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         # Search with department | ||||
|         response = self.client.get(reverse("pedagogy:guide"), {"department": "HUMA"}) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         # Search with semester | ||||
|         response = self.client.get(reverse("pedagogy:guide"), {"semester": "AUTUMN"}) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         response = self.client.get(reverse("pedagogy:guide"), {"semester": "SPRING"}) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         response = self.client.get( | ||||
|             reverse("pedagogy:guide"), {"semester": "AUTUMN_AND_SPRING"} | ||||
|         ) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         # Search with language | ||||
|         response = self.client.get(reverse("pedagogy:guide"), {"language": "FR"}) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         # Search with credit type | ||||
|         response = self.client.get(reverse("pedagogy:guide"), {"credit_type": "OM"}) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         # Search with combinaison of all | ||||
|         response = self.client.get( | ||||
|             reverse("pedagogy:guide"), | ||||
|     def test_format(self): | ||||
|         """Test that the return data format is correct""" | ||||
|         self.client.force_login(self.bibou) | ||||
|         res = self.client.get(self.url + "?search=PA00") | ||||
|         uv = UV.objects.get(code="PA00") | ||||
|         assert res.status_code == 200 | ||||
|         assert json.loads(res.content) == [ | ||||
|             { | ||||
|                 "search": "P", | ||||
|                 "department": "HUMA", | ||||
|                 "semester": "AUTUMN", | ||||
|                 "language": "FR", | ||||
|                 "credit_type": "OM", | ||||
|             }, | ||||
|         ) | ||||
|         self.assertContains(response, text="PA00") | ||||
|  | ||||
|         # Test json briefly | ||||
|         response = self.client.get( | ||||
|             reverse("pedagogy:guide"), | ||||
|             { | ||||
|                 "json": "t", | ||||
|                 "search": "P", | ||||
|                 "department": "HUMA", | ||||
|                 "semester": "AUTUMN", | ||||
|                 "language": "FR", | ||||
|                 "credit_type": "OM", | ||||
|             }, | ||||
|         ) | ||||
|         self.assertJSONEqual( | ||||
|             response.content, | ||||
|             [ | ||||
|                 { | ||||
|                     "id": 1, | ||||
|                     "absolute_url": "/pedagogy/uv/1/", | ||||
|                     "update_url": "/pedagogy/uv/1/edit/", | ||||
|                     "delete_url": "/pedagogy/uv/1/delete/", | ||||
|                     "code": "PA00", | ||||
|                     "author": 0, | ||||
|                     "credit_type": "OM", | ||||
|                     "semester": "AUTUMN_AND_SPRING", | ||||
|                     "language": "FR", | ||||
|                     "credits": 5, | ||||
|                     "department": "HUMA", | ||||
|                     "title": "Participation dans une association \u00e9tudiante", | ||||
|                     "manager": "Laurent HEYBERGER", | ||||
|                     "objectives": "* Permettre aux \u00e9tudiants de r\u00e9aliser, pendant un semestre, un projet culturel ou associatif et de le valoriser.", | ||||
|                     "program": "* Semestre pr\u00e9c\u00e9dent proposition d'un projet et d'un cahier des charges\n* Evaluation par un jury de six membres\n* Si accord r\u00e9alisation dans le cadre de l'UV\n* Compte-rendu de l'exp\u00e9rience\n* Pr\u00e9sentation", | ||||
|                     "skills": "* G\u00e9rer un projet associatif ou une action \u00e9ducative en autonomie:\n* en produisant un cahier des charges qui -d\u00e9finit clairement le contexte du projet personnel -pose les jalons de ce projet -estime de mani\u00e8re r\u00e9aliste les moyens et objectifs du projet -d\u00e9finit exactement les livrables attendus\n    * en \u00e9tant capable de respecter ce cahier des charges ou, le cas \u00e9ch\u00e9ant, de r\u00e9viser le cahier des charges de mani\u00e8re argument\u00e9e.\n* Relater son exp\u00e9rience dans un rapport:\n* qui permettra \u00e0 d'autres \u00e9tudiants de poursuivre les actions engag\u00e9es\n* qui montre la capacit\u00e9 \u00e0 s'auto-\u00e9valuer et \u00e0 adopter une distance critique sur son action.", | ||||
|                     "key_concepts": "* Autonomie\n* Responsabilit\u00e9\n* Cahier des charges\n* Gestion de projet", | ||||
|                     "hours_CM": 0, | ||||
|                     "hours_TD": 0, | ||||
|                     "hours_TP": 0, | ||||
|                     "hours_THE": 121, | ||||
|                     "hours_TE": 4, | ||||
|                 "id": uv.id, | ||||
|                 "title": uv.title, | ||||
|                 "code": uv.code, | ||||
|                 "credit_type": uv.credit_type, | ||||
|                 "semester": uv.semester, | ||||
|                 "department": uv.department, | ||||
|             } | ||||
|             ], | ||||
|         ] | ||||
|  | ||||
|     def test_search_by_code(self): | ||||
|         self.client.force_login(self.bibou) | ||||
|         res = self.client.get(self.url + "?search=MT") | ||||
|         assert res.status_code == 200 | ||||
|         assert {uv["code"] for uv in json.loads(res.content)} == {"MT01", "MT10"} | ||||
|  | ||||
|     def test_search_by_credit_type(self): | ||||
|         self.client.force_login(self.bibou) | ||||
|         res = self.client.get(self.url + "?credit_type=CS") | ||||
|         assert res.status_code == 200 | ||||
|         codes = [uv["code"] for uv in json.loads(res.content)] | ||||
|         assert codes == ["AP4A", "MT01", "PHYS11"] | ||||
|         res = self.client.get(self.url + "?credit_type=CS&credit_type=OM") | ||||
|         assert res.status_code == 200 | ||||
|         codes = {uv["code"] for uv in json.loads(res.content)} | ||||
|         assert codes == {"AP4A", "MT01", "PHYS11", "PA00"} | ||||
|  | ||||
|     def test_search_by_semester(self): | ||||
|         self.client.force_login(self.bibou) | ||||
|         res = self.client.get(self.url + "?semester=SPRING") | ||||
|         assert res.status_code == 200 | ||||
|         codes = {uv["code"] for uv in json.loads(res.content)} | ||||
|         assert codes == {"DA50", "TNEV", "PA00"} | ||||
|  | ||||
|     def test_search_multiple_filters(self): | ||||
|         self.client.force_login(self.bibou) | ||||
|         res = self.client.get( | ||||
|             self.url + "?semester=AUTUMN&credit_type=CS&department=TC" | ||||
|         ) | ||||
|         assert res.status_code == 200 | ||||
|         codes = {uv["code"] for uv in json.loads(res.content)} | ||||
|         assert codes == {"MT01", "PHYS11"} | ||||
|  | ||||
|     def test_search_fails(self): | ||||
|         self.client.force_login(self.bibou) | ||||
|         res = self.client.get(self.url + "?credit_type=CS&search=DA") | ||||
|         assert res.status_code == 200 | ||||
|         assert json.loads(res.content) == [] | ||||
|  | ||||
|     def test_search_pa00_fail(self): | ||||
|         self.client.force_login(self.bibou) | ||||
|         # Search with UV code | ||||
|         response = self.client.get(reverse("pedagogy:guide"), {"search": "IFC"}) | ||||
|         self.assertNotContains(response, text="PA00") | ||||
|   | ||||
| @@ -27,7 +27,7 @@ from pedagogy.views import * | ||||
|  | ||||
| urlpatterns = [ | ||||
|     # Urls displaying the actual application for visitors | ||||
|     path("", UVListView.as_view(), name="guide"), | ||||
|     path("", UVGuideView.as_view(), name="guide"), | ||||
|     path("uv/<int:uv_id>/", UVDetailFormView.as_view(), name="uv_detail"), | ||||
|     path( | ||||
|         "comment/<int:comment_id>/edit/", | ||||
|   | ||||
							
								
								
									
										81
									
								
								pedagogy/utbm_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								pedagogy/utbm_api.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| """Set of functions to interact with the UTBM UV api.""" | ||||
|  | ||||
| import urllib | ||||
|  | ||||
| from django.conf import settings | ||||
|  | ||||
| from pedagogy.schemas import ShortUvList, UtbmFullUvSchema, UtbmShortUvSchema, UvSchema | ||||
|  | ||||
|  | ||||
| def find_uv(lang, year, code) -> UvSchema | None: | ||||
|     """Find an UV from the UTBM API.""" | ||||
|     # query the UV list | ||||
|     base_url = settings.SITH_PEDAGOGY_UTBM_API | ||||
|     uvs_url = f"{base_url}/uvs/{lang}/{year}" | ||||
|     response = urllib.request.urlopen(uvs_url) | ||||
|     uvs: list[UtbmShortUvSchema] = ShortUvList.validate_json(response.read()) | ||||
|  | ||||
|     short_uv = next((uv for uv in uvs if uv.code == code), None) | ||||
|     if short_uv is None: | ||||
|         return None | ||||
|  | ||||
|     # get detailed information about the UV | ||||
|     uv_url = f"{base_url}/uv/{lang}/{year}/{code}/{short_uv.code_formation}" | ||||
|     response = urllib.request.urlopen(uv_url) | ||||
|     full_uv = UtbmFullUvSchema.model_validate_json(response.read()) | ||||
|     return _make_clean_uv(short_uv, full_uv) | ||||
|  | ||||
|  | ||||
| def _make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvSchema: | ||||
|     """Cleans the data up so that it corresponds to our data representation. | ||||
|  | ||||
|     Some of the needed information are in the short uv schema, some | ||||
|     other in the full uv schema. | ||||
|     Thus we combine those information to obtain a data schema suitable | ||||
|     for our needs. | ||||
|     """ | ||||
|     if full_uv.departement == "Pôle Humanités": | ||||
|         department = "HUMA" | ||||
|     else: | ||||
|         department = { | ||||
|             "AL": "IMSI", | ||||
|             "AE": "EE", | ||||
|             "GI": "GI", | ||||
|             "GC": "EE", | ||||
|             "GM": "MC", | ||||
|             "TC": "TC", | ||||
|             "GP": "IMSI", | ||||
|             "ED": "EDIM", | ||||
|             "AI": "GI", | ||||
|             "AM": "MC", | ||||
|         }.get(short_uv.code_formation, "NA") | ||||
|  | ||||
|     match short_uv.ouvert_printemps, short_uv.ouvert_automne: | ||||
|         case True, True: | ||||
|             semester = "AUTUMN_AND_SPRING" | ||||
|         case True, False: | ||||
|             semester = "SPRING" | ||||
|         case False, True: | ||||
|             semester = "AUTUMN" | ||||
|         case _: | ||||
|             semester = "CLOSED" | ||||
|  | ||||
|     return UvSchema( | ||||
|         title=full_uv.libelle, | ||||
|         code=full_uv.code, | ||||
|         credit_type=short_uv.code_categorie, | ||||
|         semester=semester, | ||||
|         language=short_uv.code_langue.upper(), | ||||
|         credits=full_uv.credits_ects, | ||||
|         department=department, | ||||
|         hours_THE=next((i.nbh for i in full_uv.activites if i.code == "THE"), 0) // 60, | ||||
|         hours_TD=next((i.nbh for i in full_uv.activites if i.code == "TD"), 0) // 60, | ||||
|         hours_TP=next((i.nbh for i in full_uv.activites if i.code == "TP"), 0) // 60, | ||||
|         hours_TE=next((i.nbh for i in full_uv.activites if i.code == "TE"), 0) // 60, | ||||
|         hours_CM=next((i.nbh for i in full_uv.activites if i.code == "CM"), 0) // 60, | ||||
|         manager=full_uv.respo_automne or full_uv.respo_printemps or "", | ||||
|         objectives=full_uv.objectifs, | ||||
|         program=full_uv.programme, | ||||
|         skills=full_uv.acquisition_competences, | ||||
|         key_concepts=full_uv.acquisition_notions, | ||||
|     ) | ||||
| @@ -22,21 +22,17 @@ | ||||
| # | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.mixins import LoginRequiredMixin | ||||
| from django.core.exceptions import ObjectDoesNotExist, PermissionDenied | ||||
| from django.http import HttpResponse | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.urls import reverse, reverse_lazy | ||||
| from django.utils import html | ||||
| from django.views.generic import ( | ||||
|     CreateView, | ||||
|     DeleteView, | ||||
|     FormView, | ||||
|     ListView, | ||||
|     TemplateView, | ||||
|     UpdateView, | ||||
|     View, | ||||
| ) | ||||
| from haystack.query import SearchQuerySet | ||||
| from rest_framework.renderers import JSONRenderer | ||||
|  | ||||
| from core.models import Notification, RealGroup | ||||
| from core.views import ( | ||||
| @@ -44,6 +40,7 @@ from core.views import ( | ||||
|     CanEditPropMixin, | ||||
|     CanViewMixin, | ||||
|     DetailFormView, | ||||
|     FormerSubscriberMixin, | ||||
| ) | ||||
| from pedagogy.forms import ( | ||||
|     UVCommentForm, | ||||
| @@ -51,30 +48,12 @@ from pedagogy.forms import ( | ||||
|     UVCommentReportForm, | ||||
|     UVForm, | ||||
| ) | ||||
| from pedagogy.models import UV, UVComment, UVCommentReport, UVSerializer | ||||
|  | ||||
| # Some mixins | ||||
|  | ||||
|  | ||||
| class CanCreateUVFunctionMixin(View): | ||||
|     """Add the function can_create_uv(user) into the template.""" | ||||
|  | ||||
|     @staticmethod | ||||
|     def can_create_uv(user): | ||||
|         """Creates a dummy instance of UV and test is_owner.""" | ||||
|         return user.is_owner(UV()) | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         """Pass the function to the template.""" | ||||
|         kwargs = super().get_context_data(**kwargs) | ||||
|         kwargs["can_create_uv"] = self.can_create_uv | ||||
|         return kwargs | ||||
|  | ||||
| from pedagogy.models import UV, UVComment, UVCommentReport | ||||
|  | ||||
| # Acutal views | ||||
|  | ||||
|  | ||||
| class UVDetailFormView(CanViewMixin, CanCreateUVFunctionMixin, DetailFormView): | ||||
| class UVDetailFormView(CanViewMixin, DetailFormView): | ||||
|     """Display every comment of an UV and detailed infos about it. | ||||
|  | ||||
|     Allow to comment the UV. | ||||
| @@ -101,6 +80,15 @@ class UVDetailFormView(CanViewMixin, CanCreateUVFunctionMixin, DetailFormView): | ||||
|             "pedagogy:uv_detail", kwargs={"uv_id": self.get_object().id} | ||||
|         ) | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         user = self.request.user | ||||
|         return super().get_context_data(**kwargs) | { | ||||
|             "can_create_uv": ( | ||||
|                 user.is_root | ||||
|                 or user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID) | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|  | ||||
| class UVCommentUpdateView(CanEditPropMixin, UpdateView): | ||||
|     """Allow edit of a given comment.""" | ||||
| @@ -134,65 +122,19 @@ class UVCommentDeleteView(CanEditPropMixin, DeleteView): | ||||
|         return reverse_lazy("pedagogy:uv_detail", kwargs={"uv_id": self.object.uv.id}) | ||||
|  | ||||
|  | ||||
| class UVListView(CanViewMixin, CanCreateUVFunctionMixin, ListView): | ||||
| class UVGuideView(LoginRequiredMixin, FormerSubscriberMixin, TemplateView): | ||||
|     """UV guide main page.""" | ||||
|  | ||||
|     # This is very basic and is prone to changment | ||||
|  | ||||
|     model = UV | ||||
|     ordering = ["code"] | ||||
|     template_name = "pedagogy/guide.jinja" | ||||
|  | ||||
|     def get(self, *args, **kwargs): | ||||
|         if not self.request.GET.get("json", None): | ||||
|             # Return normal full template response | ||||
|             return super().get(*args, **kwargs) | ||||
|  | ||||
|         # Return serialized response | ||||
|         return HttpResponse( | ||||
|             JSONRenderer().render(UVSerializer(self.get_queryset(), many=True).data), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         queryset = super().get_queryset() | ||||
|         search = self.request.GET.get("search", None) | ||||
|  | ||||
|         additional_filters = {} | ||||
|  | ||||
|         for filter_type in ["credit_type", "language", "department"]: | ||||
|             arg = self.request.GET.get(filter_type, None) | ||||
|             if arg: | ||||
|                 additional_filters[filter_type] = arg | ||||
|  | ||||
|         semester = self.request.GET.get("semester", None) | ||||
|         if semester: | ||||
|             if semester in ["AUTUMN", "SPRING"]: | ||||
|                 additional_filters["semester__in"] = [semester, "AUTUMN_AND_SPRING"] | ||||
|             else: | ||||
|                 additional_filters["semester"] = semester | ||||
|  | ||||
|         queryset = queryset.filter(**additional_filters) | ||||
|         if not search: | ||||
|             return queryset | ||||
|  | ||||
|         if len(search) == 1: | ||||
|             # It's a search with only one letter | ||||
|             # Haystack doesn't work well with only one letter | ||||
|             return queryset.filter(code__istartswith=search) | ||||
|  | ||||
|         try: | ||||
|             qs = ( | ||||
|                 SearchQuerySet() | ||||
|                 .models(self.model) | ||||
|                 .autocomplete(auto=html.escape(search)) | ||||
|             ) | ||||
|         except TypeError: | ||||
|             return self.model.objects.none() | ||||
|  | ||||
|         return queryset.filter( | ||||
|             id__in=([o.object.id for o in qs if o.object is not None]) | ||||
|     def get_context_data(self, **kwargs): | ||||
|         user = self.request.user | ||||
|         return super().get_context_data(**kwargs) | { | ||||
|             "can_create_uv": ( | ||||
|                 user.is_root | ||||
|                 or user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID) | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|  | ||||
| class UVCommentReportCreateView(CanCreateMixin, CreateView): | ||||
|   | ||||
							
								
								
									
										213
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										213
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							| @@ -11,6 +11,17 @@ files = [ | ||||
|     {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "annotated-types" | ||||
| version = "0.7.0" | ||||
| description = "Reusable constraint types to use with typing.Annotated" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| files = [ | ||||
|     {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, | ||||
|     {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "asgiref" | ||||
| version = "3.8.1" | ||||
| @@ -303,6 +314,17 @@ files = [ | ||||
|     {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "contextlib2" | ||||
| version = "21.6.0" | ||||
| description = "Backports and enhancements for the contextlib module" | ||||
| optional = false | ||||
| python-versions = ">=3.6" | ||||
| files = [ | ||||
|     {file = "contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f"}, | ||||
|     {file = "contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869"}, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "coverage" | ||||
| version = "7.6.0" | ||||
| @@ -573,6 +595,44 @@ files = [ | ||||
| django = ">=3.2" | ||||
| jinja2 = ">=3" | ||||
|  | ||||
| [[package]] | ||||
| name = "django-ninja" | ||||
| version = "1.2.1" | ||||
| description = "Django Ninja - Fast Django REST framework" | ||||
| optional = false | ||||
| python-versions = ">=3.7" | ||||
| files = [ | ||||
|     {file = "django_ninja-1.2.1-py3-none-any.whl", hash = "sha256:acb7a0005e84acdb0ae96066c42c7f304f988a078d370e5952382b928bb28a08"}, | ||||
|     {file = "django_ninja-1.2.1.tar.gz", hash = "sha256:667ff27304039d4692421709ae532fd62b16a4d34a969ef850d5cd22cb46090a"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| Django = ">=3.1" | ||||
| pydantic = ">=2.0,<3.0.0" | ||||
|  | ||||
| [package.extras] | ||||
| dev = ["pre-commit"] | ||||
| doc = ["markdown-include", "mkdocs", "mkdocs-material", "mkdocstrings"] | ||||
| test = ["django-stubs", "mypy (==1.7.1)", "psycopg2-binary", "pytest", "pytest-asyncio", "pytest-cov", "pytest-django", "ruff (==0.4.2)"] | ||||
|  | ||||
| [[package]] | ||||
| name = "django-ninja-extra" | ||||
| version = "0.21.1" | ||||
| description = "Django Ninja Extra - Class Based Utility and more for Django Ninja(Fast Django REST framework)" | ||||
| optional = false | ||||
| python-versions = ">=3.7" | ||||
| files = [ | ||||
|     {file = "django_ninja_extra-0.21.1-py3-none-any.whl", hash = "sha256:331cdf9cbeb8a122a8192c35ac1fba373b0736f4d91d75bc2d39fd0e8d8a66ea"}, | ||||
|     {file = "django_ninja_extra-0.21.1.tar.gz", hash = "sha256:7e0de377c2afd0d4b6655e01901bb8c370c04ffdf5471a17b14e8db0d1002e8e"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| asgiref = "*" | ||||
| contextlib2 = "*" | ||||
| Django = ">=2.2" | ||||
| django-ninja = "1.2.1" | ||||
| injector = ">=0.19.0" | ||||
|  | ||||
| [[package]] | ||||
| name = "django-ordered-model" | ||||
| version = "3.7.4" | ||||
| @@ -634,20 +694,6 @@ Pillow = ">=6.2.0" | ||||
| [package.extras] | ||||
| test = ["testfixtures"] | ||||
|  | ||||
| [[package]] | ||||
| name = "djangorestframework" | ||||
| version = "3.15.2" | ||||
| description = "Web APIs for Django, made easy." | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| files = [ | ||||
|     {file = "djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20"}, | ||||
|     {file = "djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| django = ">=4.2" | ||||
|  | ||||
| [[package]] | ||||
| name = "docutils" | ||||
| version = "0.19" | ||||
| @@ -796,6 +842,20 @@ files = [ | ||||
|     {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "injector" | ||||
| version = "0.22.0" | ||||
| description = "Injector - Python dependency injection framework, inspired by Guice" | ||||
| optional = false | ||||
| python-versions = "*" | ||||
| files = [ | ||||
|     {file = "injector-0.22.0-py2.py3-none-any.whl", hash = "sha256:74379ccef3b893bc7d0b7d504c255b5160c5a55e97dc7bdcc73cb33cc7dce3a1"}, | ||||
|     {file = "injector-0.22.0.tar.gz", hash = "sha256:79b2d4a0874c75d3aa735f11c5b32b89d9542711ca07071161882c5e9cc15ed6"}, | ||||
| ] | ||||
|  | ||||
| [package.extras] | ||||
| dev = ["black (==24.3.0)", "build (==1.0.3)", "check-manifest (==0.49)", "click (==8.1.7)", "coverage[toml] (==7.3.2)", "exceptiongroup (==1.2.0)", "importlib-metadata (==7.0.0)", "iniconfig (==2.0.0)", "mypy (==1.7.1)", "mypy-extensions (==1.0.0)", "packaging (==23.2)", "pathspec (==0.12.1)", "platformdirs (==4.1.0)", "pluggy (==1.3.0)", "pyproject-hooks (==1.0.0)", "pytest (==7.4.3)", "pytest-cov (==4.1.0)", "tomli (==2.0.1)", "typing-extensions (==4.9.0)", "zipp (==3.17.0)"] | ||||
|  | ||||
| [[package]] | ||||
| name = "ipython" | ||||
| version = "8.26.0" | ||||
| @@ -1525,6 +1585,129 @@ files = [ | ||||
|     {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "pydantic" | ||||
| version = "2.8.2" | ||||
| description = "Data validation using Python type hints" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| files = [ | ||||
|     {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, | ||||
|     {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| annotated-types = ">=0.4.0" | ||||
| pydantic-core = "2.20.1" | ||||
| typing-extensions = [ | ||||
|     {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, | ||||
|     {version = ">=4.6.1", markers = "python_version < \"3.13\""}, | ||||
| ] | ||||
|  | ||||
| [package.extras] | ||||
| email = ["email-validator (>=2.0.0)"] | ||||
|  | ||||
| [[package]] | ||||
| name = "pydantic-core" | ||||
| version = "2.20.1" | ||||
| description = "Core functionality for Pydantic validation and serialization" | ||||
| optional = false | ||||
| python-versions = ">=3.8" | ||||
| files = [ | ||||
|     {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, | ||||
|     {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, | ||||
|     {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, | ||||
|     {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, | ||||
|     {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, | ||||
|     {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, | ||||
|     {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, | ||||
|     {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, | ||||
|     {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, | ||||
|     {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, | ||||
|     {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, | ||||
|     {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, | ||||
|     {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, | ||||
|     {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, | ||||
|     {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, | ||||
|     {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, | ||||
|     {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, | ||||
|     {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, | ||||
|     {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, | ||||
|     {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, | ||||
|     {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, | ||||
|     {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, | ||||
|     {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, | ||||
|     {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, | ||||
|     {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, | ||||
|     {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, | ||||
|     {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, | ||||
|     {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, | ||||
|     {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, | ||||
|     {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, | ||||
|     {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, | ||||
|     {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, | ||||
|     {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, | ||||
|     {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, | ||||
|     {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, | ||||
|     {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, | ||||
|     {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, | ||||
|     {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, | ||||
|     {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, | ||||
|     {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, | ||||
|     {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, | ||||
|     {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, | ||||
|     {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, | ||||
|     {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, | ||||
|     {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, | ||||
|     {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, | ||||
|     {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, | ||||
|     {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, | ||||
|     {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, | ||||
|     {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, | ||||
|     {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, | ||||
|     {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, | ||||
|     {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, | ||||
|     {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, | ||||
|     {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, | ||||
|     {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, | ||||
|     {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, | ||||
|     {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, | ||||
|     {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, | ||||
|     {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, | ||||
|     {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, | ||||
|     {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, | ||||
|     {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, | ||||
|     {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, | ||||
|     {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, | ||||
|     {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, | ||||
|     {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, | ||||
|     {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, | ||||
|     {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, | ||||
|     {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, | ||||
|     {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, | ||||
|     {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, | ||||
|     {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, | ||||
|     {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, | ||||
|     {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, | ||||
|     {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, | ||||
|     {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, | ||||
|     {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, | ||||
|     {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, | ||||
|     {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, | ||||
|     {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, | ||||
|     {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, | ||||
|     {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, | ||||
|     {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, | ||||
|     {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, | ||||
|     {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, | ||||
|     {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, | ||||
|     {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, | ||||
|     {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, | ||||
| ] | ||||
|  | ||||
| [package.dependencies] | ||||
| typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" | ||||
|  | ||||
| [[package]] | ||||
| name = "pygments" | ||||
| version = "2.18.0" | ||||
| @@ -2271,4 +2454,4 @@ filelock = ">=3.4" | ||||
| [metadata] | ||||
| lock-version = "2.0" | ||||
| python-versions = "^3.10" | ||||
| content-hash = "9038b84fac4dc7ce5aea0520d29e4d5705e2e55f3e165d2455ebc61eafe6cfe0" | ||||
| content-hash = "ee0b881719f6834880266d72272429708e781b3ccd34a0fbf3e8b4119dcb95fd" | ||||
|   | ||||
| @@ -22,11 +22,12 @@ license = "GPL-3.0-only" | ||||
| [tool.poetry.dependencies] | ||||
| python = "^3.10" | ||||
| Django = "^4.2.14" | ||||
| django-ninja = "^1.2.0" | ||||
| django-ninja-extra = "^0.21.0" | ||||
| Pillow = "^10.4.0" | ||||
| mistune = "^3.0.2" | ||||
| django-jinja = "^2.11" | ||||
| cryptography = "^42.0.8" | ||||
| djangorestframework = "^3.13" | ||||
| django-phonenumber-field = "^6.3" | ||||
| phonenumbers = "^8.12" | ||||
| django-ajax-selects = "^2.1.0" | ||||
|   | ||||
							
								
								
									
										43
									
								
								sas/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								sas/api.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| from ninja import Query | ||||
| from ninja_extra import ControllerBase, api_controller, route | ||||
| from ninja_extra.exceptions import PermissionDenied | ||||
| from ninja_extra.permissions import IsAuthenticated | ||||
|  | ||||
| from core.models import User | ||||
| from sas.models import Picture | ||||
| from sas.schemas import PictureFilterSchema, PictureSchema | ||||
|  | ||||
|  | ||||
| @api_controller("/sas") | ||||
| class SasController(ControllerBase): | ||||
|     @route.get( | ||||
|         "/picture", | ||||
|         response=list[PictureSchema], | ||||
|         permissions=[IsAuthenticated], | ||||
|         url_name="pictures", | ||||
|     ) | ||||
|     def fetch_pictures(self, filters: Query[PictureFilterSchema]): | ||||
|         """Find pictures viewable by the user corresponding to the given filters. | ||||
|  | ||||
|         A user with an active subscription can see any picture, as long | ||||
|         as it has been moderated and not asked for removal. | ||||
|         An unsubscribed user can see the pictures he has been identified on | ||||
|         (only the moderated ones, too) | ||||
|  | ||||
|         Notes: | ||||
|             Trying to fetch the pictures of another user with this route | ||||
|             while being unsubscribed will just result in an empty response. | ||||
|         """ | ||||
|         user: User = self.context.request.user | ||||
|         if not user.is_subscribed and filters.users_identified != {user.id}: | ||||
|             # User can view any moderated picture if he/she is subscribed. | ||||
|             # If not, he/she can view only the one he/she has been identified on | ||||
|             raise PermissionDenied | ||||
|         pictures = filters.filter( | ||||
|             Picture.objects.filter(is_moderated=True, asked_for_removal=False) | ||||
|         ) | ||||
|         for picture in pictures: | ||||
|             picture.full_size_url = picture.get_download_url() | ||||
|             picture.compressed_url = picture.get_download_compressed_url() | ||||
|             picture.thumb_url = picture.get_download_thumb_url() | ||||
|         return pictures | ||||
							
								
								
									
										25
									
								
								sas/schemas.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								sas/schemas.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| from datetime import datetime | ||||
|  | ||||
| from ninja import FilterSchema, ModelSchema | ||||
| from pydantic import Field | ||||
|  | ||||
| from core.schemas import SimpleUserSchema | ||||
| from sas.models import Picture | ||||
|  | ||||
|  | ||||
| class PictureFilterSchema(FilterSchema): | ||||
|     before_date: datetime | None = Field(None, q="date__lte") | ||||
|     after_date: datetime | None = Field(None, q="date__gte") | ||||
|     users_identified: set[int] | None = Field(None, q="people__user_id__in") | ||||
|     album_id: int | None = Field(None, q="parent_id") | ||||
|  | ||||
|  | ||||
| class PictureSchema(ModelSchema): | ||||
|     class Meta: | ||||
|         model = Picture | ||||
|         fields = ["id", "name", "date"] | ||||
|  | ||||
|     author: SimpleUserSchema = Field(validation_alias="owner") | ||||
|     full_size_url: str | ||||
|     compressed_url: str | ||||
|     thumb_url: str | ||||
| @@ -78,7 +78,7 @@ INSTALLED_APPS = ( | ||||
|     "django.contrib.sites", | ||||
|     "honeypot", | ||||
|     "django_jinja", | ||||
|     "rest_framework", | ||||
|     "ninja_extra", | ||||
|     "ajax_select", | ||||
|     "haystack", | ||||
|     "captcha", | ||||
| @@ -89,7 +89,6 @@ INSTALLED_APPS = ( | ||||
|     "counter", | ||||
|     "eboutic", | ||||
|     "launderette", | ||||
|     "api", | ||||
|     "rootplace", | ||||
|     "sas", | ||||
|     "com", | ||||
| @@ -473,8 +472,8 @@ SITH_PEDAGOGY_UV_RESULT_GRADE = [ | ||||
| ] | ||||
|  | ||||
| SITH_LOG_OPERATION_TYPE = [ | ||||
|     (("SELLING_DELETION"), _("Selling deletion")), | ||||
|     (("REFILLING_DELETION"), _("Refilling deletion")), | ||||
|     ("SELLING_DELETION", _("Selling deletion")), | ||||
|     ("REFILLING_DELETION", _("Refilling deletion")), | ||||
| ] | ||||
|  | ||||
| SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide" | ||||
|   | ||||
| @@ -19,6 +19,7 @@ from django.conf.urls.static import static | ||||
| from django.contrib import admin | ||||
| from django.urls import include, path | ||||
| from django.views.i18n import JavaScriptCatalog | ||||
| from ninja_extra import NinjaExtraAPI | ||||
|  | ||||
| js_info_dict = {"packages": ("sith",)} | ||||
|  | ||||
| @@ -26,8 +27,12 @@ handler403 = "core.views.forbidden" | ||||
| handler404 = "core.views.not_found" | ||||
| handler500 = "core.views.internal_servor_error" | ||||
|  | ||||
| api = NinjaExtraAPI(version="0.2.0", urls_namespace="api") | ||||
| api.auto_discover_controllers() | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("", include(("core.urls", "core"), namespace="core")), | ||||
|     path("api/", api.urls), | ||||
|     path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")), | ||||
|     path( | ||||
|         "subscription/", | ||||
| @@ -47,7 +52,6 @@ urlpatterns = [ | ||||
|         include(("launderette.urls", "launderette"), namespace="launderette"), | ||||
|     ), | ||||
|     path("sas/", include(("sas.urls", "sas"), namespace="sas")), | ||||
|     path("api/v1/", include(("api.urls", "api"), namespace="api")), | ||||
|     path("election/", include(("election.urls", "election"), namespace="election")), | ||||
|     path("forum/", include(("forum.urls", "forum"), namespace="forum")), | ||||
|     path("galaxy/", include(("galaxy.urls", "galaxy"), namespace="galaxy")), | ||||
| @@ -61,7 +65,6 @@ urlpatterns = [ | ||||
|     path("captcha/", include("captcha.urls")), | ||||
| ] | ||||
|  | ||||
|  | ||||
| if settings.DEBUG: | ||||
|     urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) | ||||
|     urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) | ||||
|   | ||||
| @@ -57,7 +57,7 @@ def test_subscription_compute_start_explicit(start_date, duration, expected_star | ||||
|         (date(2020, 9, 18), 1, date(2021, 3, 18)), | ||||
|         (date(2020, 9, 18), 2, date(2021, 9, 18)), | ||||
|         (date(2020, 9, 18), 3, date(2022, 2, 15)), | ||||
|         (date(2020, 5, 17), 4, date(2022, 8, 15)), | ||||
|         (date(2020, 5, 17), 4, date(2022, 2, 15)), | ||||
|         (date(2020, 9, 18), 0.33, date(2020, 11, 18)), | ||||
|         (date(2020, 9, 18), 0.67, date(2021, 1, 19)), | ||||
|         (date(2020, 9, 18), 0.5, date(2020, 12, 18)), | ||||
| @@ -75,7 +75,7 @@ def test_subscription_compute_end_from_today(today, duration, expected_end): | ||||
|         (date(2020, 9, 18), 4, date(2022, 9, 18)), | ||||
|     ], | ||||
| ) | ||||
| def test_subscription_compute_end_from_today(start_date, duration, expected_end): | ||||
| def test_subscription_compute_end(start_date, duration, expected_end): | ||||
|     assert Subscription.compute_end(duration, start_date) == expected_end | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -46,7 +46,6 @@ from core.views import ( | ||||
|     CanViewMixin, | ||||
|     QuickNotifMixin, | ||||
|     TabedViewMixin, | ||||
|     UserIsLoggedMixin, | ||||
| ) | ||||
| from core.views.forms import SelectDate | ||||
| from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser | ||||
| @@ -296,7 +295,7 @@ class UserTrombiForm(forms.Form): | ||||
|  | ||||
|  | ||||
| class UserTrombiToolsView( | ||||
|     QuickNotifMixin, TrombiTabsMixin, UserIsLoggedMixin, TemplateView | ||||
|     LoginRequiredMixin, QuickNotifMixin, TrombiTabsMixin, TemplateView | ||||
| ): | ||||
|     """Display a user's trombi tools.""" | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user