Merge pull request #724 from ae-utbm/ninja

Use django-ninja for the API
This commit is contained in:
thomas girod 2024-07-24 00:48:08 +02:00 committed by GitHub
commit d51dbf8a53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 1455 additions and 1331 deletions

View File

@ -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"
#
#

View File

@ -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.

View File

@ -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.

View File

@ -1,49 +0,0 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith3
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.urls import include, path, re_path
from rest_framework import routers
from api.views import *
# Router config
router = routers.DefaultRouter()
router.register(r"counter", CounterViewSet, basename="api_counter")
router.register(r"user", UserViewSet, basename="api_user")
router.register(r"club", ClubViewSet, basename="api_club")
router.register(r"group", GroupViewSet, basename="api_group")
# Launderette
router.register(
r"launderette/place", LaunderettePlaceViewSet, basename="api_launderette_place"
)
router.register(
r"launderette/machine",
LaunderetteMachineViewSet,
basename="api_launderette_machine",
)
router.register(
r"launderette/token", LaunderetteTokenViewSet, basename="api_launderette_token"
)
urlpatterns = [
# API
re_path(r"^", include(router.urls)),
re_path(r"^login/", include("rest_framework.urls", namespace="rest_framework")),
re_path(r"^markdown$", RenderMarkdown, name="api_markdown"),
re_path(r"^mailings$", FetchMailingLists, name="mailings_fetch"),
re_path(r"^uv$", uv_endpoint, name="uv_endpoint"),
path("sas/<int:user>", all_pictures_of_user_endpoint, name="all_pictures_of_user"),
]

View File

@ -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 *

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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)
]
)

View File

@ -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)

View File

@ -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

View File

@ -486,10 +486,8 @@ class Mailing(models.Model):
super().delete()
def fetch_format(self):
resp = self.email + ": "
for sub in self.subscriptions.all():
resp += sub.fetch_format()
return resp
destination = "".join(s.fetch_format() for s in self.subscriptions.all())
return f"{self.email}: {destination}"
class MailingSubscription(models.Model):

29
core/api.py Normal file
View File

@ -0,0 +1,29 @@
from django.conf import settings
from django.http import HttpResponse
from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import PermissionDenied
from club.models import Mailing
from core.schemas import MarkdownSchema
from core.templatetags.renderer import markdown
@api_controller("/markdown")
class MarkdownController(ControllerBase):
@route.post("", url_name="markdown")
def render_markdown(self, body: MarkdownSchema):
"""Convert the markdown text into html."""
return HttpResponse(markdown(body.text), content_type="text/html")
@api_controller("/mailings")
class MailingListController(ControllerBase):
@route.get("", response=str)
def fetch_mailing_lists(self, key: str):
if key != settings.SITH_MAILING_FETCH_KEY:
raise PermissionDenied
mailings = Mailing.objects.filter(
is_moderated=True, club__is_active=True
).prefetch_related("subscriptions")
data = "\n".join(m.fetch_format() for m in mailings)
return data

122
core/api_permissions.py Normal file
View File

@ -0,0 +1,122 @@
"""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:
# 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):
"""Check that the user is in the group whose primary key is given."""
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):
"""Check that the user is root."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return request.user.is_root
class IsSubscriber(BasePermission):
"""Check that the user is currently subscribed."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return request.user.is_subscribed
class IsOldSubscriber(BasePermission):
"""Check that the user has at least one subscription in its history."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return request.user.was_subscribed
class CanView(BasePermission):
"""Check that this user has the permission to view the object of this route.
Wrap the `user.can_view(obj)` method.
To see an example, look at the exemple in the module docstring.
"""
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):
"""Check that this user has the permission to edit the object of this route.
Wrap the `user.can_edit(obj)` method.
To see an example, look at the exemple in the module docstring.
"""
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):
"""Check that this user owns the object of this route.
Wrap the `user.is_owner(obj)` method.
To see an example, look at the exemple in the module docstring.
"""
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)

32
core/baker_recipes.py Normal file
View File

@ -0,0 +1,32 @@
from datetime import timedelta
from django.utils.timezone import now
from model_bakery import seq
from model_bakery.recipe import Recipe, related
from core.models import User
from subscription.models import Subscription
active_subscription = Recipe(
Subscription,
subscription_start=now() - timedelta(days=30),
subscription_end=now() + timedelta(days=30),
)
ended_subscription = Recipe(
Subscription,
subscription_start=now() - timedelta(days=60),
subscription_end=now() - timedelta(days=30),
)
subscriber_user = Recipe(
User,
first_name="subscriber",
last_name=seq("user "),
subscriptions=related(active_subscription),
)
old_subscriber_user = Recipe(
User,
first_name="old subscriber",
last_name=seq("user "),
subscriptions=related(ended_subscription),
)

15
core/schemas.py Normal file
View File

@ -0,0 +1,15 @@
from ninja import ModelSchema, Schema
from core.models import User
class SimpleUserSchema(ModelSchema):
"""A schema with the minimum amount of information to represent a user."""
class Meta:
model = User
fields = ["id", "nick_name", "first_name", "last_name"]
class MarkdownSchema(Schema):
text: str

View File

@ -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;

View File

