From 3046438cb1de9bd79bb042843391aa6c3486a5c6 Mon Sep 17 00:00:00 2001 From: thomas girod Date: Thu, 18 Jul 2024 20:23:30 +0200 Subject: [PATCH 1/6] replace drf by django-ninja --- api/__init__.py | 14 - api/admin.py | 16 -- api/models.py | 16 -- api/tests.py | 16 -- api/urls.py | 49 ---- api/views/__init__.py | 70 ----- api/views/api.py | 31 --- api/views/club.py | 51 ---- api/views/counter.py | 46 ---- api/views/group.py | 31 --- api/views/launderette.py | 112 -------- api/views/sas.py | 43 ---- api/views/user.py | 56 ---- api/views/uv.py | 126 --------- club/models.py | 6 +- core/api.py | 29 +++ core/api_permissions.py | 96 +++++++ core/schemas.py | 15 ++ core/templates/core/user_pictures.jinja | 4 +- core/tests.py | 3 +- core/views/__init__.py | 15 +- core/views/forms.py | 2 +- core/views/user.py | 12 +- counter/api.py | 37 +++ counter/schemas.py | 13 + forum/views.py | 4 +- pedagogy/api.py | 36 +++ pedagogy/models.py | 28 -- pedagogy/schemas.py | 132 ++++++++++ pedagogy/templates/pedagogy/guide.jinja | 300 ++++++++-------------- pedagogy/templates/pedagogy/uv_edit.jinja | 26 +- pedagogy/tests.py | 267 ++++++++----------- pedagogy/urls.py | 2 +- pedagogy/utbm_api.py | 81 ++++++ pedagogy/views.py | 102 ++------ poetry.lock | 213 +++++++++++++-- pyproject.toml | 3 +- sas/api.py | 43 ++++ sas/schemas.py | 25 ++ sith/settings.py | 7 +- sith/urls.py | 7 +- subscription/tests.py | 4 +- trombi/views.py | 3 +- 43 files changed, 1001 insertions(+), 1191 deletions(-) delete mode 100644 api/__init__.py delete mode 100644 api/admin.py delete mode 100644 api/models.py delete mode 100644 api/tests.py delete mode 100644 api/urls.py delete mode 100644 api/views/__init__.py delete mode 100644 api/views/api.py delete mode 100644 api/views/club.py delete mode 100644 api/views/counter.py delete mode 100644 api/views/group.py delete mode 100644 api/views/launderette.py delete mode 100644 api/views/sas.py delete mode 100644 api/views/user.py delete mode 100644 api/views/uv.py create mode 100644 core/api.py create mode 100644 core/api_permissions.py create mode 100644 core/schemas.py create mode 100644 counter/api.py create mode 100644 counter/schemas.py create mode 100644 pedagogy/api.py create mode 100644 pedagogy/schemas.py create mode 100644 pedagogy/utbm_api.py create mode 100644 sas/api.py create mode 100644 sas/schemas.py diff --git a/api/__init__.py b/api/__init__.py deleted file mode 100644 index a098e7ba..00000000 --- a/api/__init__.py +++ /dev/null @@ -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" -# -# diff --git a/api/admin.py b/api/admin.py deleted file mode 100644 index 1a02ff3a..00000000 --- a/api/admin.py +++ /dev/null @@ -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. diff --git a/api/models.py b/api/models.py deleted file mode 100644 index c6372d7f..00000000 --- a/api/models.py +++ /dev/null @@ -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. diff --git a/api/tests.py b/api/tests.py deleted file mode 100644 index 48d8f1f6..00000000 --- a/api/tests.py +++ /dev/null @@ -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. diff --git a/api/urls.py b/api/urls.py deleted file mode 100644 index 15bc6839..00000000 --- a/api/urls.py +++ /dev/null @@ -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/", all_pictures_of_user_endpoint, name="all_pictures_of_user"), -] diff --git a/api/views/__init__.py b/api/views/__init__.py deleted file mode 100644 index 5017806b..00000000 --- a/api/views/__init__.py +++ /dev/null @@ -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 * diff --git a/api/views/api.py b/api/views/api.py deleted file mode 100644 index 9de0a87e..00000000 --- a/api/views/api.py +++ /dev/null @@ -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) diff --git a/api/views/club.py b/api/views/club.py deleted file mode 100644 index 6c4c5b0b..00000000 --- a/api/views/club.py +++ /dev/null @@ -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) diff --git a/api/views/counter.py b/api/views/counter.py deleted file mode 100644 index 3fbaa931..00000000 --- a/api/views/counter.py +++ /dev/null @@ -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) diff --git a/api/views/group.py b/api/views/group.py deleted file mode 100644 index da37dbbc..00000000 --- a/api/views/group.py +++ /dev/null @@ -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() diff --git a/api/views/launderette.py b/api/views/launderette.py deleted file mode 100644 index cb88d80c..00000000 --- a/api/views/launderette.py +++ /dev/null @@ -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) diff --git a/api/views/sas.py b/api/views/sas.py deleted file mode 100644 index 455edf09..00000000 --- a/api/views/sas.py +++ /dev/null @@ -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) - ] - ) diff --git a/api/views/user.py b/api/views/user.py deleted file mode 100644 index 84078ce2..00000000 --- a/api/views/user.py +++ /dev/null @@ -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) diff --git a/api/views/uv.py b/api/views/uv.py deleted file mode 100644 index 09292d8e..00000000 --- a/api/views/uv.py +++ /dev/null @@ -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 diff --git a/club/models.py b/club/models.py index 0ed51389..6baba7bd 100644 --- a/club/models.py +++ b/club/models.py @@ -486,10 +486,8 @@ class Mailing(models.Model): super().delete() def fetch_format(self): - resp = self.email + ": " - for sub in self.subscriptions.all(): - resp += sub.fetch_format() - return resp + destination = "".join(s.fetch_format() for s in self.subscriptions.all()) + return f"{self.email}: {destination}" class MailingSubscription(models.Model): diff --git a/core/api.py b/core/api.py new file mode 100644 index 00000000..2f203680 --- /dev/null +++ b/core/api.py @@ -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 diff --git a/core/api_permissions.py b/core/api_permissions.py new file mode 100644 index 00000000..2213d164 --- /dev/null +++ b/core/api_permissions.py @@ -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) diff --git a/core/schemas.py b/core/schemas.py new file mode 100644 index 00000000..6ee967d8 --- /dev/null +++ b/core/schemas.py @@ -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 diff --git a/core/templates/core/user_pictures.jinja b/core/templates/core/user_pictures.jinja index 471aecda..de3f2af0 100644 --- a/core/templates/core/user_pictures.jinja +++ b/core/templates/core/user_pictures.jinja @@ -84,10 +84,10 @@ } function download_pictures() { $("#download_all_pictures").prop("disabled", true); - var xhr = new XMLHttpRequest(); + const xhr = new XMLHttpRequest(); $.ajax({ type: "GET", - url: "{{ url('api:all_pictures_of_user', user=object.id) }}", + url: "{{ url('api:pictures') }}?users_identified={{ object.id }}", tryCount: 0, xhr: function(){ return xhr; diff --git a/core/tests.py b/core/tests.py index 91f3be96..07259946 100644 --- a/core/tests.py +++ b/core/tests.py @@ -65,7 +65,7 @@ class TestUserRegistration: {"password2": "not the same as password1"}, "Les deux mots de passe ne correspondent pas.", ), - ({"email": "not-an-email"}, "Saisissez une adresse e-mail valide."), + ({"email": "not-an-email"}, "Saisissez une adresse de courriel valide."), ({"first_name": ""}, "Ce champ est obligatoire."), ({"last_name": ""}, "Ce champ est obligatoire."), ({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"), @@ -310,7 +310,6 @@ http://git.an ) response = self.client.get(reverse("core:page", kwargs={"page_name": "guy"})) assert response.status_code == 200 - print(response.content.decode()) expected = """

Guy bibou

http://git.an

diff --git a/core/views/__init__.py b/core/views/__init__.py index 7439f037..cacaf68f 100644 --- a/core/views/__init__.py +++ b/core/views/__init__.py @@ -25,6 +25,7 @@ import types from typing import Any +from django.contrib.auth.mixins import AccessMixin from django.core.exceptions import ( ImproperlyConfigured, PermissionDenied, @@ -234,7 +235,7 @@ class UserIsRootMixin(GenericContentPermissionMixinBuilder): permission_function = lambda obj, user: user.is_root -class FormerSubscriberMixin(View): +class FormerSubscriberMixin(AccessMixin): """Check if the user was at least an old subscriber. Raises: @@ -247,16 +248,10 @@ class FormerSubscriberMixin(View): return super().dispatch(request, *args, **kwargs) -class UserIsLoggedMixin(View): - """Check if the user is logged. - - Raises: - PermissionDenied: - """ - +class SubscriberMixin(AccessMixin): def dispatch(self, request, *args, **kwargs): - if request.user.is_anonymous: - raise PermissionDenied + if not request.user.is_subscribed: + return self.handle_no_permission() return super().dispatch(request, *args, **kwargs) diff --git a/core/views/forms.py b/core/views/forms.py index 140f5158..ff649848 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -104,7 +104,7 @@ class MarkdownInput(Textarea): "fullscreen": _("Toggle fullscreen"), "guide": _("Markdown guide"), } - context["markdown_api_url"] = reverse("api:api_markdown") + context["markdown_api_url"] = reverse("api:markdown") return context diff --git a/core/views/user.py b/core/views/user.py index 66abd014..4dfce360 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -29,6 +29,7 @@ from smtplib import SMTPException from django.conf import settings from django.contrib.auth import login, views from django.contrib.auth.forms import PasswordChangeForm +from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import PermissionDenied, ValidationError from django.forms import CheckboxSelectMultiple from django.forms.models import modelform_factory @@ -50,7 +51,6 @@ from django.views.generic.dates import MonthMixin, YearMixin from django.views.generic.edit import FormView, UpdateView from honeypot.decorators import check_honeypot -from api.views.sas import all_pictures_of_user from core.models import Gift, Preferences, SithFile, User from core.views import ( CanEditMixin, @@ -58,7 +58,6 @@ from core.views import ( CanViewMixin, QuickNotifMixin, TabedViewMixin, - UserIsLoggedMixin, ) from core.views.forms import ( GiftForm, @@ -68,6 +67,7 @@ from core.views.forms import ( UserProfileForm, ) from counter.forms import StudentCardForm +from sas.models import Picture from subscription.models import Subscription from trombi.views import UserTrombiForm @@ -313,7 +313,11 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView): kwargs = super().get_context_data(**kwargs) kwargs["albums"] = [] kwargs["pictures"] = {} - picture_qs = all_pictures_of_user(self.object) + picture_qs = ( + Picture.objects.filter(people__user_id=self.object.id) + .order_by("parent__date", "id") + .all() + ) last_album = None for picture in picture_qs: album = picture.parent @@ -720,7 +724,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView): current_tab = "groups" -class UserToolsView(QuickNotifMixin, UserTabsMixin, UserIsLoggedMixin, TemplateView): +class UserToolsView(LoginRequiredMixin, QuickNotifMixin, UserTabsMixin, TemplateView): """Displays the logged user's tools.""" template_name = "core/user_tools.jinja" diff --git a/counter/api.py b/counter/api.py new file mode 100644 index 00000000..8f42bc85 --- /dev/null +++ b/counter/api.py @@ -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 diff --git a/counter/schemas.py b/counter/schemas.py new file mode 100644 index 00000000..afe2455d --- /dev/null +++ b/counter/schemas.py @@ -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"] diff --git a/forum/views.py b/forum/views.py index 93a19c49..56d1945c 100644 --- a/forum/views.py +++ b/forum/views.py @@ -26,6 +26,7 @@ import math from ajax_select import make_ajax_field from django import forms from django.conf import settings +from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import PermissionDenied from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db import IntegrityError @@ -43,7 +44,6 @@ from core.views import ( CanEditMixin, CanEditPropMixin, CanViewMixin, - UserIsLoggedMixin, can_view, ) from core.views.forms import MarkdownInput @@ -273,7 +273,7 @@ class ForumTopicEditView(CanEditMixin, UpdateView): class ForumTopicSubscribeView( - CanViewMixin, UserIsLoggedMixin, SingleObjectMixin, RedirectView + LoginRequiredMixin, CanViewMixin, SingleObjectMixin, RedirectView ): model = ForumTopic pk_url_kwarg = "topic_id" diff --git a/pedagogy/api.py b/pedagogy/api.py new file mode 100644 index 00000000..e9d24c84 --- /dev/null +++ b/pedagogy/api.py @@ -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] diff --git a/pedagogy/models.py b/pedagogy/models.py index c74bcb3d..92cd16d5 100644 --- a/pedagogy/models.py +++ b/pedagogy/models.py @@ -28,7 +28,6 @@ from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from rest_framework import serializers from core.models import User @@ -327,30 +326,3 @@ class UVCommentReport(models.Model): def is_owned_by(self, user): """Can be created by a pedagogy admin, a superuser or a subscriber.""" return user.is_subscribed or user.is_owner(self.comment.uv) - - -# Custom serializers - - -class UVSerializer(serializers.ModelSerializer): - """Custom seralizer for UVs. - - Allow adding more informations like absolute_url. - """ - - class Meta: - model = UV - fields = "__all__" - - absolute_url = serializers.SerializerMethodField() - update_url = serializers.SerializerMethodField() - delete_url = serializers.SerializerMethodField() - - def get_absolute_url(self, obj): - return obj.get_absolute_url() - - def get_update_url(self, obj): - return reverse("pedagogy:uv_update", kwargs={"uv_id": obj.id}) - - def get_delete_url(self, obj): - return reverse("pedagogy:uv_delete", kwargs={"uv_id": obj.id}) diff --git a/pedagogy/schemas.py b/pedagogy/schemas.py new file mode 100644 index 00000000..5ce120fd --- /dev/null +++ b/pedagogy/schemas.py @@ -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) diff --git a/pedagogy/templates/pedagogy/guide.jinja b/pedagogy/templates/pedagogy/guide.jinja index 1084a9af..7871f2b4 100644 --- a/pedagogy/templates/pedagogy/guide.jinja +++ b/pedagogy/templates/pedagogy/guide.jinja @@ -5,52 +5,79 @@ {% trans %}UV Guide{% endtrans %} {% endblock %} +{% block additional_js %} + +{% endblock %} + {% block head %} {{ super() }} {% endblock head %} {% block content %} -
-
+{% if can_create_uv %} + +
+{% endif %} +
+
- {% if can_create_uv(user) %} - - {% endif %}
- {% 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 [ + ("EDIM", "EDIM"), ("ENERGIE", "EE"), ("IMSI", "IMSI"), + ("INFO", "GI"), ("GMC", "MC"), ("HUMA", "HUMA"), ("TC", "TC") + ] %} + + {% endfor %}
{% for credit_type in ["CS", "TM", "EC", "QC", "OM"] %} - + + {% endfor %}
- - - + + + +
-
@@ -62,185 +89,84 @@ - {% if can_create_uv(user) %} - - + {% if can_create_uv %} + + {% endif %} - {% for uv in object_list %} - - - - - - - - {% if user.is_owner(uv) -%} - - - {%- endif -%} - - {% endfor %} +
{% trans %}Credit type{% endtrans %} {% trans %}Edit{% endtrans %}{% trans %}Delete{% endtrans %}{% trans %}Edit{% endtrans %}{% trans %}Delete{% endtrans %}
{{ uv.code }}{{ uv.title }}{{ uv.department }}{{ uv.credit_type }} - {% if uv.semester in ["AUTUMN", "AUTUMN_AND_SPRING"] %} - - {% endif %} - - {% if uv.semester in ["SPRING", "AUTUMN_AND_SPRING"] %} - - {% endif %} - {% trans %}Edit{% endtrans %}{% trans %}Delete{% endtrans %}
{% endblock content %} \ No newline at end of file diff --git a/pedagogy/templates/pedagogy/uv_edit.jinja b/pedagogy/templates/pedagogy/uv_edit.jinja index b6994a69..7a6b4b02 100644 --- a/pedagogy/templates/pedagogy/uv_edit.jinja +++ b/pedagogy/templates/pedagogy/uv_edit.jinja @@ -51,29 +51,31 @@ if (today.getMonth() < 7) { // student year starts in september year-- } - const url = "{{ url('api:uv_endpoint') }}?year=" + year + "&code=" + codeInput.value + const url = `/api/uv/${year}/${codeInput.value}`; deleteQuickNotifs() $.ajax({ dataType: "json", url: url, success: function(data, _, xhr) { - if (xhr.status != 200) { + if (xhr.status !== 200) { createQuickNotif("{% trans %}Unknown UE code{% endtrans %}") return } - for (let key in data) { - if (data.hasOwnProperty(key)) { - const el = document.querySelector('[name="' + key + '"]') - if (el.tagName == 'TEXTAREA') { - el.parentNode.querySelector('.CodeMirror').CodeMirror.setValue(data[key]) + Object.entries(data) + .filter(([_, val]) => !!val) // skip entries with null or undefined value + .map(([key, val]) => { // convert keys to DOM elements + return [document.querySelector('[name="' + key + '"]'), val]; + }) + .filter(([elem, _]) => !!elem) // skip non-existing DOM elements + .forEach(([elem, val]) => { // write the value in the form field + if (elem.tagName === 'TEXTAREA') { + // MD editor text input + elem.parentNode.querySelector('.CodeMirror').CodeMirror.setValue(val); } else { - el.value = data[key] + elem.value = val; } - - } - } - + }); createQuickNotif('{% trans %}Successful autocomplete{% endtrans %}') }, error: function(_, _, statusMessage) { diff --git a/pedagogy/tests.py b/pedagogy/tests.py index aa761682..b75ca938 100644 --- a/pedagogy/tests.py +++ b/pedagogy/tests.py @@ -20,10 +20,11 @@ # Place - Suite 330, Boston, MA 02111-1307, USA. # # +import json +import pytest from django.conf import settings -from django.core.management import call_command -from django.test import TestCase +from django.test import Client, TestCase from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -141,41 +142,27 @@ class UVCreation(TestCase): assert not UV.objects.filter(code="IFC1").exists() -class UVListTest(TestCase): - """Test guide display rights.""" +@pytest.mark.django_db +@pytest.mark.parametrize( + ("username", "expected_code"), + [ + ("root", 200), + ("tutu", 200), + ("sli", 200), + ("old_subscriber", 200), + ("public", 403), + ], +) +def test_guide_permissions(client: Client, username: str, expected_code: int): + client.force_login(User.objects.get(username=username)) + res = client.get(reverse("pedagogy:guide")) + assert res.status_code == expected_code - @classmethod - def setUpTestData(cls): - cls.bibou = User.objects.get(username="root") - cls.tutu = User.objects.get(username="tutu") - cls.sli = User.objects.get(username="sli") - cls.guy = User.objects.get(username="guy") - def test_uv_list_display_success(self): - # Display for root - self.client.force_login(self.bibou) - response = self.client.get(reverse("pedagogy:guide")) - self.assertContains(response, text="PA00") - - # Display for pedagogy admin - self.client.force_login(self.tutu) - response = self.client.get(reverse("pedagogy:guide")) - self.assertContains(response, text="PA00") - - # Display for simple subscriber - self.client.force_login(self.sli) - response = self.client.get(reverse("pedagogy:guide")) - self.assertContains(response, text="PA00") - - def test_uv_list_display_fail(self): - # Don't display for anonymous user - response = self.client.get(reverse("pedagogy:guide")) - assert response.status_code == 403 - - # Don't display for none subscribed users - self.client.force_login(self.guy) - response = self.client.get(reverse("pedagogy:guide")) - assert response.status_code == 403 +@pytest.mark.django_db +def test_guide_anonymous_permission_denied(client: Client): + res = client.get(reverse("pedagogy:guide")) + assert res.status_code == 302 class UVDeleteTest(TestCase): @@ -577,141 +564,111 @@ class UVSearchTest(TestCase): cls.tutu = User.objects.get(username="tutu") cls.sli = User.objects.get(username="sli") cls.guy = User.objects.get(username="guy") + cls.url = reverse("api:fetch_uvs") + uvs = [ + UV(code="AP4A", credit_type="CS", semester="AUTUMN", department="GI"), + UV(code="MT01", credit_type="CS", semester="AUTUMN", department="TC"), + UV(code="PHYS11", credit_type="CS", semester="AUTUMN", department="TC"), + UV(code="TNEV", credit_type="TM", semester="SPRING", department="TC"), + UV(code="MT10", credit_type="TM", semester="AUTUMN", department="IMSI"), + UV( + code="DA50", + credit_type="TM", + semester="AUTUMN_AND_SPRING", + department="GI", + ), + ] + for uv in uvs: + uv.author = cls.bibou + uv.title = "" + uv.manager = "" + uv.language = "FR" + uv.objectives = "" + uv.program = "" + uv.skills = "" + uv.key_concepts = "" + uv.credits = 6 + UV.objects.bulk_create(uvs) - def setUp(self): - call_command("update_index", "pedagogy") + def fetch_uvs(self, **kwargs): + params = "&".join(f"{key}={val}" for key, val in kwargs.items()) + return json.loads(f"{self.url}?{params}") - def test_get_page_authorized_success(self): - # Test with root user - self.client.force_login(self.bibou) - response = self.client.get(reverse("pedagogy:guide")) - assert response.status_code == 200 - - # Test with pedagogy admin - self.client.force_login(self.tutu) - response = self.client.get(reverse("pedagogy:guide")) - assert response.status_code == 200 - - # Test with subscribed user - self.client.force_login(self.sli) - response = self.client.get(reverse("pedagogy:guide")) - assert response.status_code == 200 - - def test_get_page_unauthorized_fail(self): + def test_permissions(self): # Test with anonymous user - response = self.client.get(reverse("pedagogy:guide")) + response = self.client.get(self.url) assert response.status_code == 403 # Test with not subscribed user self.client.force_login(self.guy) - response = self.client.get(reverse("pedagogy:guide")) + response = self.client.get(self.url) assert response.status_code == 403 - def test_search_pa00_success(self): - self.client.force_login(self.sli) + for user in self.bibou, self.tutu, self.sli: + # users that have right + with self.subTest(): + self.client.force_login(user) + response = self.client.get(self.url) + assert response.status_code == 200 - # Search with UV code - response = self.client.get(reverse("pedagogy:guide"), {"search": "PA00"}) - self.assertContains(response, text="PA00") - - # Search with first letter of UV code - response = self.client.get(reverse("pedagogy:guide"), {"search": "P"}) - self.assertContains(response, text="PA00") - - # Search with first letter of UV code in lowercase - response = self.client.get(reverse("pedagogy:guide"), {"search": "p"}) - self.assertContains(response, text="PA00") - - # Search with UV title - response = self.client.get( - reverse("pedagogy:guide"), {"search": "participation"} - ) - self.assertContains(response, text="PA00") - - # Search with UV manager - response = self.client.get(reverse("pedagogy:guide"), {"search": "HEYBERGER"}) - self.assertContains(response, text="PA00") - - # Search with department - response = self.client.get(reverse("pedagogy:guide"), {"department": "HUMA"}) - self.assertContains(response, text="PA00") - - # Search with semester - response = self.client.get(reverse("pedagogy:guide"), {"semester": "AUTUMN"}) - self.assertContains(response, text="PA00") - - response = self.client.get(reverse("pedagogy:guide"), {"semester": "SPRING"}) - self.assertContains(response, text="PA00") - - response = self.client.get( - reverse("pedagogy:guide"), {"semester": "AUTUMN_AND_SPRING"} - ) - self.assertContains(response, text="PA00") - - # Search with language - response = self.client.get(reverse("pedagogy:guide"), {"language": "FR"}) - self.assertContains(response, text="PA00") - - # Search with credit type - response = self.client.get(reverse("pedagogy:guide"), {"credit_type": "OM"}) - self.assertContains(response, text="PA00") - - # Search with combinaison of all - response = self.client.get( - reverse("pedagogy:guide"), + def test_format(self): + """Test that the return data format is correct""" + self.client.force_login(self.bibou) + res = self.client.get(self.url + "?search=PA00") + uv = UV.objects.get(code="PA00") + assert res.status_code == 200 + assert json.loads(res.content) == [ { - "search": "P", - "department": "HUMA", - "semester": "AUTUMN", - "language": "FR", - "credit_type": "OM", - }, - ) - self.assertContains(response, text="PA00") + "id": uv.id, + "title": uv.title, + "code": uv.code, + "credit_type": uv.credit_type, + "semester": uv.semester, + "department": uv.department, + } + ] - # Test json briefly - response = self.client.get( - reverse("pedagogy:guide"), - { - "json": "t", - "search": "P", - "department": "HUMA", - "semester": "AUTUMN", - "language": "FR", - "credit_type": "OM", - }, - ) - self.assertJSONEqual( - response.content, - [ - { - "id": 1, - "absolute_url": "/pedagogy/uv/1/", - "update_url": "/pedagogy/uv/1/edit/", - "delete_url": "/pedagogy/uv/1/delete/", - "code": "PA00", - "author": 0, - "credit_type": "OM", - "semester": "AUTUMN_AND_SPRING", - "language": "FR", - "credits": 5, - "department": "HUMA", - "title": "Participation dans une association \u00e9tudiante", - "manager": "Laurent HEYBERGER", - "objectives": "* Permettre aux \u00e9tudiants de r\u00e9aliser, pendant un semestre, un projet culturel ou associatif et de le valoriser.", - "program": "* Semestre pr\u00e9c\u00e9dent proposition d'un projet et d'un cahier des charges\n* Evaluation par un jury de six membres\n* Si accord r\u00e9alisation dans le cadre de l'UV\n* Compte-rendu de l'exp\u00e9rience\n* Pr\u00e9sentation", - "skills": "* G\u00e9rer un projet associatif ou une action \u00e9ducative en autonomie:\n* en produisant un cahier des charges qui -d\u00e9finit clairement le contexte du projet personnel -pose les jalons de ce projet -estime de mani\u00e8re r\u00e9aliste les moyens et objectifs du projet -d\u00e9finit exactement les livrables attendus\n * en \u00e9tant capable de respecter ce cahier des charges ou, le cas \u00e9ch\u00e9ant, de r\u00e9viser le cahier des charges de mani\u00e8re argument\u00e9e.\n* Relater son exp\u00e9rience dans un rapport:\n* qui permettra \u00e0 d'autres \u00e9tudiants de poursuivre les actions engag\u00e9es\n* qui montre la capacit\u00e9 \u00e0 s'auto-\u00e9valuer et \u00e0 adopter une distance critique sur son action.", - "key_concepts": "* Autonomie\n* Responsabilit\u00e9\n* Cahier des charges\n* Gestion de projet", - "hours_CM": 0, - "hours_TD": 0, - "hours_TP": 0, - "hours_THE": 121, - "hours_TE": 4, - } - ], + def test_search_by_code(self): + self.client.force_login(self.bibou) + res = self.client.get(self.url + "?search=MT") + assert res.status_code == 200 + assert {uv["code"] for uv in json.loads(res.content)} == {"MT01", "MT10"} + + def test_search_by_credit_type(self): + self.client.force_login(self.bibou) + res = self.client.get(self.url + "?credit_type=CS") + assert res.status_code == 200 + codes = [uv["code"] for uv in json.loads(res.content)] + assert codes == ["AP4A", "MT01", "PHYS11"] + res = self.client.get(self.url + "?credit_type=CS&credit_type=OM") + assert res.status_code == 200 + codes = {uv["code"] for uv in json.loads(res.content)} + assert codes == {"AP4A", "MT01", "PHYS11", "PA00"} + + def test_search_by_semester(self): + self.client.force_login(self.bibou) + res = self.client.get(self.url + "?semester=SPRING") + assert res.status_code == 200 + codes = {uv["code"] for uv in json.loads(res.content)} + assert codes == {"DA50", "TNEV", "PA00"} + + def test_search_multiple_filters(self): + self.client.force_login(self.bibou) + res = self.client.get( + self.url + "?semester=AUTUMN&credit_type=CS&department=TC" ) + assert res.status_code == 200 + codes = {uv["code"] for uv in json.loads(res.content)} + assert codes == {"MT01", "PHYS11"} + + def test_search_fails(self): + self.client.force_login(self.bibou) + res = self.client.get(self.url + "?credit_type=CS&search=DA") + assert res.status_code == 200 + assert json.loads(res.content) == [] def test_search_pa00_fail(self): + self.client.force_login(self.bibou) # Search with UV code response = self.client.get(reverse("pedagogy:guide"), {"search": "IFC"}) self.assertNotContains(response, text="PA00") diff --git a/pedagogy/urls.py b/pedagogy/urls.py index b8c3bf21..7540c27c 100644 --- a/pedagogy/urls.py +++ b/pedagogy/urls.py @@ -27,7 +27,7 @@ from pedagogy.views import * urlpatterns = [ # Urls displaying the actual application for visitors - path("", UVListView.as_view(), name="guide"), + path("", UVGuideView.as_view(), name="guide"), path("uv//", UVDetailFormView.as_view(), name="uv_detail"), path( "comment//edit/", diff --git a/pedagogy/utbm_api.py b/pedagogy/utbm_api.py new file mode 100644 index 00000000..fdd6d1fb --- /dev/null +++ b/pedagogy/utbm_api.py @@ -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, + ) diff --git a/pedagogy/views.py b/pedagogy/views.py index 9a0439fa..6e3a2707 100644 --- a/pedagogy/views.py +++ b/pedagogy/views.py @@ -22,21 +22,17 @@ # from django.conf import settings +from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import ObjectDoesNotExist, PermissionDenied -from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.urls import reverse, reverse_lazy -from django.utils import html from django.views.generic import ( CreateView, DeleteView, FormView, - ListView, + TemplateView, UpdateView, - View, ) -from haystack.query import SearchQuerySet -from rest_framework.renderers import JSONRenderer from core.models import Notification, RealGroup from core.views import ( @@ -44,6 +40,7 @@ from core.views import ( CanEditPropMixin, CanViewMixin, DetailFormView, + FormerSubscriberMixin, ) from pedagogy.forms import ( UVCommentForm, @@ -51,30 +48,12 @@ from pedagogy.forms import ( UVCommentReportForm, UVForm, ) -from pedagogy.models import UV, UVComment, UVCommentReport, UVSerializer - -# Some mixins - - -class CanCreateUVFunctionMixin(View): - """Add the function can_create_uv(user) into the template.""" - - @staticmethod - def can_create_uv(user): - """Creates a dummy instance of UV and test is_owner.""" - return user.is_owner(UV()) - - def get_context_data(self, **kwargs): - """Pass the function to the template.""" - kwargs = super().get_context_data(**kwargs) - kwargs["can_create_uv"] = self.can_create_uv - return kwargs - +from pedagogy.models import UV, UVComment, UVCommentReport # Acutal views -class UVDetailFormView(CanViewMixin, CanCreateUVFunctionMixin, DetailFormView): +class UVDetailFormView(CanViewMixin, DetailFormView): """Display every comment of an UV and detailed infos about it. Allow to comment the UV. @@ -101,6 +80,15 @@ class UVDetailFormView(CanViewMixin, CanCreateUVFunctionMixin, DetailFormView): "pedagogy:uv_detail", kwargs={"uv_id": self.get_object().id} ) + def get_context_data(self, **kwargs): + user = self.request.user + return super().get_context_data(**kwargs) | { + "can_create_uv": ( + user.is_root + or user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID) + ) + } + class UVCommentUpdateView(CanEditPropMixin, UpdateView): """Allow edit of a given comment.""" @@ -134,65 +122,19 @@ class UVCommentDeleteView(CanEditPropMixin, DeleteView): return reverse_lazy("pedagogy:uv_detail", kwargs={"uv_id": self.object.uv.id}) -class UVListView(CanViewMixin, CanCreateUVFunctionMixin, ListView): +class UVGuideView(LoginRequiredMixin, FormerSubscriberMixin, TemplateView): """UV guide main page.""" - # This is very basic and is prone to changment - - model = UV - ordering = ["code"] template_name = "pedagogy/guide.jinja" - def get(self, *args, **kwargs): - if not self.request.GET.get("json", None): - # Return normal full template response - return super().get(*args, **kwargs) - - # Return serialized response - return HttpResponse( - JSONRenderer().render(UVSerializer(self.get_queryset(), many=True).data), - content_type="application/json", - ) - - def get_queryset(self): - queryset = super().get_queryset() - search = self.request.GET.get("search", None) - - additional_filters = {} - - for filter_type in ["credit_type", "language", "department"]: - arg = self.request.GET.get(filter_type, None) - if arg: - additional_filters[filter_type] = arg - - semester = self.request.GET.get("semester", None) - if semester: - if semester in ["AUTUMN", "SPRING"]: - additional_filters["semester__in"] = [semester, "AUTUMN_AND_SPRING"] - else: - additional_filters["semester"] = semester - - queryset = queryset.filter(**additional_filters) - if not search: - return queryset - - if len(search) == 1: - # It's a search with only one letter - # Haystack doesn't work well with only one letter - return queryset.filter(code__istartswith=search) - - try: - qs = ( - SearchQuerySet() - .models(self.model) - .autocomplete(auto=html.escape(search)) + 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) ) - 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): diff --git a/poetry.lock b/poetry.lock index 5b55cfdd..227ddc06 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,6 +11,17 @@ files = [ {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "asgiref" version = "3.8.1" @@ -303,6 +314,17 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "contextlib2" +version = "21.6.0" +description = "Backports and enhancements for the contextlib module" +optional = false +python-versions = ">=3.6" +files = [ + {file = "contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f"}, + {file = "contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869"}, +] + [[package]] name = "coverage" version = "7.6.0" @@ -573,6 +595,44 @@ files = [ django = ">=3.2" jinja2 = ">=3" +[[package]] +name = "django-ninja" +version = "1.2.1" +description = "Django Ninja - Fast Django REST framework" +optional = false +python-versions = ">=3.7" +files = [ + {file = "django_ninja-1.2.1-py3-none-any.whl", hash = "sha256:acb7a0005e84acdb0ae96066c42c7f304f988a078d370e5952382b928bb28a08"}, + {file = "django_ninja-1.2.1.tar.gz", hash = "sha256:667ff27304039d4692421709ae532fd62b16a4d34a969ef850d5cd22cb46090a"}, +] + +[package.dependencies] +Django = ">=3.1" +pydantic = ">=2.0,<3.0.0" + +[package.extras] +dev = ["pre-commit"] +doc = ["markdown-include", "mkdocs", "mkdocs-material", "mkdocstrings"] +test = ["django-stubs", "mypy (==1.7.1)", "psycopg2-binary", "pytest", "pytest-asyncio", "pytest-cov", "pytest-django", "ruff (==0.4.2)"] + +[[package]] +name = "django-ninja-extra" +version = "0.21.1" +description = "Django Ninja Extra - Class Based Utility and more for Django Ninja(Fast Django REST framework)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "django_ninja_extra-0.21.1-py3-none-any.whl", hash = "sha256:331cdf9cbeb8a122a8192c35ac1fba373b0736f4d91d75bc2d39fd0e8d8a66ea"}, + {file = "django_ninja_extra-0.21.1.tar.gz", hash = "sha256:7e0de377c2afd0d4b6655e01901bb8c370c04ffdf5471a17b14e8db0d1002e8e"}, +] + +[package.dependencies] +asgiref = "*" +contextlib2 = "*" +Django = ">=2.2" +django-ninja = "1.2.1" +injector = ">=0.19.0" + [[package]] name = "django-ordered-model" version = "3.7.4" @@ -634,20 +694,6 @@ Pillow = ">=6.2.0" [package.extras] test = ["testfixtures"] -[[package]] -name = "djangorestframework" -version = "3.15.2" -description = "Web APIs for Django, made easy." -optional = false -python-versions = ">=3.8" -files = [ - {file = "djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20"}, - {file = "djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad"}, -] - -[package.dependencies] -django = ">=4.2" - [[package]] name = "docutils" version = "0.19" @@ -796,6 +842,20 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "injector" +version = "0.22.0" +description = "Injector - Python dependency injection framework, inspired by Guice" +optional = false +python-versions = "*" +files = [ + {file = "injector-0.22.0-py2.py3-none-any.whl", hash = "sha256:74379ccef3b893bc7d0b7d504c255b5160c5a55e97dc7bdcc73cb33cc7dce3a1"}, + {file = "injector-0.22.0.tar.gz", hash = "sha256:79b2d4a0874c75d3aa735f11c5b32b89d9542711ca07071161882c5e9cc15ed6"}, +] + +[package.extras] +dev = ["black (==24.3.0)", "build (==1.0.3)", "check-manifest (==0.49)", "click (==8.1.7)", "coverage[toml] (==7.3.2)", "exceptiongroup (==1.2.0)", "importlib-metadata (==7.0.0)", "iniconfig (==2.0.0)", "mypy (==1.7.1)", "mypy-extensions (==1.0.0)", "packaging (==23.2)", "pathspec (==0.12.1)", "platformdirs (==4.1.0)", "pluggy (==1.3.0)", "pyproject-hooks (==1.0.0)", "pytest (==7.4.3)", "pytest-cov (==4.1.0)", "tomli (==2.0.1)", "typing-extensions (==4.9.0)", "zipp (==3.17.0)"] + [[package]] name = "ipython" version = "8.26.0" @@ -1525,6 +1585,129 @@ files = [ {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] +[[package]] +name = "pydantic" +version = "2.8.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.20.1" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.20.1" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, + {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, + {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, + {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, + {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, + {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, + {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, + {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, + {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pygments" version = "2.18.0" @@ -2271,4 +2454,4 @@ filelock = ">=3.4" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "9038b84fac4dc7ce5aea0520d29e4d5705e2e55f3e165d2455ebc61eafe6cfe0" +content-hash = "ee0b881719f6834880266d72272429708e781b3ccd34a0fbf3e8b4119dcb95fd" diff --git a/pyproject.toml b/pyproject.toml index 81a69bf4..2d26e082 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,11 +22,12 @@ license = "GPL-3.0-only" [tool.poetry.dependencies] python = "^3.10" Django = "^4.2.14" +django-ninja = "^1.2.0" +django-ninja-extra = "^0.21.0" Pillow = "^10.4.0" mistune = "^3.0.2" django-jinja = "^2.11" cryptography = "^42.0.8" -djangorestframework = "^3.13" django-phonenumber-field = "^6.3" phonenumbers = "^8.12" django-ajax-selects = "^2.1.0" diff --git a/sas/api.py b/sas/api.py new file mode 100644 index 00000000..babc119e --- /dev/null +++ b/sas/api.py @@ -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 diff --git a/sas/schemas.py b/sas/schemas.py new file mode 100644 index 00000000..4e0ffc90 --- /dev/null +++ b/sas/schemas.py @@ -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 diff --git a/sith/settings.py b/sith/settings.py index 6424d039..51dd69e1 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -78,7 +78,7 @@ INSTALLED_APPS = ( "django.contrib.sites", "honeypot", "django_jinja", - "rest_framework", + "ninja_extra", "ajax_select", "haystack", "captcha", @@ -89,7 +89,6 @@ INSTALLED_APPS = ( "counter", "eboutic", "launderette", - "api", "rootplace", "sas", "com", @@ -473,8 +472,8 @@ SITH_PEDAGOGY_UV_RESULT_GRADE = [ ] SITH_LOG_OPERATION_TYPE = [ - (("SELLING_DELETION"), _("Selling deletion")), - (("REFILLING_DELETION"), _("Refilling deletion")), + ("SELLING_DELETION", _("Selling deletion")), + ("REFILLING_DELETION", _("Refilling deletion")), ] SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide" diff --git a/sith/urls.py b/sith/urls.py index 601c7a79..e7872ca7 100644 --- a/sith/urls.py +++ b/sith/urls.py @@ -19,6 +19,7 @@ from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path from django.views.i18n import JavaScriptCatalog +from ninja_extra import NinjaExtraAPI js_info_dict = {"packages": ("sith",)} @@ -26,8 +27,12 @@ handler403 = "core.views.forbidden" handler404 = "core.views.not_found" handler500 = "core.views.internal_servor_error" +api = NinjaExtraAPI(version="0.2.0", urls_namespace="api") +api.auto_discover_controllers() + urlpatterns = [ path("", include(("core.urls", "core"), namespace="core")), + path("api/", api.urls), path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")), path( "subscription/", @@ -47,7 +52,6 @@ urlpatterns = [ include(("launderette.urls", "launderette"), namespace="launderette"), ), path("sas/", include(("sas.urls", "sas"), namespace="sas")), - path("api/v1/", include(("api.urls", "api"), namespace="api")), path("election/", include(("election.urls", "election"), namespace="election")), path("forum/", include(("forum.urls", "forum"), namespace="forum")), path("galaxy/", include(("galaxy.urls", "galaxy"), namespace="galaxy")), @@ -61,7 +65,6 @@ urlpatterns = [ path("captcha/", include("captcha.urls")), ] - if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/subscription/tests.py b/subscription/tests.py index 6964b4de..abb65251 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -57,7 +57,7 @@ def test_subscription_compute_start_explicit(start_date, duration, expected_star (date(2020, 9, 18), 1, date(2021, 3, 18)), (date(2020, 9, 18), 2, date(2021, 9, 18)), (date(2020, 9, 18), 3, date(2022, 2, 15)), - (date(2020, 5, 17), 4, date(2022, 8, 15)), + (date(2020, 5, 17), 4, date(2022, 2, 15)), (date(2020, 9, 18), 0.33, date(2020, 11, 18)), (date(2020, 9, 18), 0.67, date(2021, 1, 19)), (date(2020, 9, 18), 0.5, date(2020, 12, 18)), @@ -75,7 +75,7 @@ def test_subscription_compute_end_from_today(today, duration, expected_end): (date(2020, 9, 18), 4, date(2022, 9, 18)), ], ) -def test_subscription_compute_end_from_today(start_date, duration, expected_end): +def test_subscription_compute_end(start_date, duration, expected_end): assert Subscription.compute_end(duration, start_date) == expected_end diff --git a/trombi/views.py b/trombi/views.py index 0c49f40e..ebada052 100644 --- a/trombi/views.py +++ b/trombi/views.py @@ -46,7 +46,6 @@ from core.views import ( CanViewMixin, QuickNotifMixin, TabedViewMixin, - UserIsLoggedMixin, ) from core.views.forms import SelectDate from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser @@ -296,7 +295,7 @@ class UserTrombiForm(forms.Form): class UserTrombiToolsView( - QuickNotifMixin, TrombiTabsMixin, UserIsLoggedMixin, TemplateView + LoginRequiredMixin, QuickNotifMixin, TrombiTabsMixin, TemplateView ): """Display a user's trombi tools.""" From 293369f165a22fd937058ede24c7f6d518c260af Mon Sep 17 00:00:00 2001 From: Sli Date: Mon, 22 Jul 2024 18:40:32 +0200 Subject: [PATCH 2/6] Pagination on UV guide --- core/static/core/style.scss | 21 +++++++++---- pedagogy/api.py | 12 ++++---- pedagogy/templates/pedagogy/guide.jinja | 40 ++++++++++++++++++++----- pedagogy/tests.py | 40 +++++++++++++++---------- 4 files changed, 79 insertions(+), 34 deletions(-) diff --git a/core/static/core/style.scss b/core/static/core/style.scss index c5de097b..a2596986 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -24,11 +24,6 @@ $black-color: hsl(0, 0%, 17%); $faceblue: hsl(221, 44%, 41%); $twitblue: hsl(206, 82%, 63%); -$pinktober: #ff5674; -$pinktober-secondary: #8a2536; -$pinktober-primary-text: white; -$pinktober-bar-closed: $pinktober-secondary; -$pinktober-bar-opened: #388e3c; $shadow-color: rgb(223, 223, 223); @@ -48,6 +43,18 @@ body { font-family: sans-serif; } +button:disabled, +button:disabled:hover { + color: #fff; + background-color: #6c757d; +} + +button.active, +button.active:hover { + color: #fff; + background-color: $secondary-color; +} + a.button, button, input[type="button"], @@ -1510,6 +1517,10 @@ $pedagogy-light-blue: #caf0ff; $pedagogy-white-text: #f0f0f0; .pedagogy { + #pagination { + text-align: center; + } + &.star-not-checked { color: #f7f7f7; margin-bottom: 0; diff --git a/pedagogy/api.py b/pedagogy/api.py index e9d24c84..5bb359be 100644 --- a/pedagogy/api.py +++ b/pedagogy/api.py @@ -3,8 +3,9 @@ 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 import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import NotFound +from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseSchema from core.api_permissions import IsInGroup, IsRoot, IsSubscriber from pedagogy.models import UV @@ -29,8 +30,9 @@ class UvController(ControllerBase): raise NotFound return res - @route.get("", response=list[SimpleUvSchema], url_name="fetch_uvs") + @route.get( + "", response=PaginatedResponseSchema[SimpleUvSchema], url_name="fetch_uvs" + ) + @paginate(PageNumberPaginationExtra, page_size=100) 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] + return search.filter(UV.objects.all()) diff --git a/pedagogy/templates/pedagogy/guide.jinja b/pedagogy/templates/pedagogy/guide.jinja index 7871f2b4..e494492c 100644 --- a/pedagogy/templates/pedagogy/guide.jinja +++ b/pedagogy/templates/pedagogy/guide.jinja @@ -73,9 +73,9 @@
- + - +
@@ -96,22 +96,29 @@ -