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() |         super().delete() | ||||||
|  |  | ||||||
|     def fetch_format(self): |     def fetch_format(self): | ||||||
|         resp = self.email + ": " |         destination = "".join(s.fetch_format() for s in self.subscriptions.all()) | ||||||
|         for sub in self.subscriptions.all(): |         return f"{self.email}: {destination}" | ||||||
|             resp += sub.fetch_format() |  | ||||||
|         return resp |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MailingSubscription(models.Model): | 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() { |         function download_pictures() { | ||||||
|             $("#download_all_pictures").prop("disabled", true); |             $("#download_all_pictures").prop("disabled", true); | ||||||
|             var xhr = new XMLHttpRequest(); |             const xhr = new XMLHttpRequest(); | ||||||
|             $.ajax({ |             $.ajax({ | ||||||
|                 type: "GET", |                 type: "GET", | ||||||
|                 url: "{{ url('api:all_pictures_of_user', user=object.id) }}", |                 url: "{{ url('api:pictures') }}?users_identified={{ object.id }}", | ||||||
|                 tryCount: 0, |                 tryCount: 0, | ||||||
|                 xhr: function(){ |                 xhr: function(){ | ||||||
|                      return xhr; |                      return xhr; | ||||||
|   | |||||||
| @@ -65,7 +65,7 @@ class TestUserRegistration: | |||||||
|                 {"password2": "not the same as password1"}, |                 {"password2": "not the same as password1"}, | ||||||
|                 "Les deux mots de passe ne correspondent pas.", |                 "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."), |             ({"first_name": ""}, "Ce champ est obligatoire."), | ||||||
|             ({"last_name": ""}, "Ce champ est obligatoire."), |             ({"last_name": ""}, "Ce champ est obligatoire."), | ||||||
|             ({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"), |             ({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"), | ||||||
| @@ -310,7 +310,6 @@ http://git.an | |||||||
|         ) |         ) | ||||||
|         response = self.client.get(reverse("core:page", kwargs={"page_name": "guy"})) |         response = self.client.get(reverse("core:page", kwargs={"page_name": "guy"})) | ||||||
|         assert response.status_code == 200 |         assert response.status_code == 200 | ||||||
|         print(response.content.decode()) |  | ||||||
|         expected = """ |         expected = """ | ||||||
|             <p>Guy <em>bibou</em></p> |             <p>Guy <em>bibou</em></p> | ||||||
|             <p><a href="http://git.an">http://git.an</a></p> |             <p><a href="http://git.an">http://git.an</a></p> | ||||||
|   | |||||||
| @@ -25,6 +25,7 @@ | |||||||
| import types | import types | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
|  | from django.contrib.auth.mixins import AccessMixin | ||||||
| from django.core.exceptions import ( | from django.core.exceptions import ( | ||||||
|     ImproperlyConfigured, |     ImproperlyConfigured, | ||||||
|     PermissionDenied, |     PermissionDenied, | ||||||
| @@ -234,7 +235,7 @@ class UserIsRootMixin(GenericContentPermissionMixinBuilder): | |||||||
|     permission_function = lambda obj, user: user.is_root |     permission_function = lambda obj, user: user.is_root | ||||||
|  |  | ||||||
|  |  | ||||||
| class FormerSubscriberMixin(View): | class FormerSubscriberMixin(AccessMixin): | ||||||
|     """Check if the user was at least an old subscriber. |     """Check if the user was at least an old subscriber. | ||||||
|  |  | ||||||
|     Raises: |     Raises: | ||||||
| @@ -247,16 +248,10 @@ class FormerSubscriberMixin(View): | |||||||
|         return super().dispatch(request, *args, **kwargs) |         return super().dispatch(request, *args, **kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserIsLoggedMixin(View): | class SubscriberMixin(AccessMixin): | ||||||
|     """Check if the user is logged. |  | ||||||
|  |  | ||||||
|     Raises: |  | ||||||
|         PermissionDenied: |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     def dispatch(self, request, *args, **kwargs): |     def dispatch(self, request, *args, **kwargs): | ||||||
|         if request.user.is_anonymous: |         if not request.user.is_subscribed: | ||||||
|             raise PermissionDenied |             return self.handle_no_permission() | ||||||
|         return super().dispatch(request, *args, **kwargs) |         return super().dispatch(request, *args, **kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -104,7 +104,7 @@ class MarkdownInput(Textarea): | |||||||
|             "fullscreen": _("Toggle fullscreen"), |             "fullscreen": _("Toggle fullscreen"), | ||||||
|             "guide": _("Markdown guide"), |             "guide": _("Markdown guide"), | ||||||
|         } |         } | ||||||
|         context["markdown_api_url"] = reverse("api:api_markdown") |         context["markdown_api_url"] = reverse("api:markdown") | ||||||
|         return context |         return context | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -29,6 +29,7 @@ from smtplib import SMTPException | |||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.contrib.auth import login, views | from django.contrib.auth import login, views | ||||||
| from django.contrib.auth.forms import PasswordChangeForm | from django.contrib.auth.forms import PasswordChangeForm | ||||||
|  | from django.contrib.auth.mixins import LoginRequiredMixin | ||||||
| from django.core.exceptions import PermissionDenied, ValidationError | from django.core.exceptions import PermissionDenied, ValidationError | ||||||
| from django.forms import CheckboxSelectMultiple | from django.forms import CheckboxSelectMultiple | ||||||
| from django.forms.models import modelform_factory | 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 django.views.generic.edit import FormView, UpdateView | ||||||
| from honeypot.decorators import check_honeypot | 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.models import Gift, Preferences, SithFile, User | ||||||
| from core.views import ( | from core.views import ( | ||||||
|     CanEditMixin, |     CanEditMixin, | ||||||
| @@ -58,7 +58,6 @@ from core.views import ( | |||||||
|     CanViewMixin, |     CanViewMixin, | ||||||
|     QuickNotifMixin, |     QuickNotifMixin, | ||||||
|     TabedViewMixin, |     TabedViewMixin, | ||||||
|     UserIsLoggedMixin, |  | ||||||
| ) | ) | ||||||
| from core.views.forms import ( | from core.views.forms import ( | ||||||
|     GiftForm, |     GiftForm, | ||||||
| @@ -68,6 +67,7 @@ from core.views.forms import ( | |||||||
|     UserProfileForm, |     UserProfileForm, | ||||||
| ) | ) | ||||||
| from counter.forms import StudentCardForm | from counter.forms import StudentCardForm | ||||||
|  | from sas.models import Picture | ||||||
| from subscription.models import Subscription | from subscription.models import Subscription | ||||||
| from trombi.views import UserTrombiForm | from trombi.views import UserTrombiForm | ||||||
|  |  | ||||||
| @@ -313,7 +313,11 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView): | |||||||
|         kwargs = super().get_context_data(**kwargs) |         kwargs = super().get_context_data(**kwargs) | ||||||
|         kwargs["albums"] = [] |         kwargs["albums"] = [] | ||||||
|         kwargs["pictures"] = {} |         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 |         last_album = None | ||||||
|         for picture in picture_qs: |         for picture in picture_qs: | ||||||
|             album = picture.parent |             album = picture.parent | ||||||
| @@ -720,7 +724,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView): | |||||||
|     current_tab = "groups" |     current_tab = "groups" | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserToolsView(QuickNotifMixin, UserTabsMixin, UserIsLoggedMixin, TemplateView): | class UserToolsView(LoginRequiredMixin, QuickNotifMixin, UserTabsMixin, TemplateView): | ||||||
|     """Displays the logged user's tools.""" |     """Displays the logged user's tools.""" | ||||||
|  |  | ||||||
|     template_name = "core/user_tools.jinja" |     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 ajax_select import make_ajax_field | ||||||
| from django import forms | from django import forms | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
|  | from django.contrib.auth.mixins import LoginRequiredMixin | ||||||
| from django.core.exceptions import PermissionDenied | from django.core.exceptions import PermissionDenied | ||||||
| from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator | from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator | ||||||
| from django.db import IntegrityError | from django.db import IntegrityError | ||||||
| @@ -43,7 +44,6 @@ from core.views import ( | |||||||
|     CanEditMixin, |     CanEditMixin, | ||||||
|     CanEditPropMixin, |     CanEditPropMixin, | ||||||
|     CanViewMixin, |     CanViewMixin, | ||||||
|     UserIsLoggedMixin, |  | ||||||
|     can_view, |     can_view, | ||||||
| ) | ) | ||||||
| from core.views.forms import MarkdownInput | from core.views.forms import MarkdownInput | ||||||
| @@ -273,7 +273,7 @@ class ForumTopicEditView(CanEditMixin, UpdateView): | |||||||
|  |  | ||||||
|  |  | ||||||
| class ForumTopicSubscribeView( | class ForumTopicSubscribeView( | ||||||
|     CanViewMixin, UserIsLoggedMixin, SingleObjectMixin, RedirectView |     LoginRequiredMixin, CanViewMixin, SingleObjectMixin, RedirectView | ||||||
| ): | ): | ||||||
|     model = ForumTopic |     model = ForumTopic | ||||||
|     pk_url_kwarg = "topic_id" |     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 import timezone | ||||||
| from django.utils.functional import cached_property | from django.utils.functional import cached_property | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from rest_framework import serializers |  | ||||||
|  |  | ||||||
| from core.models import User | from core.models import User | ||||||
|  |  | ||||||
| @@ -327,30 +326,3 @@ class UVCommentReport(models.Model): | |||||||
|     def is_owned_by(self, user): |     def is_owned_by(self, user): | ||||||
|         """Can be created by a pedagogy admin, a superuser or a subscriber.""" |         """Can be created by a pedagogy admin, a superuser or a subscriber.""" | ||||||
|         return user.is_subscribed or user.is_owner(self.comment.uv) |         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 %} | {% trans %}UV Guide{% endtrans %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block additional_js %} | ||||||
|  |     <script src="{{ static('core/js/alpinejs.min.js') }}" defer></script> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
|     {{ super() }} |     {{ super() }} | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=0.6, maximum-scale=2"> |     <meta name="viewport" content="width=device-width, initial-scale=0.6, maximum-scale=2"> | ||||||
| {% endblock head %} | {% endblock head %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
| <div class="pedagogy"> | {% if can_create_uv %} | ||||||
| 	<form id="search_form" action="{{ url('pedagogy:guide') }}" method="get"> | <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> | ||||||
|  | <br/> | ||||||
|  | {% endif %} | ||||||
|  | <div class="pedagogy" x-data="uv_search" x-cloak> | ||||||
|  | 	<form id="search_form"> | ||||||
| 		<div class="search-form-container"> | 		<div class="search-form-container"> | ||||||
| 			{% if can_create_uv(user) %} |  | ||||||
| 			<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 class="search-bar"> | 			<div class="search-bar"> | ||||||
| 				<input id="search_input" class="search-bar-input" type="text" name="search"> | 				<input | ||||||
| 				<button class="search-bar-button">{% trans %}Search{% endtrans %}</button> |                     id="search_input" | ||||||
|  |                     class="search-bar-input" | ||||||
|  |                     type="text" | ||||||
|  |                     name="search" | ||||||
|  |                     x-model.debounce.500ms="search" | ||||||
|  |                 /> | ||||||
| 			</div> | 			</div> | ||||||
| 			<div class="radio-department"> | 			<div class="radio-department"> | ||||||
| 				<div class="radio-guide"> | 				<div class="radio-guide"> | ||||||
| 					{% for (display_name, real_name) in [("EDIM", "EDIM"), ("ENERGIE", "EE"), ("IMSI", "IMSI"), ("INFO", "GI"), ("GMC", "MC"), ("HUMA", "HUMA"), ("TC", "TC")] %} | 					{% for (display_name, real_name) in [ | ||||||
| 						<input type="radio" name="department" id="radio{{ real_name }}" value="{{ real_name }}"><label for="radio{{ real_name }}">{% trans %}{{ display_name }}{% endtrans %}</label> | 					    ("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 %} | 					{% endfor %} | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 			<div class="radio-credit-type"> | 			<div class="radio-credit-type"> | ||||||
| 				<div class="radio-guide"> | 				<div class="radio-guide"> | ||||||
| 					{% for credit_type in ["CS", "TM", "EC", "QC", "OM"] %} | 					{% 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 %} | 					{% endfor %} | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
|  |  | ||||||
| 			<div class="radio-semester"> | 			<div class="radio-semester"> | ||||||
| 				<div class="radio-guide"> | 				<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="radioAUTUMN" value="AUTUMN" x-model="semester"/> | ||||||
| 					<input type="checkbox" name="semester" id="radioSPRING" value="SPRING"><label for="radioSPRING"><i class="fa fa-sun-o"></i></label> |                     <label for="radioAUTUMN"><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="radioSPRING" value="SPRING" x-model="semester"/> | ||||||
|  |                     <label for="radioSPRING"><i class="fa fa-leaf"></i></label> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 			<input type="text" name="json" hidden> |  | ||||||
| 		</div> | 		</div> | ||||||
| 	</form> | 	</form> | ||||||
| 	<table id="dynamic_view"> | 	<table id="dynamic_view"> | ||||||
| @@ -62,185 +89,84 @@ | |||||||
| 				<td>{% trans %}Credit type{% endtrans %}</td> | 				<td>{% trans %}Credit type{% endtrans %}</td> | ||||||
| 				<td><i class="fa fa-leaf"></i></td> | 				<td><i class="fa fa-leaf"></i></td> | ||||||
| 				<td><i class="fa fa-sun-o"></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 %}Edit{% endtrans %}</td> | ||||||
| 				<td>{% trans %}Delete{% endtrans %}</td> |                     <td>{% trans %}Delete{% endtrans %}</td> | ||||||
| 				{% endif %} | 				{% endif %} | ||||||
| 			</tr> | 			</tr> | ||||||
| 		</thead> | 		</thead> | ||||||
| 		<tbody id="dynamic_view_content"> | 		<tbody id="dynamic_view_content"> | ||||||
| 			{% for uv in object_list %} |             <template x-for="uv in uvs" :key="uv.id"> | ||||||
| 			<tr onclick="window.location.href = `{{ url('pedagogy:uv_detail', uv_id=uv.id) }}`"> |                 <tr @click="window.location.href = `/pedagogy/uv/${uv.id}`"> | ||||||
| 				<td><a href="{{ url('pedagogy:uv_detail', uv_id=uv.id) }}">{{ uv.code }}</a></td> |                     <td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td> | ||||||
| 				<td>{{ uv.title }}</td> |                     <td x-text="uv.title"></td> | ||||||
| 				<td>{{ uv.department }}</td> |                     <td x-text="uv.department"></td> | ||||||
| 				<td>{{ uv.credit_type }}</td> |                     <td x-text="uv.credit_type"></td> | ||||||
| 				<td> |                     <td><i :class="[uv.semester].includes('AUTUMN') && 'fa fa-leaf'"></i></td> | ||||||
| 					{% if uv.semester in ["AUTUMN", "AUTUMN_AND_SPRING"] %} |                     <td><i :class="[uv.semester].includes('SPRING') && 'fa fa-sun-o'"></i></td> | ||||||
| 						<i class="fa fa-leaf"></i> |                     {% if can_create_uv -%} | ||||||
| 					{% endif %} |                         <td><a :href="`/pedagogy/uv/${uv.id}/update`">{% trans %}Edit{% endtrans %}</a></td> | ||||||
| 				</td> |                         <td><a :href="`/pedagogy/uv/${uv.id}/delete`">{% trans %}Delete{% endtrans %}</a></td> | ||||||
| 				<td> |                     {%- endif -%} | ||||||
| 					{% if uv.semester in ["SPRING", "AUTUMN_AND_SPRING"] %} |                 </tr> | ||||||
| 						<i class="fa fa-sun-o"></i> |             </template> | ||||||
| 					{% 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> |  | ||||||
| 				{%- endif -%} |  | ||||||
| 			</tr> |  | ||||||
| 			{% endfor %} |  | ||||||
| 		</tbody> | 		</tbody> | ||||||
| 	</table> | 	</table> | ||||||
| </div> | </div> | ||||||
| <script> | <script> | ||||||
| 	function autofillCheckboxRadio(name){ |     const initialUrlParams = new URLSearchParams(window.location.search); | ||||||
| 			if (urlParams.has(name)){ $("input[name='" + name + "']").each(function(){ |  | ||||||
| 				if ($(this).attr("value") == urlParams.get(name)) |  | ||||||
| 					$(this).prop("checked", true); |  | ||||||
| 			}); |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	function uvJSONToHTML(uv){ |     function update_query_string(key, value) { | ||||||
| 		var autumn = ""; |         const url = new URL(window.location.href); | ||||||
| 		var spring = ""; |         console.log(value) | ||||||
| 		if (uv.semester == "AUTUMN" || uv.semester == "AUTUMN_AND_SPRING") |         console.log(!!value) | ||||||
| 			autumn = "<i class='fa fa-leaf'></i>"; |         if (!value) { | ||||||
| 		if (uv.semester == "SPRING" || uv.semester == "AUTUMN_AND_SPRING") |             url.searchParams.delete(key) | ||||||
| 			spring = "<i class='fa fa-sun-o'></i>"; |         } else if (Array.isArray(value)) { | ||||||
|  |             url.searchParams.delete(key) | ||||||
| 		var html = ` |             value.forEach((v) => url.searchParams.append(key, v)) | ||||||
| 			<tr onclick="window.location.href = '${uv.absolute_url}';"> |         } else { | ||||||
| 				<td><a href="${uv.absolute_url}">${uv.code}</a></td> |             url.searchParams.set(key, value); | ||||||
| 				<td>${uv.title}</td> |         } | ||||||
| 				<td>${uv.department}</td> |         history.pushState(null, document.title, url.toString()); | ||||||
| 				<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) { |     How does this work : | ||||||
| 		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 |     The page contains two main elements : the form and the results. | ||||||
| 		var xhr = new XMLHttpRequest(); |     The form contains multiple inputs, allowing the user to apply the filter of its choice. | ||||||
| 		$.ajax({ |     Each modification of those filters will modify the GET parameters of the URL, | ||||||
| 			type: "GET", |     then fetch the corresponding data from the API. | ||||||
| 			url: "{{ url('pedagogy:guide') }}", |     This data will then be displayed on the result part of the page. | ||||||
| 			data: $(this).serialize(), |     #} | ||||||
| 			tryCount: 0, |     document.addEventListener("alpine:init", () => { | ||||||
| 			retryLimit: 10, |         Alpine.data("uv_search", () => ({ | ||||||
| 			xhr: function(){ |             uvs: [], | ||||||
| 				 return xhr; |             search: initialUrlParams.get("search") || "", | ||||||
| 			}, |             department: initialUrlParams.getAll("department"), | ||||||
| 			success: function(data){ |             credit_type: initialUrlParams.getAll("credit_type"), | ||||||
| 				// Update URL |             {# The semester is easier to use on the backend as an enum (spring/autumn/both/none) | ||||||
| 				history.pushState({}, null, xhr.responseURL.replace("&json=true", "")); |             and easier to use on the frontend as an array ([spring, autumn]). | ||||||
| 				// Update content |             Thus there is some conversion involved when both communicate together #} | ||||||
| 				$("#dynamic_view_content").html(""); |             semester: initialUrlParams.has("semester") ? | ||||||
| 				for (key in data){ |                 initialUrlParams.get("semester").split("_AND_") : [], | ||||||
| 					$("#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 |             async init() { | ||||||
| 		if (autumn_and_spring.prop("checked")){ |                 ["search", "department", "credit_type", "semester"].forEach((param) => { | ||||||
| 			autumn_and_spring.prop("checked", false); |                     this.$watch(param, async (value) => { | ||||||
| 			autumn.prop("checked", true); |                         update_query_string(param, value); | ||||||
| 			spring.prop("checked", true); |                         await this.fetch_data();  {# reload data on form change #} | ||||||
| 		} |                     }); | ||||||
|     }); |                 }) | ||||||
|  |                 await this.fetch_data();  {# load initial data #} | ||||||
|  |             }, | ||||||
|  |  | ||||||
|     // Auto send on change |             async fetch_data() { | ||||||
|     $("#search_form").on("change", function(e){ |                 const url = "{{ url("api:fetch_uvs") }}" + window.location.search; | ||||||
|     	$(this).submit(); |                 this.uvs = await (await fetch(url)).json(); | ||||||
|     }); |             } | ||||||
|  |         })) | ||||||
|  |     }) | ||||||
| </script> | </script> | ||||||
| {% endblock content %} | {% endblock content %} | ||||||
| @@ -51,29 +51,31 @@ | |||||||
|             if (today.getMonth() < 7) {  // student year starts in september |             if (today.getMonth() < 7) {  // student year starts in september | ||||||
|                 year-- |                 year-- | ||||||
|             } |             } | ||||||
|             const url = "{{ url('api:uv_endpoint') }}?year=" + year + "&code=" + codeInput.value |             const url = `/api/uv/${year}/${codeInput.value}`; | ||||||
|             deleteQuickNotifs() |             deleteQuickNotifs() | ||||||
|  |  | ||||||
|             $.ajax({ |             $.ajax({ | ||||||
|                 dataType: "json", |                 dataType: "json", | ||||||
|                 url: url, |                 url: url, | ||||||
|                 success: function(data, _, xhr) { |                 success: function(data, _, xhr) { | ||||||
|                     if (xhr.status != 200) { |                     if (xhr.status !== 200) { | ||||||
|                         createQuickNotif("{% trans %}Unknown UE code{% endtrans %}") |                         createQuickNotif("{% trans %}Unknown UE code{% endtrans %}") | ||||||
|                         return |                         return | ||||||
|                     } |                     } | ||||||
|                     for (let key in data) { |                     Object.entries(data) | ||||||
|                         if (data.hasOwnProperty(key)) { |                         .filter(([_, val]) => !!val)  // skip entries with null or undefined value | ||||||
|                             const el = document.querySelector('[name="' + key + '"]') |                         .map(([key, val]) => {  // convert keys to DOM elements | ||||||
|                             if (el.tagName == 'TEXTAREA') { |                             return [document.querySelector('[name="' + key + '"]'), val]; | ||||||
|                                 el.parentNode.querySelector('.CodeMirror').CodeMirror.setValue(data[key]) |                         }) | ||||||
|  |                         .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 { |                             } else { | ||||||
|                                 el.value = data[key] |                                 elem.value = val; | ||||||
|                             } |                             } | ||||||
|  |                         }); | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     createQuickNotif('{% trans %}Successful autocomplete{% endtrans %}') |                     createQuickNotif('{% trans %}Successful autocomplete{% endtrans %}') | ||||||
|                 }, |                 }, | ||||||
|                 error: function(_, _, statusMessage) { |                 error: function(_, _, statusMessage) { | ||||||
|   | |||||||
| @@ -20,10 +20,11 @@ | |||||||
| # Place - Suite 330, Boston, MA 02111-1307, USA. | # Place - Suite 330, Boston, MA 02111-1307, USA. | ||||||
| # | # | ||||||
| # | # | ||||||
|  | import json | ||||||
|  |  | ||||||
|  | import pytest | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.core.management import call_command | from django.test import Client, TestCase | ||||||
| from django.test import TestCase |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
|  |  | ||||||
| @@ -141,41 +142,27 @@ class UVCreation(TestCase): | |||||||
|         assert not UV.objects.filter(code="IFC1").exists() |         assert not UV.objects.filter(code="IFC1").exists() | ||||||
|  |  | ||||||
|  |  | ||||||
| class UVListTest(TestCase): | @pytest.mark.django_db | ||||||
|     """Test guide display rights.""" | @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): | @pytest.mark.django_db | ||||||
|         # Display for root | def test_guide_anonymous_permission_denied(client: Client): | ||||||
|         self.client.force_login(self.bibou) |     res = client.get(reverse("pedagogy:guide")) | ||||||
|         response = self.client.get(reverse("pedagogy:guide")) |     assert res.status_code == 302 | ||||||
|         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 |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UVDeleteTest(TestCase): | class UVDeleteTest(TestCase): | ||||||
| @@ -577,141 +564,111 @@ class UVSearchTest(TestCase): | |||||||
|         cls.tutu = User.objects.get(username="tutu") |         cls.tutu = User.objects.get(username="tutu") | ||||||
|         cls.sli = User.objects.get(username="sli") |         cls.sli = User.objects.get(username="sli") | ||||||
|         cls.guy = User.objects.get(username="guy") |         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): |     def fetch_uvs(self, **kwargs): | ||||||
|         call_command("update_index", "pedagogy") |         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): |     def test_permissions(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): |  | ||||||
|         # Test with anonymous user |         # Test with anonymous user | ||||||
|         response = self.client.get(reverse("pedagogy:guide")) |         response = self.client.get(self.url) | ||||||
|         assert response.status_code == 403 |         assert response.status_code == 403 | ||||||
|  |  | ||||||
|         # Test with not subscribed user |         # Test with not subscribed user | ||||||
|         self.client.force_login(self.guy) |         self.client.force_login(self.guy) | ||||||
|         response = self.client.get(reverse("pedagogy:guide")) |         response = self.client.get(self.url) | ||||||
|         assert response.status_code == 403 |         assert response.status_code == 403 | ||||||
|  |  | ||||||
|     def test_search_pa00_success(self): |         for user in self.bibou, self.tutu, self.sli: | ||||||
|         self.client.force_login(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 |     def test_format(self): | ||||||
|         response = self.client.get(reverse("pedagogy:guide"), {"search": "PA00"}) |         """Test that the return data format is correct""" | ||||||
|         self.assertContains(response, text="PA00") |         self.client.force_login(self.bibou) | ||||||
|  |         res = self.client.get(self.url + "?search=PA00") | ||||||
|         # Search with first letter of UV code |         uv = UV.objects.get(code="PA00") | ||||||
|         response = self.client.get(reverse("pedagogy:guide"), {"search": "P"}) |         assert res.status_code == 200 | ||||||
|         self.assertContains(response, text="PA00") |         assert json.loads(res.content) == [ | ||||||
|  |  | ||||||
|         # 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"), |  | ||||||
|             { |             { | ||||||
|                 "search": "P", |                 "id": uv.id, | ||||||
|                 "department": "HUMA", |                 "title": uv.title, | ||||||
|                 "semester": "AUTUMN", |                 "code": uv.code, | ||||||
|                 "language": "FR", |                 "credit_type": uv.credit_type, | ||||||
|                 "credit_type": "OM", |                 "semester": uv.semester, | ||||||
|             }, |                 "department": uv.department, | ||||||
|         ) |             } | ||||||
|         self.assertContains(response, text="PA00") |         ] | ||||||
|  |  | ||||||
|         # Test json briefly |     def test_search_by_code(self): | ||||||
|         response = self.client.get( |         self.client.force_login(self.bibou) | ||||||
|             reverse("pedagogy:guide"), |         res = self.client.get(self.url + "?search=MT") | ||||||
|             { |         assert res.status_code == 200 | ||||||
|                 "json": "t", |         assert {uv["code"] for uv in json.loads(res.content)} == {"MT01", "MT10"} | ||||||
|                 "search": "P", |  | ||||||
|                 "department": "HUMA", |     def test_search_by_credit_type(self): | ||||||
|                 "semester": "AUTUMN", |         self.client.force_login(self.bibou) | ||||||
|                 "language": "FR", |         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"] | ||||||
|         self.assertJSONEqual( |         res = self.client.get(self.url + "?credit_type=CS&credit_type=OM") | ||||||
|             response.content, |         assert res.status_code == 200 | ||||||
|             [ |         codes = {uv["code"] for uv in json.loads(res.content)} | ||||||
|                 { |         assert codes == {"AP4A", "MT01", "PHYS11", "PA00"} | ||||||
|                     "id": 1, |  | ||||||
|                     "absolute_url": "/pedagogy/uv/1/", |     def test_search_by_semester(self): | ||||||
|                     "update_url": "/pedagogy/uv/1/edit/", |         self.client.force_login(self.bibou) | ||||||
|                     "delete_url": "/pedagogy/uv/1/delete/", |         res = self.client.get(self.url + "?semester=SPRING") | ||||||
|                     "code": "PA00", |         assert res.status_code == 200 | ||||||
|                     "author": 0, |         codes = {uv["code"] for uv in json.loads(res.content)} | ||||||
|                     "credit_type": "OM", |         assert codes == {"DA50", "TNEV", "PA00"} | ||||||
|                     "semester": "AUTUMN_AND_SPRING", |  | ||||||
|                     "language": "FR", |     def test_search_multiple_filters(self): | ||||||
|                     "credits": 5, |         self.client.force_login(self.bibou) | ||||||
|                     "department": "HUMA", |         res = self.client.get( | ||||||
|                     "title": "Participation dans une association \u00e9tudiante", |             self.url + "?semester=AUTUMN&credit_type=CS&department=TC" | ||||||
|                     "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, |  | ||||||
|                 } |  | ||||||
|             ], |  | ||||||
|         ) |         ) | ||||||
|  |         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): |     def test_search_pa00_fail(self): | ||||||
|  |         self.client.force_login(self.bibou) | ||||||
|         # Search with UV code |         # Search with UV code | ||||||
|         response = self.client.get(reverse("pedagogy:guide"), {"search": "IFC"}) |         response = self.client.get(reverse("pedagogy:guide"), {"search": "IFC"}) | ||||||
|         self.assertNotContains(response, text="PA00") |         self.assertNotContains(response, text="PA00") | ||||||
|   | |||||||
| @@ -27,7 +27,7 @@ from pedagogy.views import * | |||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     # Urls displaying the actual application for visitors |     # 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("uv/<int:uv_id>/", UVDetailFormView.as_view(), name="uv_detail"), | ||||||
|     path( |     path( | ||||||
|         "comment/<int:comment_id>/edit/", |         "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.conf import settings | ||||||
|  | from django.contrib.auth.mixins import LoginRequiredMixin | ||||||
| from django.core.exceptions import ObjectDoesNotExist, PermissionDenied | from django.core.exceptions import ObjectDoesNotExist, PermissionDenied | ||||||
| from django.http import HttpResponse |  | ||||||
| from django.shortcuts import get_object_or_404 | from django.shortcuts import get_object_or_404 | ||||||
| from django.urls import reverse, reverse_lazy | from django.urls import reverse, reverse_lazy | ||||||
| from django.utils import html |  | ||||||
| from django.views.generic import ( | from django.views.generic import ( | ||||||
|     CreateView, |     CreateView, | ||||||
|     DeleteView, |     DeleteView, | ||||||
|     FormView, |     FormView, | ||||||
|     ListView, |     TemplateView, | ||||||
|     UpdateView, |     UpdateView, | ||||||
|     View, |  | ||||||
| ) | ) | ||||||
| from haystack.query import SearchQuerySet |  | ||||||
| from rest_framework.renderers import JSONRenderer |  | ||||||
|  |  | ||||||
| from core.models import Notification, RealGroup | from core.models import Notification, RealGroup | ||||||
| from core.views import ( | from core.views import ( | ||||||
| @@ -44,6 +40,7 @@ from core.views import ( | |||||||
|     CanEditPropMixin, |     CanEditPropMixin, | ||||||
|     CanViewMixin, |     CanViewMixin, | ||||||
|     DetailFormView, |     DetailFormView, | ||||||
|  |     FormerSubscriberMixin, | ||||||
| ) | ) | ||||||
| from pedagogy.forms import ( | from pedagogy.forms import ( | ||||||
|     UVCommentForm, |     UVCommentForm, | ||||||
| @@ -51,30 +48,12 @@ from pedagogy.forms import ( | |||||||
|     UVCommentReportForm, |     UVCommentReportForm, | ||||||
|     UVForm, |     UVForm, | ||||||
| ) | ) | ||||||
| from pedagogy.models import UV, UVComment, UVCommentReport, UVSerializer | from pedagogy.models import UV, UVComment, UVCommentReport | ||||||
|  |  | ||||||
| # 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 |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # Acutal views | # Acutal views | ||||||
|  |  | ||||||
|  |  | ||||||
| class UVDetailFormView(CanViewMixin, CanCreateUVFunctionMixin, DetailFormView): | class UVDetailFormView(CanViewMixin, DetailFormView): | ||||||
|     """Display every comment of an UV and detailed infos about it. |     """Display every comment of an UV and detailed infos about it. | ||||||
|  |  | ||||||
|     Allow to comment the UV. |     Allow to comment the UV. | ||||||
| @@ -101,6 +80,15 @@ class UVDetailFormView(CanViewMixin, CanCreateUVFunctionMixin, DetailFormView): | |||||||
|             "pedagogy:uv_detail", kwargs={"uv_id": self.get_object().id} |             "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): | class UVCommentUpdateView(CanEditPropMixin, UpdateView): | ||||||
|     """Allow edit of a given comment.""" |     """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}) |         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.""" |     """UV guide main page.""" | ||||||
|  |  | ||||||
|     # This is very basic and is prone to changment |  | ||||||
|  |  | ||||||
|     model = UV |  | ||||||
|     ordering = ["code"] |  | ||||||
|     template_name = "pedagogy/guide.jinja" |     template_name = "pedagogy/guide.jinja" | ||||||
|  |  | ||||||
|     def get(self, *args, **kwargs): |     def get_context_data(self, **kwargs): | ||||||
|         if not self.request.GET.get("json", None): |         user = self.request.user | ||||||
|             # Return normal full template response |         return super().get_context_data(**kwargs) | { | ||||||
|             return super().get(*args, **kwargs) |             "can_create_uv": ( | ||||||
|  |                 user.is_root | ||||||
|         # Return serialized response |                 or user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID) | ||||||
|         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]) |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UVCommentReportCreateView(CanCreateMixin, CreateView): | 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"}, |     {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]] | [[package]] | ||||||
| name = "asgiref" | name = "asgiref" | ||||||
| version = "3.8.1" | version = "3.8.1" | ||||||
| @@ -303,6 +314,17 @@ files = [ | |||||||
|     {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, |     {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]] | [[package]] | ||||||
| name = "coverage" | name = "coverage" | ||||||
| version = "7.6.0" | version = "7.6.0" | ||||||
| @@ -573,6 +595,44 @@ files = [ | |||||||
| django = ">=3.2" | django = ">=3.2" | ||||||
| jinja2 = ">=3" | 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]] | [[package]] | ||||||
| name = "django-ordered-model" | name = "django-ordered-model" | ||||||
| version = "3.7.4" | version = "3.7.4" | ||||||
| @@ -634,20 +694,6 @@ Pillow = ">=6.2.0" | |||||||
| [package.extras] | [package.extras] | ||||||
| test = ["testfixtures"] | 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]] | [[package]] | ||||||
| name = "docutils" | name = "docutils" | ||||||
| version = "0.19" | version = "0.19" | ||||||
| @@ -796,6 +842,20 @@ files = [ | |||||||
|     {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, |     {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]] | [[package]] | ||||||
| name = "ipython" | name = "ipython" | ||||||
| version = "8.26.0" | version = "8.26.0" | ||||||
| @@ -1525,6 +1585,129 @@ files = [ | |||||||
|     {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, |     {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]] | [[package]] | ||||||
| name = "pygments" | name = "pygments" | ||||||
| version = "2.18.0" | version = "2.18.0" | ||||||
| @@ -2271,4 +2454,4 @@ filelock = ">=3.4" | |||||||
| [metadata] | [metadata] | ||||||
| lock-version = "2.0" | lock-version = "2.0" | ||||||
| python-versions = "^3.10" | python-versions = "^3.10" | ||||||
| content-hash = "9038b84fac4dc7ce5aea0520d29e4d5705e2e55f3e165d2455ebc61eafe6cfe0" | content-hash = "ee0b881719f6834880266d72272429708e781b3ccd34a0fbf3e8b4119dcb95fd" | ||||||
|   | |||||||
| @@ -22,11 +22,12 @@ license = "GPL-3.0-only" | |||||||
| [tool.poetry.dependencies] | [tool.poetry.dependencies] | ||||||
| python = "^3.10" | python = "^3.10" | ||||||
| Django = "^4.2.14" | Django = "^4.2.14" | ||||||
|  | django-ninja = "^1.2.0" | ||||||
|  | django-ninja-extra = "^0.21.0" | ||||||
| Pillow = "^10.4.0" | Pillow = "^10.4.0" | ||||||
| mistune = "^3.0.2" | mistune = "^3.0.2" | ||||||
| django-jinja = "^2.11" | django-jinja = "^2.11" | ||||||
| cryptography = "^42.0.8" | cryptography = "^42.0.8" | ||||||
| djangorestframework = "^3.13" |  | ||||||
| django-phonenumber-field = "^6.3" | django-phonenumber-field = "^6.3" | ||||||
| phonenumbers = "^8.12" | phonenumbers = "^8.12" | ||||||
| django-ajax-selects = "^2.1.0" | 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", |     "django.contrib.sites", | ||||||
|     "honeypot", |     "honeypot", | ||||||
|     "django_jinja", |     "django_jinja", | ||||||
|     "rest_framework", |     "ninja_extra", | ||||||
|     "ajax_select", |     "ajax_select", | ||||||
|     "haystack", |     "haystack", | ||||||
|     "captcha", |     "captcha", | ||||||
| @@ -89,7 +89,6 @@ INSTALLED_APPS = ( | |||||||
|     "counter", |     "counter", | ||||||
|     "eboutic", |     "eboutic", | ||||||
|     "launderette", |     "launderette", | ||||||
|     "api", |  | ||||||
|     "rootplace", |     "rootplace", | ||||||
|     "sas", |     "sas", | ||||||
|     "com", |     "com", | ||||||
| @@ -473,8 +472,8 @@ SITH_PEDAGOGY_UV_RESULT_GRADE = [ | |||||||
| ] | ] | ||||||
|  |  | ||||||
| SITH_LOG_OPERATION_TYPE = [ | SITH_LOG_OPERATION_TYPE = [ | ||||||
|     (("SELLING_DELETION"), _("Selling deletion")), |     ("SELLING_DELETION", _("Selling deletion")), | ||||||
|     (("REFILLING_DELETION"), _("Refilling deletion")), |     ("REFILLING_DELETION", _("Refilling deletion")), | ||||||
| ] | ] | ||||||
|  |  | ||||||
| SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide" | 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.contrib import admin | ||||||
| from django.urls import include, path | from django.urls import include, path | ||||||
| from django.views.i18n import JavaScriptCatalog | from django.views.i18n import JavaScriptCatalog | ||||||
|  | from ninja_extra import NinjaExtraAPI | ||||||
|  |  | ||||||
| js_info_dict = {"packages": ("sith",)} | js_info_dict = {"packages": ("sith",)} | ||||||
|  |  | ||||||
| @@ -26,8 +27,12 @@ handler403 = "core.views.forbidden" | |||||||
| handler404 = "core.views.not_found" | handler404 = "core.views.not_found" | ||||||
| handler500 = "core.views.internal_servor_error" | handler500 = "core.views.internal_servor_error" | ||||||
|  |  | ||||||
|  | api = NinjaExtraAPI(version="0.2.0", urls_namespace="api") | ||||||
|  | api.auto_discover_controllers() | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     path("", include(("core.urls", "core"), namespace="core")), |     path("", include(("core.urls", "core"), namespace="core")), | ||||||
|  |     path("api/", api.urls), | ||||||
|     path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")), |     path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")), | ||||||
|     path( |     path( | ||||||
|         "subscription/", |         "subscription/", | ||||||
| @@ -47,7 +52,6 @@ urlpatterns = [ | |||||||
|         include(("launderette.urls", "launderette"), namespace="launderette"), |         include(("launderette.urls", "launderette"), namespace="launderette"), | ||||||
|     ), |     ), | ||||||
|     path("sas/", include(("sas.urls", "sas"), namespace="sas")), |     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("election/", include(("election.urls", "election"), namespace="election")), | ||||||
|     path("forum/", include(("forum.urls", "forum"), namespace="forum")), |     path("forum/", include(("forum.urls", "forum"), namespace="forum")), | ||||||
|     path("galaxy/", include(("galaxy.urls", "galaxy"), namespace="galaxy")), |     path("galaxy/", include(("galaxy.urls", "galaxy"), namespace="galaxy")), | ||||||
| @@ -61,7 +65,6 @@ urlpatterns = [ | |||||||
|     path("captcha/", include("captcha.urls")), |     path("captcha/", include("captcha.urls")), | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  |  | ||||||
| if settings.DEBUG: | if settings.DEBUG: | ||||||
|     urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) |     urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) | ||||||
|     urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_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), 1, date(2021, 3, 18)), | ||||||
|         (date(2020, 9, 18), 2, date(2021, 9, 18)), |         (date(2020, 9, 18), 2, date(2021, 9, 18)), | ||||||
|         (date(2020, 9, 18), 3, date(2022, 2, 15)), |         (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.33, date(2020, 11, 18)), | ||||||
|         (date(2020, 9, 18), 0.67, date(2021, 1, 19)), |         (date(2020, 9, 18), 0.67, date(2021, 1, 19)), | ||||||
|         (date(2020, 9, 18), 0.5, date(2020, 12, 18)), |         (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)), |         (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 |     assert Subscription.compute_end(duration, start_date) == expected_end | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -46,7 +46,6 @@ from core.views import ( | |||||||
|     CanViewMixin, |     CanViewMixin, | ||||||
|     QuickNotifMixin, |     QuickNotifMixin, | ||||||
|     TabedViewMixin, |     TabedViewMixin, | ||||||
|     UserIsLoggedMixin, |  | ||||||
| ) | ) | ||||||
| from core.views.forms import SelectDate | from core.views.forms import SelectDate | ||||||
| from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser | from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser | ||||||
| @@ -296,7 +295,7 @@ class UserTrombiForm(forms.Form): | |||||||
|  |  | ||||||
|  |  | ||||||
| class UserTrombiToolsView( | class UserTrombiToolsView( | ||||||
|     QuickNotifMixin, TrombiTabsMixin, UserIsLoggedMixin, TemplateView |     LoginRequiredMixin, QuickNotifMixin, TrombiTabsMixin, TemplateView | ||||||
| ): | ): | ||||||
|     """Display a user's trombi tools.""" |     """Display a user's trombi tools.""" | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user