@ -14,26 +14,21 @@
document.head.innerHTML += '<link rel="stylesheet" href="' + css + '">';
}
// Custom markdown parser
function customMarkdownParser(plainText, cb) {
$.ajax({
url: "{{ markdown_api_url }}",
method: "POST",
data: { text: plainText, csrfmiddlewaretoken: getCSRFToken() },
}).done(cb);
}
// Pretty markdown input
const easymde = new EasyMDE({
element: document.getElementById("{{ widget.attrs.id }}"),
spellChecker: false,
autoDownloadFontAwesome: false,
previewRender: function(plainText, preview) { // Async method
previewRender: function (plainText, preview) {
clearTimeout(lastAPICall);
lastAPICall = setTimeout(() => {
customMarkdownParser(plainText, (msg) => preview.innerHTML = msg);
lastAPICall = setTimeout(async () => {
const res = await fetch("{{ markdown_api_url }}", {
method: "POST",
body: JSON.stringify({ text: plainText }),
});
preview.innerHTML = await res.text();
}, 300);
return preview.innerHTML;
return null;
},
forceSync: true, // Avoid validation error on generic create view
toolbar: [

View File

@ -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;

View File

@ -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"),
@ -217,7 +217,7 @@ def test_full_markdown_syntax():
assert result == html
class PageHandlingTest(TestCase):
class TestPageHandling(TestCase):
@classmethod
def setUpTestData(cls):
cls.root = User.objects.get(username="root")
@ -310,7 +310,6 @@ http://git.an
)
response = self.client.get(reverse("core:page", kwargs={"page_name": "guy"}))
assert response.status_code == 200
print(response.content.decode())
expected = """
<p>Guy <em>bibou</em></p>
<p><a href="http://git.an">http://git.an</a></p>
@ -321,11 +320,16 @@ http://git.an
assertInHTML(expected, response.content.decode())
class UserToolsTest:
@pytest.mark.django_db
class TestUserTools:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to the tools page."""
response = client.get(reverse("core:user_tools"))
assert response.status_code == 403
assertRedirects(
response,
expected_url=f"/login?next=%2Fuser%2Ftools%2F",
target_status_code=301,
)
@pytest.mark.parametrize("username", ["guy", "root", "skia", "comunity"])
def test_page_is_working(self, client, username):
@ -336,13 +340,47 @@ class UserToolsTest:
assert response.status_code == 200
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages:
# - renaming a page
# - changing a page's parent --> check that page's children's full_name
# - changing the different groups of the page
class FileHandlingTest(TestCase):
class TestFileHandling(TestCase):
@classmethod
def setUpTestData(cls):
cls.subscriber = User.objects.get(username="subscriber")
@ -378,7 +416,7 @@ class FileHandlingTest(TestCase):
assert "ls</a>" in str(response.content)
class UserIsInGroupTest(TestCase):
class TestUserIsInGroup(TestCase):
"""Test that the User.is_in_group() and AnonymousUser.is_in_group()
work as intended.
"""
@ -519,7 +557,7 @@ class UserIsInGroupTest(TestCase):
assert self.skia.is_in_group(name="This doesn't exist") is False
class DateUtilsTest(TestCase):
class TestDateUtils(TestCase):
@classmethod
def setUpTestData(cls):
cls.autumn_month = settings.SITH_SEMESTER_START_AUTUMN[0]

View File

@ -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)

View File

@ -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

View File

@ -29,6 +29,7 @@ from smtplib import SMTPException
from django.conf import settings
from django.contrib.auth import login, views
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError
from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory
@ -50,7 +51,6 @@ from django.views.generic.dates import MonthMixin, YearMixin
from django.views.generic.edit import FormView, UpdateView
from honeypot.decorators import check_honeypot
from api.views.sas import all_pictures_of_user
from core.models import Gift, Preferences, SithFile, User
from core.views import (
CanEditMixin,
@ -58,7 +58,6 @@ from core.views import (
CanViewMixin,
QuickNotifMixin,
TabedViewMixin,
UserIsLoggedMixin,
)
from core.views.forms import (
GiftForm,
@ -68,6 +67,7 @@ from core.views.forms import (
UserProfileForm,
)
from counter.forms import StudentCardForm
from sas.models import Picture
from subscription.models import Subscription
from trombi.views import UserTrombiForm
@ -313,7 +313,11 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
kwargs = super().get_context_data(**kwargs)
kwargs["albums"] = []
kwargs["pictures"] = {}
picture_qs = all_pictures_of_user(self.object)
picture_qs = (
Picture.objects.filter(people__user_id=self.object.id)
.order_by("parent__date", "id")
.all()
)
last_album = None
for picture in picture_qs:
album = picture.parent
@ -720,7 +724,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
current_tab = "groups"
class UserToolsView(QuickNotifMixin, UserTabsMixin, UserIsLoggedMixin, TemplateView):
class UserToolsView(LoginRequiredMixin, QuickNotifMixin, UserTabsMixin, TemplateView):
"""Displays the logged user's tools."""
template_name = "core/user_tools.jinja"

37
counter/api.py Normal file
View File

@ -0,0 +1,37 @@
#
# Copyright 2024 AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith3
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith3/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from ninja_extra import ControllerBase, api_controller, route
from core.api_permissions import CanView, IsRoot
from counter.models import Counter
from counter.schemas import CounterSchema
@api_controller("/counter")
class CounterController(ControllerBase):
@route.get("", response=list[CounterSchema], permissions=[IsRoot])
def fetch_all(self):
return Counter.objects.all()
@route.get("{counter_id}/", response=CounterSchema, permissions=[CanView])
def fetch_one(self, counter_id: int):
return self.get_object_or_exception(Counter, pk=counter_id)
@route.get("bar/", response=list[CounterSchema], permissions=[CanView])
def fetch_bars(self):
counters = list(Counter.objects.filter(type="BAR"))
for c in counters:
self.check_object_permissions(c)
return counters

13
counter/schemas.py Normal file
View File

@ -0,0 +1,13 @@
from ninja import ModelSchema
from core.schemas import SimpleUserSchema
from counter.models import Counter
class CounterSchema(ModelSchema):
barmen_list: list[SimpleUserSchema]
is_open: bool
class Meta:
model = Counter
fields = ["id", "name", "type", "club", "products"]

View File

@ -0,0 +1 @@
::: core.api_permissions

View File

@ -0,0 +1 @@
::: core.schemas

View File

@ -0,0 +1 @@
::: counter.schemas

View File

@ -0,0 +1 @@
::: pedagogy.schemas

View File

@ -0,0 +1 @@
::: sas.schemas

View File

@ -170,7 +170,14 @@ python manage.py runserver
!!!note
Le serveur est alors accessible à l'adresse
[http://localhost:8000](http://localhost:8000) ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
[http://localhost:8000](http://localhost:8000)
ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
!!!tip
Vous trouverez également, à l'adresse
[http://localhost:8000/api/docs](http://localhost:8000/api/docs),
une interface swagger, avec toutes les routes de l'API.
## Générer la documentation

View File

@ -197,3 +197,22 @@ Les mixins suivants sont implémentés :
Mais sur les `ListView`, on peut arriver à des temps
de réponse extrêmement élevés.
## API
L'API utilise son propre système de permissions.
Ce n'est pas encore un autre système en parallèle, mais un wrapper
autour de notre système de permissions, afin de l'adapter aux besoins
de l'API.
En effet, l'interface attendue pour manipuler le plus aisément
possible les permissions des routes d'API avec la librairie que nous
utilisons est différente de notre système, tout en restant adaptable.
(Pour plus de détail,
[voir la doc de la lib](https://eadwincode.github.io/django-ninja-extra/api_controller/api_controller_permission/)).
Si vous avez bien suivi ce qui a été dit plus haut,
vous ne devriez pas être perdu, étant donné
que le système de permissions de l'API utilise
des noms assez similaires : `IsInGroup`, `IsRoot`, `IsSubscriber`...
Vous pouvez trouver des exemples d'utilisation de ce système
dans [cette partie](../reference/core/api_permissions.md).

View File

@ -26,57 +26,55 @@ sith3/
│ └── workflows/ (2)
├── accounting/ (3)
│ └── ...
├── api/ (4)
├── club/ (4)
│ └── ...
├── club/ (5)
├── com/ (5)
│ └── ...
├── com/ (6)
├── core/ (6)
│ └── ...
├── core/ (7)
├── counter/ (7)
│ └── ...
├── counter/ (8)
├── docs/ (8)
│ └── ...
├── docs/ (9)
├── eboutic/ (9)
│ └── ...
├── eboutic/ (10)
├── election/ (10)
│ └── ...
├── election/ (11)
├── forum/ (11)
│ └── ...
├── forum/ (12)
├── galaxy/ (12)
│ └── ...
├── galaxy/ (13)
├── launderette/ (13)
│ └── ...
├── launderette/ (14)
├── locale/ (14)
│ └── ...
├── locale/ (15)
├── matmat/ (15)
│ └── ...
├── matmat/ (16)
├── pedagogy/ (16)
│ └── ...
├── pedagogy/ (17)
├── rootplace/ (17)
│ └── ...
├── rootplace/ (18)
├── sas/ (18)
│ └── ...
├── sas/ (19)
├── sith/ (19)
│ └── ...
├── sith/ (20)
├── stock/ (20)
│ └── ...
├── stock/ (21)
├── subscription/ (21)
│ └── ...
├── subscription/ (22)
│ └── ...
├── trombi/ (23)
├── trombi/ (22)
│ └── ...
├── .coveragerc (24)
├── .envrc (25)
├── .coveragerc (23)
├── .envrc (24)
├── .gitattributes
├── .gitignore
├── .mailmap
├── .env.exemple
├── manage.py (26)
├── mkdocs.yml (27)
├── manage.py (25)
├── mkdocs.yml (26)
├── poetry.lock
├── pyproject.toml (28)
├── pyproject.toml (27)
└── README.md
```
</div>
@ -90,40 +88,39 @@ sith3/
Par exemple, le workflow `docs.yml` compile
et publie la documentation à chaque push sur la branche `master`.
3. Application de gestion de la comptabilité.
4. Application contenant des routes d'API.
5. Application de gestion des clubs et de leurs membres.
6. Application contenant les fonctionnalités
4. Application de gestion des clubs et de leurs membres.
5. Application contenant les fonctionnalités
destinées aux responsables communication de l'AE.
7. Application contenant la modélisation centrale du site.
6. Application contenant la modélisation centrale du site.
On en reparle plus loin sur cette page.
8. Application de gestion des comptoirs, des permanences
7. Application de gestion des comptoirs, des permanences
sur ces comptoirs et des transactions qui y sont effectuées.
9. Dossier contenant la documentation.
10. Application de gestion de la boutique en ligne.
11. Application de gestion des élections.
12. Application de gestion du forum
13. Application de gestion de la galaxie ; la galaxie
8. Dossier contenant la documentation.
9. Application de gestion de la boutique en ligne.
10. Application de gestion des élections.
11. Application de gestion du forum
12. Application de gestion de la galaxie ; la galaxie
est un graphe des niveaux de proximité entre les différents
étudiants.
14. Gestion des machines à laver de l'AE
15. Dossier contenant les fichiers de traduction.
16. Fonctionnalités de recherche d'utilisateurs.
17. Le guide des UEs du site, sur lequel les utilisateurs
13. Gestion des machines à laver de l'AE
14. Dossier contenant les fichiers de traduction.
15. Fonctionnalités de recherche d'utilisateurs.
16. Le guide des UEs du site, sur lequel les utilisateurs
peuvent également laisser leurs avis.
18. Fonctionnalités utiles aux utilisateurs root.
19. Le SAS, où l'on trouve toutes les photos de l'AE.
20. Application principale du projet, contenant sa configuration.
21. Gestion des stocks des comptoirs.
22. Gestion des cotisations des utilisateurs du site.
23. Gestion des trombinoscopes.
24. Fichier de configuration de coverage.
25. Fichier de configuration de direnv.
26. Fichier généré automatiquement par Django. C'est lui
17. Fonctionnalités utiles aux utilisateurs root.
18. Le SAS, où l'on trouve toutes les photos de l'AE.
19. Application principale du projet, contenant sa configuration.
20. Gestion des stocks des comptoirs.
21. Gestion des cotisations des utilisateurs du site.
22. Gestion des trombinoscopes.
23. Fichier de configuration de coverage.
24. Fichier de configuration de direnv.
25. Fichier généré automatiquement par Django. C'est lui
qui permet d'appeler des commandes de gestion du projet
avec la syntaxe `python ./manage.py <nom de la commande>`
27. Le fichier de configuration de la documentation,
26. Le fichier de configuration de la documentation,
avec ses plugins et sa table des matières.
28. Le fichier où sont déclarés les dépendances et la configuration
27. Le fichier où sont déclarés les dépendances et la configuration
de certaines d'entre elles.
@ -175,11 +172,13 @@ comme suit :
│ └── ...
├── templates/ (2)
│ └── ...
├── admin.py (3)
├── models.py (4)
├── tests.py (5)
├── urls.py (6)
└── views.py (7)
├── api.py (3)
├── admin.py (4)
├── models.py (5)
├── tests.py (6)
├── schemas.py (7)
├── urls.py (8)
└── views.py (9)
```
</div>
@ -188,15 +187,17 @@ comme suit :
de mettre à jour la base de données.
cf. [Gestion des migrations](../howto/migrations.md)
2. Dossier contenant les templates jinja utilisés par cette application.
3. Fichier de configuration de l'interface d'administration.
3. Fichier contenant les routes d'API liées à cette application
4. Fichier de configuration de l'interface d'administration.
Ce fichier permet de déclarer les modèles de l'application
dans l'interface d'administration.
4. Fichier contenant les modèles de l'application.
5. Fichier contenant les modèles de l'application.
Les modèles sont des classes Python qui représentent
les tables de la base de données.
5. Fichier contenant les tests de l'application.
6. Configuration des urls de l'application.
7. Fichier contenant les vues de l'application.
6. Fichier contenant les tests de l'application.
7. Schémas de validation de données utilisés principalement dans l'API.
8. Configuration des urls de l'application.
9. Fichier contenant les vues de l'application.
Dans les plus grosses applications,
ce fichier peut être remplacé par un package
`views` dans lequel les vues sont réparties entre

View File

@ -27,6 +27,7 @@ from functools import partial
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
@ -46,7 +47,6 @@ from core.views import (
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
UserIsLoggedMixin,
can_view,
)
from core.views.forms import MarkdownInput
@ -279,7 +279,7 @@ class ForumTopicEditView(CanEditMixin, UpdateView):
class ForumTopicSubscribeView(
CanViewMixin, UserIsLoggedMixin, SingleObjectMixin, RedirectView
LoginRequiredMixin, CanViewMixin, SingleObjectMixin, RedirectView
):
model = ForumTopic
pk_url_kwarg = "topic_id"

View File

@ -89,9 +89,12 @@ nav:
- core:
- reference/core/models.md
- reference/core/views.md
- reference/core/schemas.md
- reference/core/api_permissions.md
- counter:
- reference/counter/models.md
- reference/counter/views.md
- reference/counter/schemas.md
- eboutic:
- reference/eboutic/models.md
- reference/eboutic/views.md
@ -113,12 +116,14 @@ nav:
- pedagogy:
- reference/pedagogy/models.md
- reference/pedagogy/views.md
- reference/pedagogy/schemas.md
- rootplace:
- reference/rootplace/models.md
- reference/rootplace/views.md
- sas:
- reference/sas/models.md
- reference/sas/views.md
- reference/sas/schemas.md
- stock:
- reference/stock/models.md
- reference/stock/views.md
@ -159,4 +164,4 @@ markdown_extensions:
toc_depth: 3
extra_css:
- stylesheets/extra.css
- stylesheets/extra.css

41
pedagogy/api.py Normal file
View File

@ -0,0 +1,41 @@
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, 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
from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema
from pedagogy.utbm_api import find_uv
@api_controller("/uv")
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=PaginatedResponseSchema[SimpleUvSchema],
url_name="fetch_uvs",
permissions=[IsSubscriber | IsInGroup(settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)],
)
@paginate(PageNumberPaginationExtra, page_size=100)
def fetch_uv_list(self, search: Query[UvFilterSchema]):
return search.filter(UV.objects.all())

View File

@ -28,7 +28,6 @@ from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from core.models import User
@ -327,30 +326,3 @@ class UVCommentReport(models.Model):
def is_owned_by(self, user):
"""Can be created by a pedagogy admin, a superuser or a subscriber."""
return user.is_subscribed or user.is_owner(self.comment.uv)
# Custom serializers
class UVSerializer(serializers.ModelSerializer):
"""Custom seralizer for UVs.
Allow adding more informations like absolute_url.
"""
class Meta:
model = UV
fields = "__all__"
absolute_url = serializers.SerializerMethodField()
update_url = serializers.SerializerMethodField()
delete_url = serializers.SerializerMethodField()
def get_absolute_url(self, obj):
return obj.get_absolute_url()
def get_update_url(self, obj):
return reverse("pedagogy:uv_update", kwargs={"uv_id": obj.id})
def get_delete_url(self, obj):
return reverse("pedagogy:uv_delete", kwargs={"uv_id": obj.id})

132
pedagogy/schemas.py Normal file
View File

@ -0,0 +1,132 @@
from typing import Literal
from django.db.models import Q
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import AliasPath, ConfigDict, Field, TypeAdapter
from pydantic.alias_generators import to_camel
from pedagogy.models import UV
class UtbmShortUvSchema(Schema):
"""Short representation of an UV in the UTBM API.
Notes:
This schema holds only the fields we actually need.
The UTBM API returns more data than that.
"""
model_config = ConfigDict(alias_generator=to_camel)
code: str
code_formation: str
code_categorie: str | None
code_langue: str
ouvert_automne: bool
ouvert_printemps: bool
class WorkloadSchema(Schema):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
code: Literal["TD", "TP", "CM", "THE", "TE"]
nbh: int
class SemesterUvState(Schema):
"""The state of the UV during either autumn or spring semester"""
model_config = ConfigDict(alias_generator=to_camel)
responsable: str
ouvert: bool
ShortUvList = TypeAdapter(list[UtbmShortUvSchema])
class UtbmFullUvSchema(Schema):
"""Long representation of an UV in the UTBM API."""
model_config = ConfigDict(alias_generator=to_camel)
code: str
departement: str = "NA"
libelle: str
objectifs: str
programme: str
acquisition_competences: str
acquisition_notions: str
langue: str
code_langue: str
credits_ects: int
activites: list[WorkloadSchema]
respo_automne: str | None = Field(
None, validation_alias=AliasPath("automne", "responsable")
)
respo_printemps: str | None = Field(
None, validation_alias=AliasPath("printemps", "responsable")
)
class SimpleUvSchema(ModelSchema):
"""Our minimal representation of an UV."""
class Meta:
model = UV
fields = [
"id",
"title",
"code",
"credit_type",
"semester",
"department",
]
class UvSchema(ModelSchema):
"""Our complete representation of an UV"""
class Meta:
model = UV
fields = [
"id",
"title",
"code",
"hours_THE",
"hours_TD",
"hours_TP",
"hours_TE",
"hours_CM",
"credit_type",
"semester",
"language",
"department",
"credits",
"manager",
"skills",
"key_concepts",
"objectives",
"program",
]
class UvFilterSchema(FilterSchema):
search: str | None = Field(None, q="code__icontains")
semester: set[Literal["AUTUMN", "SPRING"]] | None = None
credit_type: set[Literal["CS", "TM", "EC", "OM", "QC"]] | None = Field(
None, q="credit_type__in"
)
language: str = "FR"
department: set[str] | None = Field(None, q="department__in")
def filter_semester(self, value: set[str] | None) -> Q:
"""Special filter for the semester.
If either "SPRING" or "AUTUMN" is 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)

View File

@ -5,52 +5,79 @@
{% trans %}UV Guide{% endtrans %}
{% endblock %}
{% block additional_js %}
<script src="{{ static('core/js/alpinejs.min.js') }}" defer></script>
{% endblock %}
{% block head %}
{{ super() }}
<meta name="viewport" content="width=device-width, initial-scale=0.6, maximum-scale=2">
{% endblock head %}
{% block content %}
<div class="pedagogy">
<form id="search_form" action="{{ url('pedagogy:guide') }}" method="get">
{% if can_create_uv %}
<div class="action-bar">
<p>
<a href="{{ url('pedagogy:uv_create') }}">{% trans %}Create UV{% endtrans %}</a>
</p>
<p>
<a href="{{ url('pedagogy:moderation') }}">{% trans %}Moderate comments{% endtrans %}</a>
</p>
</div>
<br/>
{% endif %}
<div class="pedagogy" x-data="uv_search" x-cloak>
<form id="search_form">
<div class="search-form-container">
{% if can_create_uv(user) %}
<div class="action-bar">
<p>
<a href="{{ url('pedagogy:uv_create') }}">{% trans %}Create UV{% endtrans %}</a>
</p>
<p>
<a href="{{ url('pedagogy:moderation') }}">{% trans %}Moderate comments{% endtrans %}</a>
</p>
</div>
{% endif %}
<div class="search-bar">
<input id="search_input" class="search-bar-input" type="text" name="search">
<button class="search-bar-button">{% trans %}Search{% endtrans %}</button>
<input
id="search_input"
class="search-bar-input"
type="text"
name="search"
x-model.debounce.500ms="search"
/>
</div>
<div class="radio-department">
<div class="radio-guide">
{% for (display_name, real_name) in [("EDIM", "EDIM"), ("ENERGIE", "EE"), ("IMSI", "IMSI"), ("INFO", "GI"), ("GMC", "MC"), ("HUMA", "HUMA"), ("TC", "TC")] %}
<input type="radio" name="department" id="radio{{ real_name }}" value="{{ real_name }}"><label for="radio{{ real_name }}">{% trans %}{{ display_name }}{% endtrans %}</label>
{% for (display_name, real_name) in [
("EDIM", "EDIM"), ("ENERGIE", "EE"), ("IMSI", "IMSI"),
("INFO", "GI"), ("GMC", "MC"), ("HUMA", "HUMA"), ("TC", "TC")
] %}
<input
type="checkbox"
name="department"
id="radio{{ real_name }}"
value="{{ real_name }}"
x-model="department"
/>
<label for="radio{{ real_name }}">{% trans %}{{ display_name }}{% endtrans %}</label>
{% endfor %}
</div>
</div>
<div class="radio-credit-type">
<div class="radio-guide">
{% for credit_type in ["CS", "TM", "EC", "QC", "OM"] %}
<input type="radio" name="credit_type" id="radio{{ credit_type }}" value="{{ credit_type }}"><label for="radio{{ credit_type }}">{% trans %}{{ credit_type }}{% endtrans %}</label>
<input
type="checkbox"
name="credit_type"
id="radio{{ credit_type }}"
value="{{ credit_type }}"
x-model="credit_type"
/>
<label for="radio{{ credit_type }}">{% trans %}{{ credit_type }}{% endtrans %}</label>
{% endfor %}
</div>
</div>
<div class="radio-semester">
<div class="radio-guide">
<input type="checkbox" name="semester" id="radioAUTUMN" value="AUTUMN"><label for="radioAUTUMN"><i class="fa fa-leaf"></i></label>
<input type="checkbox" name="semester" id="radioSPRING" value="SPRING"><label for="radioSPRING"><i class="fa fa-sun-o"></i></label>
<span><input type="checkbox" name="semester" id="radioAP" value="AUTUMN_AND_SPRING"><label for="radioAP">AP</label></span>
<input type="checkbox" name="semester" id="radioAUTUMN" value="AUTUMN" x-model="semester"/>
<label for="radioAUTUMN"><i class="fa fa-leaf"></i></label>
<input type="checkbox" name="semester" id="radioSPRING" value="SPRING" x-model="semester"/>
<label for="radioSPRING"><i class="fa fa-sun-o"></i></label>
</div>
</div>
<input type="text" name="json" hidden>
</div>
</form>
<table id="dynamic_view">
@ -62,185 +89,106 @@
<td>{% trans %}Credit type{% endtrans %}</td>
<td><i class="fa fa-leaf"></i></td>
<td><i class="fa fa-sun-o"></i></td>
{% if can_create_uv(user) %}
<td>{% trans %}Edit{% endtrans %}</td>
<td>{% trans %}Delete{% endtrans %}</td>
{% if can_create_uv %}
<td>{% trans %}Edit{% endtrans %}</td>
<td>{% trans %}Delete{% endtrans %}</td>
{% endif %}
</tr>
</thead>
<tbody id="dynamic_view_content">
{% for uv in object_list %}
<tr onclick="window.location.href = `{{ url('pedagogy:uv_detail', uv_id=uv.id) }}`">
<td><a href="{{ url('pedagogy:uv_detail', uv_id=uv.id) }}">{{ uv.code }}</a></td>
<td>{{ uv.title }}</td>
<td>{{ uv.department }}</td>
<td>{{ uv.credit_type }}</td>
<td>
{% if uv.semester in ["AUTUMN", "AUTUMN_AND_SPRING"] %}
<i class="fa fa-leaf"></i>
{% endif %}
</td>
<td>
{% if uv.semester in ["SPRING", "AUTUMN_AND_SPRING"] %}
<i class="fa fa-sun-o"></i>
{% endif %}
</td>
{% if user.is_owner(uv) -%}
<td><a href="{{ url('pedagogy:uv_update', uv_id=uv.id) }}">{% trans %}Edit{% endtrans %}</a></td>
<td><a href="{{ url('pedagogy:uv_delete', uv_id=uv.id) }}">{% trans %}Delete{% endtrans %}</a></td>
{%- endif -%}
</tr>
{% endfor %}
<template x-for="uv in uvs.results" :key="uv.id">
<tr @click="window.location.href = `/pedagogy/uv/${uv.id}`">
<td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td>
<td x-text="uv.title"></td>
<td x-text="uv.department"></td>
<td x-text="uv.credit_type"></td>
<td><i :class="uv.semester.includes('AUTUMN') && 'fa fa-leaf'"></i></td>
<td><i :class="uv.semester.includes('SPRING') && 'fa fa-sun-o'"></i></td>
{% if can_create_uv -%}
<td><a :href="`/pedagogy/uv/${uv.id}/edit`">{% trans %}Edit{% endtrans %}</a></td>
<td><a :href="`/pedagogy/uv/${uv.id}/delete`">{% trans %}Delete{% endtrans %}</a></td>
{%- endif -%}
</tr>
</template>
</tbody>
</table>
<nav id="pagination" class="hidden" :style="max_page() == 0 && 'display: none;'">
<button @click="page--" :disabled="page == 1">{% trans %}Previous{% endtrans %}</button>
<template x-for="i in max_page()">
<button x-text="i" @click="page = i" :class="i == page && 'active'"></button>
</template>
<button @click="page++" :disabled="page == max_page()">{% trans %}Next{% endtrans %}</button>
</nav>
</div>
<script>
function autofillCheckboxRadio(name){
if (urlParams.has(name)){ $("input[name='" + name + "']").each(function(){
if ($(this).attr("value") == urlParams.get(name))
$(this).prop("checked", true);
});
}
}
const initialUrlParams = new URLSearchParams(window.location.search);
function uvJSONToHTML(uv){
var autumn = "";
var spring = "";
if (uv.semester == "AUTUMN" || uv.semester == "AUTUMN_AND_SPRING")
autumn = "<i class='fa fa-leaf'></i>";
if (uv.semester == "SPRING" || uv.semester == "AUTUMN_AND_SPRING")
spring = "<i class='fa fa-sun-o'></i>";
var html = `
<tr onclick="window.location.href = '${uv.absolute_url}';">
<td><a href="${uv.absolute_url}">${uv.code}</a></td>
<td>${uv.title}</td>
<td>${uv.department}</td>
<td>${uv.credit_type}</td>
<td>${autumn}</td>
<td>${spring}</td>
`;
{% if can_create_uv(user) %}
html += `
<td><a href="${uv.update_url}">{% trans %}Edit{% endtrans %}</a></td>
<td><a href="${uv.delete_url}">{% trans %}Delete{% endtrans %}</a></td>
`;
{% endif %}
return html + "</td>";
}
var lastTypedLetter;
$("#search_input").on("keyup", function(){
// Auto submit when user pauses it's typing
clearTimeout(lastTypedLetter);
lastTypedLetter = setTimeout(function (){
$("#search_form").submit();
}, 300);
});
$("#search_input").on("change", function(e){
// Don't send request when leaving the text area
// It has already been send by the keypress event
e.preventDefault();
});
// Auto fill from get arguments
var urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("search"))
$("input[name='search']").first().prop("value", urlParams.get("search"));
autofillCheckboxRadio("department");
autofillCheckboxRadio("credit_type");
autofillCheckboxRadio("semester");
// Allow unchecking a radio button when we click on it
// Keep a state of what is checked
var formStates = {};
function radioCheckToggle(e){
if (formStates[this.name] == this.value){
this.checked = false;
formStates[this.name] = "";
// Fire an update since the browser does not do it in this situation
$("#search_form").submit();
return;
}
formStates[this.name] = this.value;
}
$("input[type='radio']").each(function() {
$(this).on("click", radioCheckToggle);
// Get current state
if ($(this).prop("checked")){
formStates[$(this).attr("name")] = $(this).attr("value");
}
});
var autumn_and_spring = $("input[value='AUTUMN_AND_SPRING']").first();
var autumn = $("input[value='AUTUMN']").first();
var spring = $("input[value='SPRING']").first();
// Make autumn and spring hidden if js is enabled
autumn_and_spring.parent().hide();
// Fill json field if js is enabled
$("input[name='json']").first().prop("value", "true");
// Set correctly state of what is checked
if (autumn_and_spring.prop("checked")){
autumn.prop("checked", true);
spring.prop("checked", true);
autumn_and_spring.prop("checked", false);
function update_query_string(key, value) {
const url = new URL(window.location.href);
if (!value) {
url.searchParams.delete(key)
} else if (Array.isArray(value)) {
url.searchParams.delete(key)
value.forEach((v) => url.searchParams.append(key, v))
} else {
url.searchParams.set(key, value);
}
history.pushState(null, document.title, url.toString());
}
// Handle submit here and modify autumn and spring here
$("#search_form").submit(function(e) {
e.preventDefault();
if (autumn.prop("checked") && spring.prop("checked")){
autumn_and_spring.prop("checked", true);
autumn.prop("checked", false);
spring.prop("checked", false);
}
{#
How does this work :
// Do query
var xhr = new XMLHttpRequest();
$.ajax({
type: "GET",
url: "{{ url('pedagogy:guide') }}",
data: $(this).serialize(),
tryCount: 0,
retryLimit: 10,
xhr: function(){
return xhr;
},
success: function(data){
// Update URL
history.pushState({}, null, xhr.responseURL.replace("&json=true", ""));
// Update content
$("#dynamic_view_content").html("");
for (key in data){
$("#dynamic_view_content").append(uvJSONToHTML(data[key]));
}
},
error: function(){
console.log(`try ${this.tryCount}`);
if (this.tryCount++ <= this.retryLimit){
$("dynamic_view_content").html("");
$.ajax(this);
return;
}
$("#dynamic_view_content").html("<tr><td></td><td>{% trans %}Error connecting to the server{% endtrans %}</td></tr>");
}
});
The page contains two main elements : the form and the results.
The form contains multiple inputs, allowing the user to apply the filter of its choice.
Each modification of those filters will modify the GET parameters of the URL,
then fetch the corresponding data from the API.
This data will then be displayed on the result part of the page.
#}
const page_default = 1;
const page_size_default = 100;
document.addEventListener("alpine:init", () => {
Alpine.data("uv_search", () => ({
uvs: [],
page: initialUrlParams.get("page") || page_default,
page_size: initialUrlParams.get("page_size") || page_size_default,
search: initialUrlParams.get("search") || "",
department: initialUrlParams.getAll("department"),
credit_type: initialUrlParams.getAll("credit_type"),
{# The semester is easier to use on the backend as an enum (spring/autumn/both/none)
and easier to use on the frontend as an array ([spring, autumn]).
Thus there is some conversion involved when both communicate together #}
semester: initialUrlParams.has("semester") ?
initialUrlParams.get("semester").split("_AND_") : [],
// Restore autumn and spring for perfect illusion
if (autumn_and_spring.prop("checked")){
autumn_and_spring.prop("checked", false);
autumn.prop("checked", true);
spring.prop("checked", true);
}
});
async init() {
let search_params = ["search", "department", "credit_type", "semester"];
let pagination_params = ["semester", "page"];
search_params.forEach((param) => {
this.$watch(param, async () => {
{# Reset pagination on search #}
this.page = page_default;
this.page_size = page_size_default;
});
});
search_params.concat(pagination_params).forEach((param) => {
this.$watch(param, async (value) => {
update_query_string(param, value);
await this.fetch_data(); {# reload data on form change #}
});
});
await this.fetch_data(); {# load initial data #}
},
// Auto send on change
$("#search_form").on("change", function(e){
$(this).submit();
});
async fetch_data() {
const url = "{{ url("api:fetch_uvs") }}" + window.location.search;
this.uvs = await (await fetch(url)).json();
},
max_page() {
return Math.round(this.uvs.count / this.page_size);
}
}))
})
</script>
{% endblock content %}

View File

@ -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) {

View File

165
pedagogy/tests/test_api.py Normal file
View File

@ -0,0 +1,165 @@
import json
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from model_bakery.recipe import Recipe
from core.baker_recipes import subscriber_user
from core.models import RealGroup, User
from pedagogy.models import UV
class UVSearchTest(TestCase):
"""Test UV guide rights for view and API."""
@classmethod
def setUpTestData(cls):
cls.root = User.objects.get(username="root")
cls.url = reverse("api:fetch_uvs")
uv_recipe = Recipe(UV, author=cls.root)
uvs = [
uv_recipe.prepare(
code="AP4A", credit_type="CS", semester="AUTUMN", department="GI"
),
uv_recipe.prepare(
code="MT01", credit_type="CS", semester="AUTUMN", department="TC"
),
uv_recipe.prepare(
code="PHYS11", credit_type="CS", semester="AUTUMN", department="TC"
),
uv_recipe.prepare(
code="TNEV", credit_type="TM", semester="SPRING", department="TC"
),
uv_recipe.prepare(
code="MT10", credit_type="TM", semester="AUTUMN", department="IMSI"
),
uv_recipe.prepare(
code="DA50",
credit_type="TM",
semester="AUTUMN_AND_SPRING",
department="GI",
),
]
UV.objects.bulk_create(uvs)
def test_permissions(self):
# Test with anonymous user
response = self.client.get(self.url)
assert response.status_code == 403
# Test with not subscribed user
self.client.force_login(baker.make(User))
response = self.client.get(self.url)
assert response.status_code == 403
for user in (
self.root,
subscriber_user.make(),
baker.make(
User,
groups=[
RealGroup.objects.get(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)
],
),
):
# users that have right
with self.subTest():
self.client.force_login(user)
response = self.client.get(self.url)
assert response.status_code == 200
def test_format(self):
"""Test that the return data format is correct"""
self.client.force_login(self.root)
res = self.client.get(self.url + "?search=PA00")
uv = UV.objects.get(code="PA00")
assert res.status_code == 200
assert json.loads(res.content) == {
"count": 1,
"next": None,
"previous": None,
"results": [
{
"id": uv.id,
"title": uv.title,
"code": uv.code,
"credit_type": uv.credit_type,
"semester": uv.semester,
"department": uv.department,
}
],
}
def test_search_by_code(self):
self.client.force_login(self.root)
res = self.client.get(self.url + "?search=MT")
assert res.status_code == 200
assert {uv["code"] for uv in json.loads(res.content)["results"]} == {
"MT01",
"MT10",
}
def test_search_by_credit_type(self):
self.client.force_login(self.root)
res = self.client.get(self.url + "?credit_type=CS")
assert res.status_code == 200
codes = [uv["code"] for uv in json.loads(res.content)["results"]]
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)["results"]}
assert codes == {"AP4A", "MT01", "PHYS11", "PA00"}
def test_search_by_semester(self):
self.client.force_login(self.root)
res = self.client.get(self.url + "?semester=SPRING")
assert res.status_code == 200
codes = {uv["code"] for uv in json.loads(res.content)["results"]}
assert codes == {"DA50", "TNEV", "PA00"}
def test_search_multiple_filters(self):
self.client.force_login(self.root)
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)["results"]}
assert codes == {"MT01", "PHYS11"}
def test_search_fails(self):
self.client.force_login(self.root)
res = self.client.get(self.url + "?credit_type=CS&search=DA")
assert res.status_code == 200
assert json.loads(res.content)["results"] == []
def test_search_pa00_fail(self):
self.client.force_login(self.root)
# Search with UV code
response = self.client.get(reverse("pedagogy:guide"), {"search": "IFC"})
self.assertNotContains(response, text="PA00")
# Search with first letter of UV code
response = self.client.get(reverse("pedagogy:guide"), {"search": "I"})
self.assertNotContains(response, text="PA00")
# Search with UV manager
response = self.client.get(reverse("pedagogy:guide"), {"search": "GILLES"})
self.assertNotContains(response, text="PA00")
# Search with department
response = self.client.get(reverse("pedagogy:guide"), {"department": "TC"})
self.assertNotContains(response, text="PA00")
# Search with semester
response = self.client.get(reverse("pedagogy:guide"), {"semester": "CLOSED"})
self.assertNotContains(response, text="PA00")
# Search with language
response = self.client.get(reverse("pedagogy:guide"), {"language": "EN"})
self.assertNotContains(response, text="PA00")
# Search with credit type
response = self.client.get(reverse("pedagogy:guide"), {"credit_type": "TM"})
self.assertNotContains(response, text="PA00")

View File

@ -21,9 +21,9 @@
#
#
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 +141,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):
@ -568,179 +554,6 @@ class UVCommentUpdateTest(TestCase):
self.assertEqual(self.comment.author, self.krophil)
class UVSearchTest(TestCase):
"""Test UV guide rights for view and API."""
@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 setUp(self):
call_command("update_index", "pedagogy")
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):
# Test with anonymous user
response = self.client.get(reverse("pedagogy:guide"))
assert response.status_code == 403
# Test with not subscribed user
self.client.force_login(self.guy)
response = self.client.get(reverse("pedagogy:guide"))
assert response.status_code == 403
def test_search_pa00_success(self):
self.client.force_login(self.sli)
# 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"),
{
"search": "P",
"department": "HUMA",
"semester": "AUTUMN",
"language": "FR",
"credit_type": "OM",
},
)
self.assertContains(response, text="PA00")
# Test json briefly
response = self.client.get(
reverse("pedagogy:guide"),
{
"json": "t",
"search": "P",
"department": "HUMA",
"semester": "AUTUMN",
"language": "FR",
"credit_type": "OM",
},
)
self.assertJSONEqual(
response.content,
[
{
"id": 1,
"absolute_url": "/pedagogy/uv/1/",
"update_url": "/pedagogy/uv/1/edit/",
"delete_url": "/pedagogy/uv/1/delete/",
"code": "PA00",
"author": 0,
"credit_type": "OM",
"semester": "AUTUMN_AND_SPRING",
"language": "FR",
"credits": 5,
"department": "HUMA",
"title": "Participation dans une association \u00e9tudiante",
"manager": "Laurent HEYBERGER",
"objectives": "* Permettre aux \u00e9tudiants de r\u00e9aliser, pendant un semestre, un projet culturel ou associatif et de le valoriser.",
"program": "* Semestre pr\u00e9c\u00e9dent proposition d'un projet et d'un cahier des charges\n* Evaluation par un jury de six membres\n* Si accord r\u00e9alisation dans le cadre de l'UV\n* Compte-rendu de l'exp\u00e9rience\n* Pr\u00e9sentation",
"skills": "* G\u00e9rer un projet associatif ou une action \u00e9ducative en autonomie:\n* en produisant un cahier des charges qui -d\u00e9finit clairement le contexte du projet personnel -pose les jalons de ce projet -estime de mani\u00e8re r\u00e9aliste les moyens et objectifs du projet -d\u00e9finit exactement les livrables attendus\n * en \u00e9tant capable de respecter ce cahier des charges ou, le cas \u00e9ch\u00e9ant, de r\u00e9viser le cahier des charges de mani\u00e8re argument\u00e9e.\n* Relater son exp\u00e9rience dans un rapport:\n* qui permettra \u00e0 d'autres \u00e9tudiants de poursuivre les actions engag\u00e9es\n* qui montre la capacit\u00e9 \u00e0 s'auto-\u00e9valuer et \u00e0 adopter une distance critique sur son action.",
"key_concepts": "* Autonomie\n* Responsabilit\u00e9\n* Cahier des charges\n* Gestion de projet",
"hours_CM": 0,
"hours_TD": 0,
"hours_TP": 0,
"hours_THE": 121,
"hours_TE": 4,
}
],
)
def test_search_pa00_fail(self):
# Search with UV code
response = self.client.get(reverse("pedagogy:guide"), {"search": "IFC"})
self.assertNotContains(response, text="PA00")
# Search with first letter of UV code
response = self.client.get(reverse("pedagogy:guide"), {"search": "I"})
self.assertNotContains(response, text="PA00")
# Search with UV manager
response = self.client.get(reverse("pedagogy:guide"), {"search": "GILLES"})
self.assertNotContains(response, text="PA00")
# Search with department
response = self.client.get(reverse("pedagogy:guide"), {"department": "TC"})
self.assertNotContains(response, text="PA00")
# Search with semester
response = self.client.get(reverse("pedagogy:guide"), {"semester": "CLOSED"})
self.assertNotContains(response, text="PA00")
# Search with language
response = self.client.get(reverse("pedagogy:guide"), {"language": "EN"})
self.assertNotContains(response, text="PA00")
# Search with credit type
response = self.client.get(reverse("pedagogy:guide"), {"credit_type": "TM"})
self.assertNotContains(response, text="PA00")
class UVModerationFormTest(TestCase):
"""Assert access rights and if the form works well."""

View File

@ -27,7 +27,7 @@ from pedagogy.views import *
urlpatterns = [
# Urls displaying the actual application for visitors
path("", UVListView.as_view(), name="guide"),
path("", UVGuideView.as_view(), name="guide"),
path("uv/<int:uv_id>/", UVDetailFormView.as_view(), name="uv_detail"),
path(
"comment/<int:comment_id>/edit/",

81
pedagogy/utbm_api.py Normal file
View File

@ -0,0 +1,81 @@
"""Set of functions to interact with the UTBM UV api."""
import urllib
from django.conf import settings
from pedagogy.schemas import ShortUvList, UtbmFullUvSchema, UtbmShortUvSchema, UvSchema
def find_uv(lang, year, code) -> UvSchema | None:
"""Find an UV from the UTBM API."""
# query the UV list
base_url = settings.SITH_PEDAGOGY_UTBM_API
uvs_url = f"{base_url}/uvs/{lang}/{year}"
response = urllib.request.urlopen(uvs_url)
uvs: list[UtbmShortUvSchema] = ShortUvList.validate_json(response.read())
short_uv = next((uv for uv in uvs if uv.code == code), None)
if short_uv is None:
return None
# get detailed information about the UV
uv_url = f"{base_url}/uv/{lang}/{year}/{code}/{short_uv.code_formation}"
response = urllib.request.urlopen(uv_url)
full_uv = UtbmFullUvSchema.model_validate_json(response.read())
return _make_clean_uv(short_uv, full_uv)
def _make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvSchema:
"""Cleans the data up so that it corresponds to our data representation.
Some of the needed information are in the short uv schema, some
other in the full uv schema.
Thus we combine those information to obtain a data schema suitable
for our needs.
"""
if full_uv.departement == "Pôle Humanités":
department = "HUMA"
else:
department = {
"AL": "IMSI",
"AE": "EE",
"GI": "GI",
"GC": "EE",
"GM": "MC",
"TC": "TC",
"GP": "IMSI",
"ED": "EDIM",
"AI": "GI",
"AM": "MC",
}.get(short_uv.code_formation, "NA")
match short_uv.ouvert_printemps, short_uv.ouvert_automne:
case True, True:
semester = "AUTUMN_AND_SPRING"
case True, False:
semester = "SPRING"
case False, True:
semester = "AUTUMN"
case _:
semester = "CLOSED"
return UvSchema(
title=full_uv.libelle,
code=full_uv.code,
credit_type=short_uv.code_categorie,
semester=semester,
language=short_uv.code_langue.upper(),
credits=full_uv.credits_ects,
department=department,
hours_THE=next((i.nbh for i in full_uv.activites if i.code == "THE"), 0) // 60,
hours_TD=next((i.nbh for i in full_uv.activites if i.code == "TD"), 0) // 60,
hours_TP=next((i.nbh for i in full_uv.activites if i.code == "TP"), 0) // 60,
hours_TE=next((i.nbh for i in full_uv.activites if i.code == "TE"), 0) // 60,
hours_CM=next((i.nbh for i in full_uv.activites if i.code == "CM"), 0) // 60,
manager=full_uv.respo_automne or full_uv.respo_printemps or "",
objectives=full_uv.objectifs,
program=full_uv.programme,
skills=full_uv.acquisition_competences,
key_concepts=full_uv.acquisition_notions,
)

View File

@ -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):

231
poetry.lock generated
View File

@ -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"
@ -1165,6 +1225,24 @@ files = [
griffe = ">=0.47"
mkdocstrings = ">=0.25"
[[package]]
name = "model-bakery"
version = "1.18.2"
description = "Smart object creation facility for Django."
optional = false
python-versions = ">=3.8"
files = [
{file = "model_bakery-1.18.2-py3-none-any.whl", hash = "sha256:fd13a251d20db78b790d80f75350a73af5d199e5151227b5dd35cb76f2f08fe8"},
{file = "model_bakery-1.18.2.tar.gz", hash = "sha256:8f8ab4ba26a206ed848da9b1740b5006b5eeca8a67389efb28dbff37b362e802"},
]
[package.dependencies]
django = ">=4.2"
[package.extras]
docs = ["myst-parser", "sphinx", "sphinx-rtd-theme"]
test = ["black", "coverage", "mypy", "pillow", "pytest", "pytest-django", "ruff"]
[[package]]
name = "nodeenv"
version = "1.9.1"
@ -1525,6 +1603,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 +2472,4 @@ filelock = ">=3.4"
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "9038b84fac4dc7ce5aea0520d29e4d5705e2e55f3e165d2455ebc61eafe6cfe0"
content-hash = "f8e48947d004d61d63a345d36d7b42777030e1ac3687bb27f97b2c51318fcc8d"

View File

@ -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"
@ -66,6 +67,7 @@ freezegun = "^1.2.2" # used to test time-dependent code
pytest = "^8.2.2"
pytest-cov = "^5.0.0"
pytest-django = "^4.8.0"
model-bakery = "^1.18.2"
# deps used to work on the documentation
[tool.poetry.group.docs.dependencies]

47
sas/api.py Normal file
View File

@ -0,0 +1,47 @@
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 = list(
filters.filter(
Picture.objects.filter(is_moderated=True, asked_for_removal=False)
)
.distinct()
.order_by("-date")
)
for picture in pictures:
picture.full_size_url = picture.get_download_url()
picture.compressed_url = picture.get_download_compressed_url()
picture.thumb_url = picture.get_download_thumb_url()
return pictures

25
sas/schemas.py Normal file
View File

@ -0,0 +1,25 @@
from datetime import datetime
from ninja import FilterSchema, ModelSchema
from pydantic import Field
from core.schemas import SimpleUserSchema
from sas.models import Picture
class PictureFilterSchema(FilterSchema):
before_date: datetime | None = Field(None, q="date__lte")
after_date: datetime | None = Field(None, q="date__gte")
users_identified: set[int] | None = Field(None, q="people__user_id__in")
album_id: int | None = Field(None, q="parent_id")
class PictureSchema(ModelSchema):
class Meta:
model = Picture
fields = ["id", "name", "date", "size"]
author: SimpleUserSchema = Field(validation_alias="owner")
full_size_url: str
compressed_url: str
thumb_url: str

View File

@ -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.

0
sas/tests/__init__.py Normal file
View File

103
sas/tests/test_api.py Normal file
View File

@ -0,0 +1,103 @@
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from model_bakery.recipe import Recipe
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
from sas.models import Album, PeoplePictureRelation, Picture
class SasTest(TestCase):
@classmethod
def setUpTestData(cls):
Picture.objects.all().delete()
owner = User.objects.get(username="root")
cls.user_a = old_subscriber_user.make()
cls.user_b, cls.user_c = subscriber_user.make(_quantity=2)
picture_recipe = Recipe(
Picture, is_in_sas=True, is_folder=False, owner=owner, is_moderated=True
)
cls.album_a = baker.make(Album, is_in_sas=True)
cls.album_b = baker.make(Album, is_in_sas=True)
for album in cls.album_a, cls.album_b:
pictures = picture_recipe.make(parent=album, _quantity=5, _bulk_create=True)
baker.make(PeoplePictureRelation, picture=pictures[1], user=cls.user_a)
baker.make(PeoplePictureRelation, picture=pictures[2], user=cls.user_a)
baker.make(PeoplePictureRelation, picture=pictures[2], user=cls.user_b)
baker.make(PeoplePictureRelation, picture=pictures[3], user=cls.user_b)
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_a)
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_b)
baker.make(PeoplePictureRelation, picture=pictures[4], user=cls.user_c)
def test_anonymous_user_forbidden(self):
res = self.client.get(reverse("api:pictures"))
assert res.status_code == 403
def test_filter_by_album(self):
self.client.force_login(self.user_b)
res = self.client.get(reverse("api:pictures") + f"?album_id={self.album_a.id}")
assert res.status_code == 200
expected = list(
self.album_a.children_pictures.order_by("-date").values_list(
"id", flat=True
)
)
assert [i["id"] for i in res.json()] == expected
def test_filter_by_user(self):
self.client.force_login(self.user_b)
res = self.client.get(
reverse("api:pictures") + f"?users_identified={self.user_a.id}"
)
assert res.status_code == 200
expected = list(
self.user_a.pictures.order_by("-picture__date").values_list(
"picture_id", flat=True
)
)
assert [i["id"] for i in res.json()] == expected
def test_filter_by_multiple_user(self):
self.client.force_login(self.user_b)
res = self.client.get(
reverse("api:pictures")
+ f"?users_identified={self.user_a.id}&users_identified={self.user_b.id}"
)
assert res.status_code == 200
expected = list(
self.user_a.pictures.union(self.user_b.pictures.all())
.order_by("-picture__date")
.values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()] == expected
def test_not_subscribed_user(self):
"""Test that a user that is not subscribed can only its own pictures."""
self.client.force_login(self.user_a)
res = self.client.get(
reverse("api:pictures") + f"?users_identified={self.user_a.id}"
)
assert res.status_code == 200
expected = list(
self.user_a.pictures.order_by("-picture__date").values_list(
"picture_id", flat=True
)
)
assert [i["id"] for i in res.json()] == expected
# trying to access the pictures of someone else
res = self.client.get(
reverse("api:pictures") + f"?users_identified={self.user_b.id}"
)
assert res.status_code == 403
# trying to access the pictures of someone else shouldn't success,
# even if mixed with owned pictures
res = self.client.get(
reverse("api:pictures")
+ f"?users_identified={self.user_a.id}&users_identified={self.user_b.id}"
)
assert res.status_code == 403

View File

@ -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",
@ -474,8 +473,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"

View File

@ -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)

View File

@ -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

View File

@ -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."""