mirror of
https://github.com/ae-utbm/sith.git
synced 2025-11-10 14:03:12 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
992b6d6b79 | ||
|
|
a7284c936b | ||
|
|
53f7bf08d3 | ||
|
|
2dce0674a2 | ||
|
|
0a3e0fa755 | ||
|
|
2984e14746 | ||
|
|
99f79487aa | ||
|
|
075c6f16ec | ||
|
|
2e9e1b6a78 | ||
|
|
02f7e10729 | ||
|
|
0ba84c4750 | ||
|
|
1e25560a1c | ||
|
|
530e851bd1 | ||
|
|
1d2a90a751 | ||
|
|
61d51a08d2 | ||
|
|
99b86fb27d | ||
|
|
ec9bfd3b7e | ||
|
|
7ef16f027a | ||
|
|
282c4b8f26 | ||
|
|
8cbf42d714 | ||
|
|
710b4aa942 | ||
|
|
5fee2e4720 |
@@ -6,6 +6,8 @@ from api.models import ApiClient, ApiKey
|
|||||||
|
|
||||||
|
|
||||||
class ApiKeyAuth(APIKeyHeader):
|
class ApiKeyAuth(APIKeyHeader):
|
||||||
|
"""Authentication through client api keys."""
|
||||||
|
|
||||||
param_name = "X-APIKey"
|
param_name = "X-APIKey"
|
||||||
|
|
||||||
def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None:
|
def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None:
|
||||||
|
|||||||
48
api/tests/test_mixed_auth.py
Normal file
48
api/tests/test_mixed_auth.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import pytest
|
||||||
|
from django.test import Client
|
||||||
|
from django.urls import path
|
||||||
|
from model_bakery import baker
|
||||||
|
from ninja import NinjaAPI
|
||||||
|
from ninja.security import SessionAuth
|
||||||
|
|
||||||
|
from api.auth import ApiKeyAuth
|
||||||
|
from api.hashers import generate_key
|
||||||
|
from api.models import ApiClient, ApiKey
|
||||||
|
|
||||||
|
api = NinjaAPI()
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("", auth=[ApiKeyAuth(), SessionAuth()])
|
||||||
|
def post_method(*args, **kwargs) -> None:
|
||||||
|
"""Dummy POST route authenticated by either api key or session cookie."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [path("", api.urls)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.urls(__name__)
|
||||||
|
@pytest.mark.parametrize("user_logged_in", [False, True])
|
||||||
|
def test_csrf_token(user_logged_in):
|
||||||
|
"""Test that CSRF check happens only when no api key is used."""
|
||||||
|
client = Client(enforce_csrf_checks=True)
|
||||||
|
key, hashed = generate_key()
|
||||||
|
api_client = baker.make(ApiClient)
|
||||||
|
baker.make(ApiKey, client=api_client, hashed_key=hashed)
|
||||||
|
if user_logged_in:
|
||||||
|
client.force_login(api_client.owner)
|
||||||
|
|
||||||
|
response = client.post("")
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json()["detail"] == "CSRF check Failed"
|
||||||
|
|
||||||
|
# if using a valid API key, CSRF check should not occur
|
||||||
|
response = client.post("", headers={"X-APIKey": key})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# if using a wrong API key, ApiKeyAuth should fail,
|
||||||
|
# leading to a fallback into SessionAuth and a CSRF check
|
||||||
|
response = client.post("", headers={"X-APIKey": generate_key()[0]})
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json()["detail"] == "CSRF check Failed"
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from ninja.security import SessionAuth
|
||||||
from ninja_extra import NinjaExtraAPI
|
from ninja_extra import NinjaExtraAPI
|
||||||
|
|
||||||
api = NinjaExtraAPI(
|
api = NinjaExtraAPI(
|
||||||
@@ -5,6 +6,6 @@ api = NinjaExtraAPI(
|
|||||||
description="Portail Interactif de Communication avec les Outils Numériques",
|
description="Portail Interactif de Communication avec les Outils Numériques",
|
||||||
version="0.2.0",
|
version="0.2.0",
|
||||||
urls_namespace="api",
|
urls_namespace="api",
|
||||||
csrf=True,
|
auth=[SessionAuth()],
|
||||||
)
|
)
|
||||||
api.auto_discover_controllers()
|
api.auto_discover_controllers()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class ClubController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[SimpleClubSchema],
|
response=PaginatedResponseSchema[SimpleClubSchema],
|
||||||
auth=[SessionAuth(), ApiKeyAuth()],
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
url_name="search_club",
|
url_name="search_club",
|
||||||
)
|
)
|
||||||
@@ -27,7 +27,7 @@ class ClubController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/{int:club_id}",
|
"/{int:club_id}",
|
||||||
response=ClubSchema,
|
response=ClubSchema,
|
||||||
auth=[SessionAuth(), ApiKeyAuth()],
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
permissions=[HasPerm("club.view_club")],
|
permissions=[HasPerm("club.view_club")],
|
||||||
url_name="fetch_club",
|
url_name="fetch_club",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,18 @@
|
|||||||
{{ club.short_description }}
|
{{ club.short_description }}
|
||||||
{%- endblock %}
|
{%- endblock %}
|
||||||
|
|
||||||
|
{% block metatags %}
|
||||||
|
<meta property="og:url" content="{{ request.build_absolute_uri(club.get_absolute_url()) }}" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="{{ club.name }}" />
|
||||||
|
<meta property="og:description" content="{{ club.short_description }}" />
|
||||||
|
{% if club.logo %}
|
||||||
|
<meta property="og:image" content="{{ request.build_absolute_uri(club.logo.url) }}" />
|
||||||
|
{% else %}
|
||||||
|
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="club_detail">
|
<div id="club_detail">
|
||||||
{% if club.logo %}
|
{% if club.logo %}
|
||||||
@@ -17,7 +29,7 @@
|
|||||||
{% if page_revision %}
|
{% if page_revision %}
|
||||||
{{ page_revision|markdown }}
|
{{ page_revision|markdown }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<h3>{% trans %}Club{% endtrans %}</h3>
|
<h3>{{ club.name }}</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
10
com/api.py
10
com/api.py
@@ -5,7 +5,6 @@ from django.utils.cache import add_never_cache_headers
|
|||||||
from ninja import Query
|
from ninja import Query
|
||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||||
from ninja_extra.permissions import IsAuthenticated
|
|
||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
from api.permissions import HasPerm
|
from api.permissions import HasPerm
|
||||||
@@ -17,17 +16,13 @@ from core.views.files import send_raw_file
|
|||||||
|
|
||||||
@api_controller("/calendar")
|
@api_controller("/calendar")
|
||||||
class CalendarController(ControllerBase):
|
class CalendarController(ControllerBase):
|
||||||
@route.get("/internal.ics", url_name="calendar_internal")
|
@route.get("/internal.ics", auth=None, url_name="calendar_internal")
|
||||||
def calendar_internal(self):
|
def calendar_internal(self):
|
||||||
response = send_raw_file(IcsCalendar.get_internal())
|
response = send_raw_file(IcsCalendar.get_internal())
|
||||||
add_never_cache_headers(response)
|
add_never_cache_headers(response)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@route.get(
|
@route.get("/unpublished.ics", url_name="calendar_unpublished")
|
||||||
"/unpublished.ics",
|
|
||||||
permissions=[IsAuthenticated],
|
|
||||||
url_name="calendar_unpublished",
|
|
||||||
)
|
|
||||||
def calendar_unpublished(self):
|
def calendar_unpublished(self):
|
||||||
response = HttpResponse(
|
response = HttpResponse(
|
||||||
IcsCalendar.get_unpublished(self.context.request.user),
|
IcsCalendar.get_unpublished(self.context.request.user),
|
||||||
@@ -74,6 +69,7 @@ class NewsController(ControllerBase):
|
|||||||
|
|
||||||
@route.get(
|
@route.get(
|
||||||
"/date",
|
"/date",
|
||||||
|
auth=None,
|
||||||
url_name="fetch_news_dates",
|
url_name="fetch_news_dates",
|
||||||
response=PaginatedResponseSchema[NewsDateSchema],
|
response=PaginatedResponseSchema[NewsDateSchema],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
{% from 'core/macros.jinja' import user_profile_link, facebook_share, tweet, link_news_logo, gen_news_metatags %}
|
{% from 'core/macros.jinja' import user_profile_link, link_news_logo %}
|
||||||
{% from "com/macros.jinja" import news_moderation_alert %}
|
{% from "com/macros.jinja" import news_moderation_alert %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% trans %}News{% endtrans %} -
|
{% trans %}News{% endtrans %} - {{ object.title }}
|
||||||
{{ object.title }}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block description %}{{ news.summary }}{% endblock %}
|
||||||
{{ super() }}
|
|
||||||
{{ gen_news_metatags(news) }}
|
{% block metatags %}
|
||||||
|
<meta property="og:url" content="{{ news.get_full_url() }}" />
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="article:section" content="{% trans %}News{% endtrans %}" />
|
||||||
|
<meta property="og:title" content="{{ news.title }}" />
|
||||||
|
<meta property="og:description" content="{{ news.summary }}" />
|
||||||
|
<meta property="og:image" content="{{ request.build_absolute_uri(link_news_logo(news)) }}" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
@@ -44,8 +49,14 @@
|
|||||||
<div><em>{{ news.summary|markdown }}</em></div>
|
<div><em>{{ news.summary|markdown }}</em></div>
|
||||||
<br/>
|
<br/>
|
||||||
<div>{{ news.content|markdown }}</div>
|
<div>{{ news.content|markdown }}</div>
|
||||||
{{ facebook_share(news) }}
|
<a
|
||||||
{{ tweet(news) }}
|
rel="nofollow"
|
||||||
|
target="#"
|
||||||
|
class="share_button facebook"
|
||||||
|
href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}"
|
||||||
|
>
|
||||||
|
{% trans %}Share on Facebook{% endtrans %}
|
||||||
|
</a>
|
||||||
<div class="news_meta">
|
<div class="news_meta">
|
||||||
<p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
|
<p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
|
||||||
{% if news.moderator %}
|
{% if news.moderator %}
|
||||||
|
|||||||
@@ -700,7 +700,7 @@ class PosterModerateView(PermissionRequiredMixin, ComTabsMixin, View):
|
|||||||
parsed = urlparse(referer)
|
parsed = urlparse(referer)
|
||||||
if parsed.netloc == settings.SITH_URL:
|
if parsed.netloc == settings.SITH_URL:
|
||||||
return redirect(parsed.path)
|
return redirect(parsed.path)
|
||||||
return redirect(reverse("com:poster_list"))
|
return redirect("com:poster_list")
|
||||||
|
|
||||||
|
|
||||||
class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView):
|
class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView):
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class SithFileController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[SithFileSchema],
|
response=PaginatedResponseSchema[SithFileSchema],
|
||||||
auth=[SessionAuth(), ApiKeyAuth()],
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
@@ -112,7 +112,7 @@ class GroupController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[GroupSchema],
|
response=PaginatedResponseSchema[GroupSchema],
|
||||||
auth=[SessionAuth(), ApiKeyAuth()],
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import types
|
import types
|
||||||
import warnings
|
|
||||||
from typing import TYPE_CHECKING, Any, LiteralString
|
from typing import TYPE_CHECKING, Any, LiteralString
|
||||||
|
|
||||||
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
|
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
|
||||||
@@ -147,45 +146,6 @@ class GenericContentPermissionMixinBuilder(View):
|
|||||||
return super().dispatch(request, *arg, **kwargs)
|
return super().dispatch(request, *arg, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class CanCreateMixin(View):
|
|
||||||
"""Protect any child view that would create an object.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
PermissionDenied:
|
|
||||||
If the user has not the necessary permission
|
|
||||||
to create the object of the view.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init_subclass__(cls, **kwargs):
|
|
||||||
warnings.warn(
|
|
||||||
f"{cls.__name__} is deprecated and should be replaced "
|
|
||||||
"by other permission verification mecanism.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2,
|
|
||||||
)
|
|
||||||
super().__init_subclass__(**kwargs)
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
warnings.warn(
|
|
||||||
f"{self.__class__.__name__} is deprecated and should be replaced "
|
|
||||||
"by other permission verification mecanism.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2,
|
|
||||||
)
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def dispatch(self, request, *arg, **kwargs):
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
raise PermissionDenied
|
|
||||||
return super().dispatch(request, *arg, **kwargs)
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
|
||||||
obj = form.instance
|
|
||||||
if can_edit_prop(obj, self.request.user):
|
|
||||||
return super().form_valid(form)
|
|
||||||
raise PermissionDenied
|
|
||||||
|
|
||||||
|
|
||||||
class CanEditPropMixin(GenericContentPermissionMixinBuilder):
|
class CanEditPropMixin(GenericContentPermissionMixinBuilder):
|
||||||
"""Ensure the user has owner permissions on the child view object.
|
"""Ensure the user has owner permissions on the child view object.
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
#
|
|
||||||
# Copyright 2018
|
|
||||||
# - Skia <skia@libskia.so>
|
|
||||||
#
|
|
||||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
|
|
||||||
# http://ae.utbm.fr.
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify it under
|
|
||||||
# the terms of the GNU General Public License a published by the Free Software
|
|
||||||
# Foundation; either version 3 of the License, or (at your option) any later
|
|
||||||
# version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License along with
|
|
||||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
|
||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
|
||||||
#
|
|
||||||
#
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from core.models import SithFile
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Recursively check the file system with respect to the DB"
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
"ids", metavar="ID", type=int, nargs="+", help="The file IDs to process"
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
files = SithFile.objects.filter(id__in=options["ids"]).all()
|
|
||||||
for f in files:
|
|
||||||
f._check_fs()
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#
|
|
||||||
# Copyright 2018
|
|
||||||
# - Skia <skia@libskia.so>
|
|
||||||
#
|
|
||||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
|
|
||||||
# http://ae.utbm.fr.
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify it under
|
|
||||||
# the terms of the GNU General Public License a published by the Free Software
|
|
||||||
# Foundation; either version 3 of the License, or (at your option) any later
|
|
||||||
# version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License along with
|
|
||||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
|
||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
|
||||||
#
|
|
||||||
#
|
|
||||||
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from core.models import SithFile
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Recursively repair the file system with respect to the DB"
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
"ids", metavar="ID", type=int, nargs="+", help="The file IDs to process"
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
files = SithFile.objects.filter(id__in=options["ids"]).all()
|
|
||||||
for f in files:
|
|
||||||
f._repair_fs()
|
|
||||||
132
core/models.py
132
core/models.py
@@ -23,14 +23,12 @@
|
|||||||
#
|
#
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import string
|
import string
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Optional, Self
|
from typing import TYPE_CHECKING, Self
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -97,48 +95,6 @@ def validate_promo(value: int) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None:
|
|
||||||
"""Search for a group by its primary key or its name.
|
|
||||||
Either one of the two must be set.
|
|
||||||
|
|
||||||
The result is cached for the default duration (should be 5 minutes).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pk: The primary key of the group
|
|
||||||
name: The name of the group
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The group if it exists, else None
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If no group matches the criteria
|
|
||||||
"""
|
|
||||||
if pk is None and name is None:
|
|
||||||
raise ValueError("Either pk or name must be set")
|
|
||||||
|
|
||||||
# replace space characters to hide warnings with memcached backend
|
|
||||||
pk_or_name: str | int = pk if pk is not None else name.replace(" ", "_")
|
|
||||||
group = cache.get(f"sith_group_{pk_or_name}")
|
|
||||||
|
|
||||||
if group == "not_found":
|
|
||||||
# Using None as a cache value is a little bit tricky,
|
|
||||||
# so we use a special string to represent None
|
|
||||||
return None
|
|
||||||
elif group is not None:
|
|
||||||
return group
|
|
||||||
# if this point is reached, the group is not in cache
|
|
||||||
if pk is not None:
|
|
||||||
group = Group.objects.filter(pk=pk).first()
|
|
||||||
else:
|
|
||||||
group = Group.objects.filter(name=name).first()
|
|
||||||
if group is not None:
|
|
||||||
name = group.name.replace(" ", "_")
|
|
||||||
cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
|
|
||||||
else:
|
|
||||||
cache.set(f"sith_group_{pk_or_name}", "not_found")
|
|
||||||
return group
|
|
||||||
|
|
||||||
|
|
||||||
class BanGroup(AuthGroup):
|
class BanGroup(AuthGroup):
|
||||||
"""An anti-group, that removes permissions instead of giving them.
|
"""An anti-group, that removes permissions instead of giving them.
|
||||||
|
|
||||||
@@ -382,19 +338,18 @@ class User(AbstractUser):
|
|||||||
Returns:
|
Returns:
|
||||||
True if the user is the group, else False
|
True if the user is the group, else False
|
||||||
"""
|
"""
|
||||||
if pk is not None:
|
if not pk and not name:
|
||||||
group: Optional[Group] = get_group(pk=pk)
|
|
||||||
elif name is not None:
|
|
||||||
group: Optional[Group] = get_group(name=name)
|
|
||||||
else:
|
|
||||||
raise ValueError("You must either provide the id or the name of the group")
|
raise ValueError("You must either provide the id or the name of the group")
|
||||||
if group is None:
|
group_id: int | None = (
|
||||||
|
pk or Group.objects.filter(name=name).values_list("id", flat=True).first()
|
||||||
|
)
|
||||||
|
if group_id is None:
|
||||||
return False
|
return False
|
||||||
if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:
|
if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
|
||||||
return self.is_subscribed
|
return self.is_subscribed
|
||||||
if group.id == settings.SITH_GROUP_ROOT_ID:
|
if group_id == settings.SITH_GROUP_ROOT_ID:
|
||||||
return self.is_root
|
return self.is_root
|
||||||
return group in self.cached_groups
|
return any(g.id == group_id for g in self.cached_groups)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def cached_groups(self) -> list[Group]:
|
def cached_groups(self) -> list[Group]:
|
||||||
@@ -454,14 +409,6 @@ class User(AbstractUser):
|
|||||||
else:
|
else:
|
||||||
raise ValidationError(_("A user with that username already exists"))
|
raise ValidationError(_("A user with that username already exists"))
|
||||||
|
|
||||||
def get_profile(self):
|
|
||||||
return {
|
|
||||||
"last_name": self.last_name,
|
|
||||||
"first_name": self.first_name,
|
|
||||||
"nick_name": self.nick_name,
|
|
||||||
"date_of_birth": self.date_of_birth,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_short_name(self):
|
def get_short_name(self):
|
||||||
"""Returns the short name for the user."""
|
"""Returns the short name for the user."""
|
||||||
if self.nick_name:
|
if self.nick_name:
|
||||||
@@ -689,8 +636,8 @@ class AnonymousUser(AuthAnonymousUser):
|
|||||||
if pk is not None:
|
if pk is not None:
|
||||||
return pk == allowed_id
|
return pk == allowed_id
|
||||||
elif name is not None:
|
elif name is not None:
|
||||||
group = get_group(name=name)
|
group = Group.objects.get(id=allowed_id)
|
||||||
return group is not None and group.id == allowed_id
|
return group.name == name
|
||||||
else:
|
else:
|
||||||
raise ValueError("You must either provide the id or the name of the group")
|
raise ValueError("You must either provide the id or the name of the group")
|
||||||
|
|
||||||
@@ -1016,63 +963,6 @@ class SithFile(models.Model):
|
|||||||
self.clean()
|
self.clean()
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def _repair_fs(self):
|
|
||||||
"""Rebuilds recursively the filesystem as it should be regarding the DB tree."""
|
|
||||||
if self.is_folder:
|
|
||||||
for c in self.children.all():
|
|
||||||
c._repair_fs()
|
|
||||||
return
|
|
||||||
elif not self._check_path_consistence():
|
|
||||||
# First get future parent path and the old file name
|
|
||||||
# Prepend "." so that we match all relative handling of Django's
|
|
||||||
# file storage
|
|
||||||
parent_path = "." + self.parent.get_full_path()
|
|
||||||
parent_full_path = settings.MEDIA_ROOT + parent_path
|
|
||||||
os.makedirs(parent_full_path, exist_ok=True)
|
|
||||||
old_path = self.file.name # Should be relative: "./users/skia/bleh.jpg"
|
|
||||||
new_path = "." + self.get_full_path()
|
|
||||||
try:
|
|
||||||
# Make this atomic, so that a FS problem rolls back the DB change
|
|
||||||
with transaction.atomic():
|
|
||||||
# Set the new filesystem path
|
|
||||||
self.file.name = new_path
|
|
||||||
self.save()
|
|
||||||
# Really move at the FS level
|
|
||||||
if os.path.exists(parent_full_path):
|
|
||||||
os.rename(
|
|
||||||
settings.MEDIA_ROOT + old_path,
|
|
||||||
settings.MEDIA_ROOT + new_path,
|
|
||||||
)
|
|
||||||
# Empty directories may remain, but that's not really a
|
|
||||||
# problem, and that can be solved with a simple shell
|
|
||||||
# command: `find . -type d -empty -delete`
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(e)
|
|
||||||
|
|
||||||
def _check_path_consistence(self):
|
|
||||||
file_path = str(self.file)
|
|
||||||
file_full_path = settings.MEDIA_ROOT + file_path
|
|
||||||
db_path = ".%s" % self.get_full_path()
|
|
||||||
if not os.path.exists(file_full_path):
|
|
||||||
print("%s: WARNING: real file does not exists!" % self.id) # noqa T201
|
|
||||||
print("file path: %s" % file_path, end="") # noqa T201
|
|
||||||
print(" db path: %s" % db_path) # noqa T201
|
|
||||||
return False
|
|
||||||
if file_path != db_path:
|
|
||||||
print("%s: " % self.id, end="") # noqa T201
|
|
||||||
print("file path: %s" % file_path, end="") # noqa T201
|
|
||||||
print(" db path: %s" % db_path) # noqa T201
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _check_fs(self):
|
|
||||||
if self.is_folder:
|
|
||||||
for c in self.children.all():
|
|
||||||
c._check_fs()
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
self._check_path_consistence()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_file(self):
|
def is_file(self):
|
||||||
return not self.is_folder
|
return not self.is_folder
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ footer.bottom-links {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: $primary-neutral-dark-color;
|
background-color: $primary-neutral-dark-color;
|
||||||
box-shadow: $shadow-color 0 0 15px;
|
box-shadow: black 0 8px 15px;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: $white-color;
|
color: $white-color;
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ $hovered-red-text-color: #ff4d4d;
|
|||||||
.header {
|
.header {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background-color: $deepblue;
|
background-color: $deepblue;
|
||||||
box-shadow: 3px 3px 3px 0 #dfdfdf;
|
box-shadow: black 0 1px 3px 0,
|
||||||
|
black 0 4px 8px 3px;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -99,7 +100,7 @@ $hovered-red-text-color: #ff4d4d;
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background-color: $deepblue;
|
background-color: transparent;
|
||||||
width: 45px;
|
width: 45px;
|
||||||
height: 25px;
|
height: 25px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -331,7 +332,7 @@ $hovered-red-text-color: #ff4d4d;
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 3px 3px 3px 0 #767676;
|
@include shadow;
|
||||||
|
|
||||||
>ul {
|
>ul {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|||||||
BIN
core/static/core/img/gala25_background.webp
Normal file
BIN
core/static/core/img/gala25_background.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
BIN
core/static/core/img/gala25_logo.webp
Normal file
BIN
core/static/core/img/gala25_logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -271,8 +271,9 @@ body {
|
|||||||
|
|
||||||
/*--------------------------------CONTENT------------------------------*/
|
/*--------------------------------CONTENT------------------------------*/
|
||||||
#content {
|
#content {
|
||||||
padding: 1em 1%;
|
padding: 1.5em 2%;
|
||||||
box-shadow: $shadow-color 0 5px 10px;
|
border-radius: 5px;
|
||||||
|
box-shadow: black 0 8px 15px;
|
||||||
background: $white-color;
|
background: $white-color;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,22 @@
|
|||||||
{% block head %}
|
{% block head %}
|
||||||
<title>{% block title %}Association des Étudiants de l'UTBM{% endblock %}</title>
|
<title>{% block title %}Association des Étudiants de l'UTBM{% endblock %}</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="description" content="{% block description -%}
|
<meta
|
||||||
{% trans trimmed %}
|
name="description"
|
||||||
AE UTBM is a voluntary organisation run by UTBM students.
|
content="{% block description -%}
|
||||||
It organises student life at UTBM and manages its student facilities.
|
{% trans trimmed %}
|
||||||
{% endtrans %}
|
AE UTBM is a voluntary organisation run by UTBM students.
|
||||||
{%- endblock %}">
|
It organises student life at UTBM and manages its student facilities.
|
||||||
|
{% endtrans %}
|
||||||
|
{%- endblock %}"
|
||||||
|
>
|
||||||
|
<meta property="og:site_name" content="Association des Étudiants de l'UTBM" />
|
||||||
|
{% block metatags %}
|
||||||
|
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="Association des Étudiants de l'UTBM" />
|
||||||
|
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
|
||||||
|
{% endblock %}
|
||||||
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/base.css') }}">
|
<link rel="stylesheet" href="{{ static('core/base.css') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
|
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
|
||||||
@@ -34,6 +44,18 @@
|
|||||||
|
|
||||||
{% block additional_css %}{% endblock %}
|
{% block additional_css %}{% endblock %}
|
||||||
{% block additional_js %}{% endblock %}
|
{% block additional_js %}{% endblock %}
|
||||||
|
<style>
|
||||||
|
{# background image must be declared here, because the static names are
|
||||||
|
changed during the static collection step,
|
||||||
|
which means we must gather them with the `static` template function #}
|
||||||
|
.header {
|
||||||
|
background-image: url("{{ static("core/img/gala25_background.webp") }}");
|
||||||
|
background-position-y: 80%; {# There are more stars in this part of the picture #}
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-image: url("{{ static("core/img/gala25_background.webp") }}");
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-logo">
|
<div class="header-logo">
|
||||||
<a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static('core/img/logo_no_text.png') }}')">
|
<a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static("core/img/gala25_logo.webp") }}')">
|
||||||
|
|
||||||
</a>
|
</a>
|
||||||
<a class="header-logo-text" href="{{ url('core:index') }}">
|
<a class="header-logo-text" href="{{ url('core:index') }}">
|
||||||
|
|||||||
@@ -21,20 +21,6 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<h2>{% trans %}Save{% endtrans %}</h2>
|
<h2>{% trans %}Save{% endtrans %}</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if messages %}
|
|
||||||
<div x-data="{show_alert: true}" class="alert alert-green" x-show="show_alert" x-transition>
|
|
||||||
<span class="alert-main">
|
|
||||||
{% for message in messages %}
|
|
||||||
{% if message.level_tag == "success" %}
|
|
||||||
{{ message }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</span>
|
|
||||||
<span class="clickable" @click="show_alert = false">
|
|
||||||
<i class="fa fa-close"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<form action="" method="post" enctype="multipart/form-data">
|
<form action="" method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p() }}
|
{{ form.as_p() }}
|
||||||
|
|||||||
@@ -13,30 +13,11 @@
|
|||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro link_news_logo(news) -%}
|
{% macro link_news_logo(news) -%}
|
||||||
{% if news.club.logo -%}
|
{%- if news.club.logo -%}
|
||||||
{{ news.club.logo.url }}
|
{{ news.club.logo.url }}
|
||||||
{% else -%}
|
{%- else -%}
|
||||||
{{ static("com/img/news.png") }}
|
{{ static("com/img/news.png") }}
|
||||||
{% endif %}
|
{%- endif -%}
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{% macro gen_news_metatags(news) -%}
|
|
||||||
<meta name="twitter:card" content="summary" />
|
|
||||||
<meta name="twitter:site" content="{{ settings.SITH_TWITTER }}" />
|
|
||||||
<meta name="twitter:creator" content= "{{ settings.SITH_TWITTER }}" />
|
|
||||||
<meta property="og:url" content="{{ news.get_full_url() }}" />
|
|
||||||
<meta property="og:type" content="article" />
|
|
||||||
<meta property="og:title" content="{{ news.title }}" />
|
|
||||||
<meta property="og:description" content="{{ news.summary }}" />
|
|
||||||
<meta property="og:image" content="{{ "https://%s%s" % (settings.SITH_URL, link_news_logo(news)) }}" />
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{% macro facebook_share(news) -%}
|
|
||||||
<a rel="nofollow" target="#" class="share_button facebook" href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}">{% trans %}Share on Facebook{% endtrans %}</a>
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{% macro tweet(news) -%}
|
|
||||||
<a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a>
|
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro user_mini_profile(user) %}
|
{% macro user_mini_profile(user) %}
|
||||||
|
|||||||
@@ -12,6 +12,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block metatags %}
|
||||||
|
{% if page %}
|
||||||
|
<meta property="og:url" content="{{ request.build_absolute_uri(page.get_absolute_url()) }}" />
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="article:section" content="{% trans %}Page{% endtrans %}" />
|
||||||
|
<meta property="og:title" content="{{ page.get_display_name() }}" />
|
||||||
|
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
|
||||||
|
{% else %}
|
||||||
|
{{ super() }}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{%- macro print_page_name(page) -%}
|
{%- macro print_page_name(page) -%}
|
||||||
{%- if page -%}
|
{%- if page -%}
|
||||||
{{ print_page_name(page.parent) }} >
|
{{ print_page_name(page.parent) }} >
|
||||||
|
|||||||
@@ -421,18 +421,16 @@ class TestUserIsInGroup(TestCase):
|
|||||||
|
|
||||||
# clear the cached property `User.cached_groups`
|
# clear the cached property `User.cached_groups`
|
||||||
self.public_user.__dict__.pop("cached_groups", None)
|
self.public_user.__dict__.pop("cached_groups", None)
|
||||||
cache.clear()
|
|
||||||
# Test when the user is in the group
|
# Test when the user is in the group
|
||||||
with self.assertNumQueries(2):
|
with self.assertNumQueries(1):
|
||||||
self.public_user.is_in_group(pk=group_in.id)
|
self.public_user.is_in_group(pk=group_in.id)
|
||||||
with self.assertNumQueries(0):
|
with self.assertNumQueries(0):
|
||||||
self.public_user.is_in_group(pk=group_in.id)
|
self.public_user.is_in_group(pk=group_in.id)
|
||||||
|
|
||||||
group_not_in = baker.make(Group)
|
group_not_in = baker.make(Group)
|
||||||
self.public_user.__dict__.pop("cached_groups", None)
|
self.public_user.__dict__.pop("cached_groups", None)
|
||||||
cache.clear()
|
|
||||||
# Test when the user is not in the group
|
# Test when the user is not in the group
|
||||||
with self.assertNumQueries(2):
|
with self.assertNumQueries(1):
|
||||||
self.public_user.is_in_group(pk=group_not_in.id)
|
self.public_user.is_in_group(pk=group_not_in.id)
|
||||||
with self.assertNumQueries(0):
|
with self.assertNumQueries(0):
|
||||||
self.public_user.is_in_group(pk=group_not_in.id)
|
self.public_user.is_in_group(pk=group_not_in.id)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class TestFetchFamilyApi(TestCase):
|
|||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("api:family_graph", args=[self.main_user.id])
|
reverse("api:family_graph", args=[self.main_user.id])
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 401
|
||||||
|
|
||||||
self.client.force_login(baker.make(User)) # unsubscribed user
|
self.client.force_login(baker.make(User)) # unsubscribed user
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ def test_apply_rights_recursively():
|
|||||||
SimpleUploadedFile(
|
SimpleUploadedFile(
|
||||||
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
|
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
|
||||||
),
|
),
|
||||||
403,
|
401,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
lambda: baker.make(User),
|
lambda: baker.make(User),
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class CounterController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[SimplifiedCounterSchema],
|
response=PaginatedResponseSchema[SimplifiedCounterSchema],
|
||||||
auth=[SessionAuth(), ApiKeyAuth()],
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
@@ -77,7 +77,7 @@ class ProductController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[SimpleProductSchema],
|
response=PaginatedResponseSchema[SimpleProductSchema],
|
||||||
auth=[SessionAuth(), ApiKeyAuth()],
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
@@ -117,7 +117,7 @@ class ProductTypeController(ControllerBase):
|
|||||||
def fetch_all(self):
|
def fetch_all(self):
|
||||||
return ProductType.objects.order_by("order")
|
return ProductType.objects.order_by("order")
|
||||||
|
|
||||||
@route.patch("/{type_id}/move")
|
@route.patch("/{type_id}/move", url_name="reorder_product_type")
|
||||||
def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
|
def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
|
||||||
"""Change the order of a product type.
|
"""Change the order of a product type.
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ from django.conf import settings
|
|||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from model_bakery import baker, seq
|
from model_bakery import baker, seq
|
||||||
from ninja_extra.testing import TestClient
|
|
||||||
|
|
||||||
from core.baker_recipes import board_user, subscriber_user
|
from core.baker_recipes import board_user, subscriber_user
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from counter.api import ProductTypeController
|
|
||||||
from counter.models import ProductType
|
from counter.models import ProductType
|
||||||
|
|
||||||
|
|
||||||
@@ -19,24 +17,43 @@ def product_types(db) -> list[ProductType]:
|
|||||||
return baker.make(ProductType, _quantity=5, order=seq(0))
|
return baker.make(ProductType, _quantity=5, order=seq(0))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def counter_admin_client(db, client: Client) -> Client:
|
||||||
|
client.force_login(
|
||||||
|
baker.make(
|
||||||
|
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_fetch_product_types(product_types: list[ProductType]):
|
def test_fetch_product_types(
|
||||||
|
counter_admin_client: Client, product_types: list[ProductType]
|
||||||
|
):
|
||||||
"""Test that the API returns the right products in the right order"""
|
"""Test that the API returns the right products in the right order"""
|
||||||
client = TestClient(ProductTypeController)
|
response = counter_admin_client.get(reverse("api:fetch_product_types"))
|
||||||
response = client.get("")
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert [i["id"] for i in response.json()] == [t.id for t in product_types]
|
assert [i["id"] for i in response.json()] == [t.id for t in product_types]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_move_below_product_type(product_types: list[ProductType]):
|
def test_move_below_product_type(
|
||||||
|
counter_admin_client: Client, product_types: list[ProductType]
|
||||||
|
):
|
||||||
"""Test that moving a product below another works"""
|
"""Test that moving a product below another works"""
|
||||||
client = TestClient(ProductTypeController)
|
response = counter_admin_client.patch(
|
||||||
response = client.patch(
|
reverse(
|
||||||
f"/{product_types[-1].id}/move", query={"below": product_types[0].id}
|
"api:reorder_product_type",
|
||||||
|
kwargs={"type_id": product_types[-1].id},
|
||||||
|
query={"below": product_types[0].id},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
new_order = [i["id"] for i in client.get("").json()]
|
new_order = [
|
||||||
|
i["id"]
|
||||||
|
for i in counter_admin_client.get(reverse("api:fetch_product_types")).json()
|
||||||
|
]
|
||||||
assert new_order == [
|
assert new_order == [
|
||||||
product_types[0].id,
|
product_types[0].id,
|
||||||
product_types[-1].id,
|
product_types[-1].id,
|
||||||
@@ -45,14 +62,22 @@ def test_move_below_product_type(product_types: list[ProductType]):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_move_above_product_type(product_types: list[ProductType]):
|
def test_move_above_product_type(
|
||||||
|
counter_admin_client: Client, product_types: list[ProductType]
|
||||||
|
):
|
||||||
"""Test that moving a product above another works"""
|
"""Test that moving a product above another works"""
|
||||||
client = TestClient(ProductTypeController)
|
response = counter_admin_client.patch(
|
||||||
response = client.patch(
|
reverse(
|
||||||
f"/{product_types[1].id}/move", query={"above": product_types[0].id}
|
"api:reorder_product_type",
|
||||||
|
kwargs={"type_id": product_types[1].id},
|
||||||
|
query={"above": product_types[0].id},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
new_order = [i["id"] for i in client.get("").json()]
|
new_order = [
|
||||||
|
i["id"]
|
||||||
|
for i in counter_admin_client.get(reverse("api:fetch_product_types")).json()
|
||||||
|
]
|
||||||
assert new_order == [
|
assert new_order == [
|
||||||
product_types[1].id,
|
product_types[1].id,
|
||||||
product_types[0].id,
|
product_types[0].id,
|
||||||
|
|||||||
@@ -182,29 +182,19 @@ ainsi même que de l'héritage de templates.
|
|||||||
si on souhaite faire des modifications côté client,
|
si on souhaite faire des modifications côté client,
|
||||||
il faut utiliser du Javascript, rien ne change à ce niveau-là.
|
il faut utiliser du Javascript, rien ne change à ce niveau-là.
|
||||||
|
|
||||||
### jQuery
|
### Typescript
|
||||||
|
|
||||||
[Site officiel](https://jquery.com/)
|
[Site officiel](https://www.typescriptlang.org/)
|
||||||
|
|
||||||
jQuery est une bibliothèque JavaScript
|
Pour rendre le site interactif, nous n'utilisons
|
||||||
libre et multiplateforme créée pour faciliter
|
pas directement Javascript, mais Typescript.
|
||||||
l'écriture de scripts côté client
|
Il s'agit d'un langage construit par-dessus Javascript,
|
||||||
dans le code HTML des pages web.
|
en ajoutant un typage statique et des éléments de sucre syntaxique.
|
||||||
La première version est lancée en janvier 2006 par John Resig.
|
Grâce au système de type, le code est plus lisible,
|
||||||
|
à la fois par les humains et par l'IDE, et plus fiable.
|
||||||
|
|
||||||
C'est une vieille technologie et certains
|
Il faut parfois se battre un peu contre le système de types de Typescript,
|
||||||
feront remarquer à juste titre que le Javascript
|
mais globalement Typescript est une alternative largement préférable à Javascript.
|
||||||
moderne permet d'utiliser assez simplement
|
|
||||||
la majorité de ce que fournit jQuery
|
|
||||||
sans rien avoir à installer.
|
|
||||||
Cependant, de nombreuses dépendances du projet
|
|
||||||
utilisent encore jQuery qui est toujours
|
|
||||||
très implanté aujourd'hui.
|
|
||||||
Le sucre syntaxique qu'offre cette librairie
|
|
||||||
reste très agréable à utiliser et économise
|
|
||||||
parfois beaucoup de temps.
|
|
||||||
Ça fonctionne et ça fonctionne très bien.
|
|
||||||
C'est maintenu et pratique.
|
|
||||||
|
|
||||||
|
|
||||||
### AlpineJS
|
### AlpineJS
|
||||||
@@ -270,17 +260,6 @@ sur tous les navigateurs contrairement
|
|||||||
à un simple icône unicode qui s'affiche
|
à un simple icône unicode qui s'affiche
|
||||||
lui différemment selon la plate-forme.
|
lui différemment selon la plate-forme.
|
||||||
|
|
||||||
!!!note
|
|
||||||
|
|
||||||
C'est une dépendance capricieuse qui évolue très vite
|
|
||||||
et qu'il faut très souvent mettre à jour.
|
|
||||||
|
|
||||||
!!!warning
|
|
||||||
|
|
||||||
Il a été décidé de **ne pas utiliser**
|
|
||||||
de CDN puisque le site ralentissait régulièrement.
|
|
||||||
Il est préférable de fournir cette dépendance avec le site.
|
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
### Git
|
### Git
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
L'ORM de Django est puissant, très puissant, non par parce qu'il
|
L'ORM de Django est puissant, très puissant, non pas parce qu'il
|
||||||
est performant (après tout, ce n'est qu'une interface, le gros du boulot,
|
est performant (après tout, ce n'est qu'une interface, le gros du boulot,
|
||||||
c'est la db qui le fait), mais parce qu'il permet d'écrire
|
c'est la db qui le fait), mais parce qu'il permet d'écrire
|
||||||
de manière relativement simple un grand panel de requêtes.
|
de manière relativement simple un grand panel de requêtes.
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ Pour accéder au fichier, il faut utiliser `static` comme pour le reste mais en
|
|||||||
Le bundler ne génère que des modules javascript.
|
Le bundler ne génère que des modules javascript.
|
||||||
Ajouter `type="module"` n'est pas optionnel !
|
Ajouter `type="module"` n'est pas optionnel !
|
||||||
|
|
||||||
### Les imports au sein des fichiers des fichiers javascript bundlés
|
### Les imports au sein des fichiers javascript bundlés
|
||||||
|
|
||||||
Pour importer au sein d'un fichier js bundlé, il faut préfixer ses imports de `#app:`.
|
Pour importer au sein d'un fichier js bundlé, il faut préfixer ses imports de `#app:`.
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,4 @@ SITH_SUBSCRIPTIONS = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Une fois ceci fait, il faut créer une nouvelle migration :
|
Après ça, n'oubliez pas de gérer les traductions (cf. [ici](./translation.md))
|
||||||
|
|
||||||
```bash
|
|
||||||
python ./manage.py makemigrations subscription
|
|
||||||
python ./manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
N'oubliez pas non plus les traductions (cf. [ici](./translation.md))
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
- can_edit_prop
|
- can_edit_prop
|
||||||
- can_edit
|
- can_edit
|
||||||
- can_view
|
- can_view
|
||||||
- CanCreateMixin
|
|
||||||
- CanEditMixin
|
- CanEditMixin
|
||||||
- CanViewMixin
|
- CanViewMixin
|
||||||
- CanEditPropMixin
|
- CanEditPropMixin
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
Pour l'API, nous utilisons `django-ninja` et sa surcouche `django-ninja-extra`.
|
Pour l'API, nous utilisons `django-ninja` et sa surcouche `django-ninja-extra`.
|
||||||
Ce sont des librairies relativement simples et qui présentent
|
Ce sont des librairies relativement simples et qui présentent
|
||||||
l'immense avantage d'offrir des mécanismes de validation et de sérialisation
|
l'immense avantage d'offrir des mécanismes de validation et de sérialisation
|
||||||
@@ -49,8 +48,9 @@ Notre API offre deux moyens d'authentification :
|
|||||||
- par clef d'API
|
- par clef d'API
|
||||||
|
|
||||||
La plus grande partie des routes de l'API utilisent la méthode par cookie de session.
|
La plus grande partie des routes de l'API utilisent la méthode par cookie de session.
|
||||||
|
Cette dernière est donc activée par défaut.
|
||||||
|
|
||||||
Pour placer une route d'API derrière l'une de ces méthodes (ou bien les deux),
|
Pour changer la méthode d'authentification,
|
||||||
utilisez l'attribut `auth` et les classes `SessionAuth` et
|
utilisez l'attribut `auth` et les classes `SessionAuth` et
|
||||||
[`ApiKeyAuth`][api.auth.ApiKeyAuth].
|
[`ApiKeyAuth`][api.auth.ApiKeyAuth].
|
||||||
|
|
||||||
@@ -60,13 +60,17 @@ utilisez l'attribut `auth` et les classes `SessionAuth` et
|
|||||||
@api_controller("/foo")
|
@api_controller("/foo")
|
||||||
class FooController(ControllerBase):
|
class FooController(ControllerBase):
|
||||||
# Cette route sera accessible uniquement avec l'authentification
|
# Cette route sera accessible uniquement avec l'authentification
|
||||||
# par cookie de session
|
# par clef d'API
|
||||||
@route.get("", auth=[SessionAuth()])
|
@route.get("", auth=[ApiKeyAuth()])
|
||||||
def fetch_foo(self, club_id: int): ...
|
def fetch_foo(self, club_id: int): ...
|
||||||
|
|
||||||
# Et celle-ci sera accessible peut importe la méthode d'authentification
|
# Celle-ci sera accessible avec les deux méthodes d'authentification
|
||||||
@route.get("/bar", auth=[SessionAuth(), ApiKeyAuth()])
|
@route.get("/bar", auth=[ApiKeyAuth(), SessionAuth()])
|
||||||
def fetch_bar(self, club_id: int): ...
|
def fetch_bar(self, club_id: int): ...
|
||||||
|
|
||||||
|
# Et celle-ci sera accessible aussi aux utilisateurs non-connectés
|
||||||
|
@route.get("/public", auth=None)
|
||||||
|
def fetch_public(self, club_id: int): ...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Permissions
|
### Permissions
|
||||||
@@ -79,9 +83,7 @@ par-dessus `django-ninja`, le système de permissions de django
|
|||||||
et notre propre système.
|
et notre propre système.
|
||||||
Cette dernière est documentée [ici](../perms.md).
|
Cette dernière est documentée [ici](../perms.md).
|
||||||
|
|
||||||
### Limites des clefs d'API
|
### Incompatibilité avec certaines permissions
|
||||||
|
|
||||||
#### Incompatibilité avec certaines permissions
|
|
||||||
|
|
||||||
Le système des clefs d'API est apparu très tard dans l'histoire du site
|
Le système des clefs d'API est apparu très tard dans l'histoire du site
|
||||||
(en P25, 10 ans après le début du développement).
|
(en P25, 10 ans après le début du développement).
|
||||||
@@ -112,10 +114,33 @@ Les principaux points de friction sont :
|
|||||||
- `IsLoggedInCounter`, qui utilise encore un autre système
|
- `IsLoggedInCounter`, qui utilise encore un autre système
|
||||||
d'authentification maison et qui n'est pas fait pour être utilisé en dehors du site.
|
d'authentification maison et qui n'est pas fait pour être utilisé en dehors du site.
|
||||||
|
|
||||||
#### Incompatibilité avec les tokens csrf
|
### CSRF
|
||||||
|
|
||||||
Le [CSRF (*cross-site request forgery*)](https://fr.wikipedia.org/wiki/Cross-site_request_forgery)
|
!!!info "A propos du csrf"
|
||||||
est un des multiples facteurs d'attaque sur le web.
|
|
||||||
|
Le [CSRF (*cross-site request forgery*)](https://fr.wikipedia.org/wiki/Cross-site_request_forgery)
|
||||||
|
est un vecteur d'attaque sur le web consistant
|
||||||
|
à soumettre des données au serveur à l'insu
|
||||||
|
de l'utilisateur, en profitant de sa session.
|
||||||
|
|
||||||
|
C'est une attaque qui peut se produire lorsque l'utilisateur
|
||||||
|
est authentifié par cookie de session.
|
||||||
|
En effet, les cookies sont joints automatiquement à
|
||||||
|
toutes les requêtes ;
|
||||||
|
en l'absence de protection contre le CSRF,
|
||||||
|
un attaquant parvenant à insérer un formulaire
|
||||||
|
dans la page de l'utilisateur serait en mesure
|
||||||
|
de faire presque n'importe quoi en son nom,
|
||||||
|
et ce sans même que l'utilisateur ni les administrateurs
|
||||||
|
ne s'en rendent compte avant qu'il ne soit largement trop tard !
|
||||||
|
|
||||||
|
Sur le CSRF et les moyens de s'en prémunir, voir :
|
||||||
|
|
||||||
|
- [https://owasp.org/www-community/attacks/csrf]()
|
||||||
|
- [https://security.stackexchange.com/questions/166724/should-i-use-csrf-protection-on-rest-api-endpoints]()
|
||||||
|
- [https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html]()
|
||||||
|
|
||||||
|
Le CSRF, c'est dangereux.
|
||||||
Heureusement, Django vient encore une fois à notre aide,
|
Heureusement, Django vient encore une fois à notre aide,
|
||||||
avec des mécanismes intégrés pour s'en protéger.
|
avec des mécanismes intégrés pour s'en protéger.
|
||||||
Ceux-ci incluent notamment un système de
|
Ceux-ci incluent notamment un système de
|
||||||
@@ -123,16 +148,39 @@ Ceux-ci incluent notamment un système de
|
|||||||
à fournir dans les requêtes POST/PUT/PATCH.
|
à fournir dans les requêtes POST/PUT/PATCH.
|
||||||
|
|
||||||
Ceux-ci sont bien adaptés au cycle requêtes/réponses
|
Ceux-ci sont bien adaptés au cycle requêtes/réponses
|
||||||
typique de l'expérience utilisateur sur un navigateur,
|
typiques de l'expérience utilisateur sur un navigateur,
|
||||||
où les requêtes POST sont toujours effectuées après une requête
|
où les requêtes POST sont toujours effectuées après une requête
|
||||||
GET au cours de laquelle on a pu récupérer un token csrf.
|
GET au cours de laquelle on a pu récupérer un token csrf.
|
||||||
Cependant, le flux des requêtes sur une API est bien différent ;
|
Cependant, ils sont également gênants et moins utiles
|
||||||
de ce fait, il est à attendre que les requêtes POST envoyées à l'API
|
dans le cadre d'une API REST, étant donné
|
||||||
par un client externe n'aient pas de token CSRF et se retrouvent
|
que l'authentification cesse d'être implicite :
|
||||||
donc bloquées.
|
la clef d'API doit être explicitement jointe aux headers,
|
||||||
|
pour chaque requête.
|
||||||
|
|
||||||
Pour ces raisons, l'accès aux requêtes POST/PUT/PATCH de l'API
|
Pour ces raisons, la vérification CSRF ne prend place
|
||||||
par un client externe ne marche pas.
|
que pour la vérification de l'authentification
|
||||||
|
par cookie de session.
|
||||||
|
|
||||||
|
!!!warning "L'ordre est important"
|
||||||
|
|
||||||
|
Si vous écrivez le code suivant, l'authentification par clef d'API
|
||||||
|
ne marchera plus :
|
||||||
|
|
||||||
|
```python
|
||||||
|
@api_controller("/foo")
|
||||||
|
class FooController(ControllerBase):
|
||||||
|
@route.post("/bar", auth=[SessionAuth(), ApiKeyAuth()])
|
||||||
|
def post_bar(self, club_id: int): ...
|
||||||
|
```
|
||||||
|
|
||||||
|
En effet, la vérification du cookie de session intègrera
|
||||||
|
toujours la vérification CSRF.
|
||||||
|
Or, un échec de cette dernière est traduit par django en un code HTTP 403
|
||||||
|
au lieu d'un HTTP 401.
|
||||||
|
L'authentification se retrouve alors court-circuitée,
|
||||||
|
faisant que la vérification de la clef d'API ne sera jamais appelée.
|
||||||
|
|
||||||
|
`SessionAuth` doit donc être déclaré **après** `ApiKeyAuth`.
|
||||||
|
|
||||||
## Créer un client et une clef d'API
|
## Créer un client et une clef d'API
|
||||||
|
|
||||||
@@ -171,5 +219,3 @@ qui en a besoin.
|
|||||||
Dites-lui bien de garder cette clef en lieu sûr !
|
Dites-lui bien de garder cette clef en lieu sûr !
|
||||||
Si la clef est perdue, il n'y a pas moyen de la récupérer,
|
Si la clef est perdue, il n'y a pas moyen de la récupérer,
|
||||||
vous devrez en recréer une.
|
vous devrez en recréer une.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -157,16 +157,18 @@ que sont VsCode et Sublime Text.
|
|||||||
Si vous avez réussi à terminer l'installation, vous n'avez donc pas de configuration
|
Si vous avez réussi à terminer l'installation, vous n'avez donc pas de configuration
|
||||||
supplémentaire à effectuer.
|
supplémentaire à effectuer.
|
||||||
|
|
||||||
Pour utiliser Biome, placez-vous à la racine du projet et lancer la commande suivante:
|
Pour utiliser Biome, placez-vous à la racine du projet et lancez la commande suivante:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @biomejs/biome check # Pour checker le code avec le linter et le formater
|
npx @biomejs/biome check # Pour checker le code avec le linter et le formater
|
||||||
npx @biomejs/biome check --write # Pour appliquer les changemnts
|
npx @biomejs/biome check --write # Pour appliquer les changements
|
||||||
```
|
```
|
||||||
|
|
||||||
Biome va alors faire son travail sur l'ensemble du projet puis vous dire
|
Biome va alors faire son travail sur l'ensemble du projet puis vous dire
|
||||||
si des documents ont été reformatés (si vous avez fait `npx @biomejs/biome format --write`)
|
si des documents ont été reformatés (si vous avez fait `npx @biomejs/biome format --write`)
|
||||||
ou bien s'il y a des erreurs à réparer (si vous avez faire `npx @biomejs/biome lint`) ou les deux (si vous avez fait `npx @biomejs/biome check --write`).
|
ou bien s'il y a des erreurs à réparer
|
||||||
|
(si vous avez fait `npx @biomejs/biome lint`)
|
||||||
|
ou les deux (si vous avez fait `npx @biomejs/biome check --write`).
|
||||||
|
|
||||||
Appeler Biome en ligne de commandes avant de pousser votre code sur Github
|
Appeler Biome en ligne de commandes avant de pousser votre code sur Github
|
||||||
est une technique qui marche très bien.
|
est une technique qui marche très bien.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ opérations, telles que la validation de formulaire.
|
|||||||
En effet, valider un formulaire demande beaucoup
|
En effet, valider un formulaire demande beaucoup
|
||||||
de travail de nettoyage des données et d'affichage
|
de travail de nettoyage des données et d'affichage
|
||||||
des messages d'erreur appropriés.
|
des messages d'erreur appropriés.
|
||||||
Or, tout ce travail existe déjà dans django.
|
Or, tout ce travail existe déjà dans Django.
|
||||||
|
|
||||||
On veut donc, dans ces cas-là, ne pas demander
|
On veut donc, dans ces cas-là, ne pas demander
|
||||||
toute une page HTML au serveur, mais uniquement
|
toute une page HTML au serveur, mais uniquement
|
||||||
@@ -84,7 +84,7 @@ Grâce à ça, on peut écrire des vues qui
|
|||||||
fonctionnent dans les deux contextes.
|
fonctionnent dans les deux contextes.
|
||||||
|
|
||||||
Par exemple, supposons que nous avons
|
Par exemple, supposons que nous avons
|
||||||
une `EditView` très simple, contenant
|
une `UpdateView` très simple, contenant
|
||||||
uniquement un formulaire.
|
uniquement un formulaire.
|
||||||
On peut écrire la vue et le template de la manière
|
On peut écrire la vue et le template de la manière
|
||||||
suivante :
|
suivante :
|
||||||
@@ -94,8 +94,10 @@ suivante :
|
|||||||
```python
|
```python
|
||||||
from django.views.generic import UpdateView
|
from django.views.generic import UpdateView
|
||||||
|
|
||||||
|
from core.views import AllowFragment
|
||||||
|
|
||||||
class FooUpdateView(UpdateView):
|
|
||||||
|
class FooUpdateView(AllowFragment, UpdateView):
|
||||||
model = Foo
|
model = Foo
|
||||||
fields = ["foo", "bar"]
|
fields = ["foo", "bar"]
|
||||||
pk_url_kwarg = "foo_id"
|
pk_url_kwarg = "foo_id"
|
||||||
@@ -132,7 +134,7 @@ Dans ces situations, pouvoir décomposer une vue
|
|||||||
en plusieurs vues de fragment permet de ne plus
|
en plusieurs vues de fragment permet de ne plus
|
||||||
raisonner en termes de condition, mais en termes
|
raisonner en termes de condition, mais en termes
|
||||||
de composition : on n'a pas un seul template
|
de composition : on n'a pas un seul template
|
||||||
qui peut changer les situations, on a plusieurs
|
qui peut changer selon les situations, on a plusieurs
|
||||||
templates que l'on injecte dans un template principal.
|
templates que l'on injecte dans un template principal.
|
||||||
|
|
||||||
Supposons, par exemple, que nous n'avons plus un,
|
Supposons, par exemple, que nous n'avons plus un,
|
||||||
@@ -238,10 +240,10 @@ qui se comportera alors comme une vue normale.
|
|||||||
|
|
||||||
#### La méthode `as_fragment`
|
#### La méthode `as_fragment`
|
||||||
|
|
||||||
Il est à noter que l'instantiation d'un fragment
|
Il est à noter que l'instanciation d'un fragment
|
||||||
se fait en deux étapes :
|
se fait en deux étapes :
|
||||||
|
|
||||||
- on commence par instantier la vue en tant que renderer.
|
- on commence par instancier la vue en tant que renderer.
|
||||||
- on appelle le renderer en lui-même
|
- on appelle le renderer en lui-même
|
||||||
|
|
||||||
Ce qui donne la syntaxe `Fragment.as_fragment()()`.
|
Ce qui donne la syntaxe `Fragment.as_fragment()()`.
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
|||||||
```bash
|
```bash
|
||||||
sudo pacman -Syu # on s'assure que les dépôts et le système sont à jour
|
sudo pacman -Syu # on s'assure que les dépôts et le système sont à jour
|
||||||
|
|
||||||
sudo pacman -S uv gcc git gettext pkgconf npm redis
|
sudo pacman -S uv gcc git gettext pkgconf npm valkey
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "macOS"
|
=== "macOS"
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ Pour les vues sous forme de fonction, il y a le décorateur
|
|||||||
obj = self.get_object()
|
obj = self.get_object()
|
||||||
obj.is_moderated = True
|
obj.is_moderated = True
|
||||||
obj.save()
|
obj.save()
|
||||||
return redirect(reverse("com:news_list"))
|
return redirect("com:news_list")
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "Function-based view"
|
=== "Function-based view"
|
||||||
@@ -233,7 +233,7 @@ Pour les vues sous forme de fonction, il y a le décorateur
|
|||||||
news = get_object_or_404(News, id=news_id)
|
news = get_object_or_404(News, id=news_id)
|
||||||
news.is_moderated = True
|
news.is_moderated = True
|
||||||
news.save()
|
news.save()
|
||||||
return redirect(reverse("com:news_list"))
|
return redirect("com:news_list")
|
||||||
```
|
```
|
||||||
|
|
||||||
## Accès à des éléments en particulier
|
## Accès à des éléments en particulier
|
||||||
@@ -447,10 +447,9 @@ l'utilisateur recevra une liste vide d'objet.
|
|||||||
Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment :
|
Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment :
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from django.views.generic import CreateView, DetailView
|
from django.views.generic import DetailView
|
||||||
|
|
||||||
from core.auth.mixins import CanViewMixin, CanCreateMixin
|
|
||||||
|
|
||||||
|
from core.auth.mixins import CanViewMixin
|
||||||
from com.models import WeekmailArticle
|
from com.models import WeekmailArticle
|
||||||
|
|
||||||
|
|
||||||
@@ -459,48 +458,15 @@ from com.models import WeekmailArticle
|
|||||||
# d'une classe de base pour fonctionner correctement.
|
# d'une classe de base pour fonctionner correctement.
|
||||||
class ArticlesDetailView(CanViewMixin, DetailView):
|
class ArticlesDetailView(CanViewMixin, DetailView):
|
||||||
model = WeekmailArticle
|
model = WeekmailArticle
|
||||||
|
|
||||||
|
|
||||||
# Même chose pour une vue de création de l'objet Article
|
|
||||||
class ArticlesCreateView(CanCreateMixin, CreateView):
|
|
||||||
model = WeekmailArticle
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Les mixins suivants sont implémentés :
|
Les mixins suivants sont implémentés :
|
||||||
|
|
||||||
- [CanCreateMixin][core.auth.mixins.CanCreateMixin] : l'utilisateur peut-il créer l'objet ?
|
|
||||||
Ce mixin existe, mais est déprécié et ne doit plus être utilisé !
|
|
||||||
- [CanEditPropMixin][core.auth.mixins.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ?
|
- [CanEditPropMixin][core.auth.mixins.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ?
|
||||||
- [CanEditMixin][core.auth.mixins.CanEditMixin] : L'utilisateur peut-il éditer l'objet ?
|
- [CanEditMixin][core.auth.mixins.CanEditMixin] : L'utilisateur peut-il éditer l'objet ?
|
||||||
- [CanViewMixin][core.auth.mixins.CanViewMixin] : L'utilisateur peut-il voir l'objet ?
|
- [CanViewMixin][core.auth.mixins.CanViewMixin] : L'utilisateur peut-il voir l'objet ?
|
||||||
- [FormerSubscriberMixin][core.auth.mixins.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ?
|
- [FormerSubscriberMixin][core.auth.mixins.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ?
|
||||||
|
|
||||||
!!!danger "CanCreateMixin"
|
|
||||||
|
|
||||||
L'usage de `CanCreateMixin` est dangereux et ne doit en aucun cas être
|
|
||||||
étendu.
|
|
||||||
La façon dont ce mixin marche est qu'il valide le formulaire
|
|
||||||
de création et crée l'objet sans le persister en base de données, puis
|
|
||||||
vérifie les droits sur cet objet non-persisté.
|
|
||||||
Le danger de ce système vient de multiples raisons :
|
|
||||||
|
|
||||||
- Les vérifications se faisant sur un objet non persisté,
|
|
||||||
l'utilisation de mécanismes nécessitant une persistance préalable
|
|
||||||
peut mener à des comportements indésirés, voire à des erreurs.
|
|
||||||
- Les développeurs de django ayant tendance à restreindre progressivement
|
|
||||||
les actions qui peuvent être faites sur des objets non-persistés,
|
|
||||||
les mises-à-jour de django deviennent plus compliquées.
|
|
||||||
- La vérification des droits ne se fait que dans les requêtes POST,
|
|
||||||
à la toute fin de la requête.
|
|
||||||
Tout ce qui arrive avant n'est absolument pas protégé.
|
|
||||||
Toute opération (même les suppressions et les créations) qui ont
|
|
||||||
lieu avant la persistance de l'objet seront appliquées,
|
|
||||||
même sans permission.
|
|
||||||
- Si un développeur du site fait l'erreur de surcharger
|
|
||||||
la méthode `form_valid` (ce qui est plutôt courant,
|
|
||||||
lorsqu'on veut accomplir certaines actions
|
|
||||||
quand un formulaire est valide), on peut se retrouver
|
|
||||||
dans une situation où l'objet est persisté sans aucune protection.
|
|
||||||
|
|
||||||
!!!danger "Performance"
|
!!!danger "Performance"
|
||||||
|
|
||||||
|
|||||||
@@ -27,14 +27,14 @@ from functools import partial
|
|||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.core.exceptions import PermissionDenied
|
|
||||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.utils import html, timezone
|
from django.utils import html, timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView, RedirectView
|
from django.views.generic import DetailView, ListView, RedirectView
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
@@ -44,7 +44,6 @@ from honeypot.decorators import check_honeypot
|
|||||||
|
|
||||||
from club.widgets.ajax_select import AutoCompleteSelectClub
|
from club.widgets.ajax_select import AutoCompleteSelectClub
|
||||||
from core.auth.mixins import (
|
from core.auth.mixins import (
|
||||||
CanCreateMixin,
|
|
||||||
CanEditMixin,
|
CanEditMixin,
|
||||||
CanEditPropMixin,
|
CanEditPropMixin,
|
||||||
CanViewMixin,
|
CanViewMixin,
|
||||||
@@ -180,11 +179,19 @@ class ForumForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ForumCreateView(CanCreateMixin, CreateView):
|
class ForumCreateView(UserPassesTestMixin, CreateView):
|
||||||
model = Forum
|
model = Forum
|
||||||
form_class = ForumForm
|
form_class = ForumForm
|
||||||
template_name = "core/create.jinja"
|
template_name = "core/create.jinja"
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
if self.request.user.has_perm("forum.add_forum"):
|
||||||
|
return True
|
||||||
|
parent = Forum.objects.filter(id=self.request.GET["parent"]).first()
|
||||||
|
if parent is not None:
|
||||||
|
return self.request.user.is_owner(parent)
|
||||||
|
return False
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
init = super().get_initial()
|
init = super().get_initial()
|
||||||
parent = Forum.objects.filter(id=self.request.GET["parent"]).first()
|
parent = Forum.objects.filter(id=self.request.GET["parent"]).first()
|
||||||
@@ -258,18 +265,19 @@ class TopicForm(forms.ModelForm):
|
|||||||
@method_decorator(
|
@method_decorator(
|
||||||
partial(check_honeypot, field_name=settings.HONEYPOT_FIELD_NAME_FORUM), name="post"
|
partial(check_honeypot, field_name=settings.HONEYPOT_FIELD_NAME_FORUM), name="post"
|
||||||
)
|
)
|
||||||
class ForumTopicCreateView(CanCreateMixin, CreateView):
|
class ForumTopicCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||||
model = ForumMessage
|
model = ForumMessage
|
||||||
form_class = TopicForm
|
form_class = TopicForm
|
||||||
template_name = "forum/reply.jinja"
|
template_name = "forum/reply.jinja"
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
@cached_property
|
||||||
self.forum = get_object_or_404(
|
def forum(self):
|
||||||
Forum, id=self.kwargs["forum_id"], is_category=False
|
return get_object_or_404(Forum, id=self.kwargs["forum_id"], is_category=False)
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
return self.request.user.has_perm("forum.add_forumtopic") or (
|
||||||
|
self.request.user.can_view(self.forum)
|
||||||
)
|
)
|
||||||
if not request.user.can_view(self.forum):
|
|
||||||
raise PermissionDenied
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
topic = ForumTopic(
|
topic = ForumTopic(
|
||||||
@@ -404,7 +412,7 @@ class ForumMessageUndeleteView(SingleObjectMixin, RedirectView):
|
|||||||
@method_decorator(
|
@method_decorator(
|
||||||
partial(check_honeypot, field_name=settings.HONEYPOT_FIELD_NAME_FORUM), name="post"
|
partial(check_honeypot, field_name=settings.HONEYPOT_FIELD_NAME_FORUM), name="post"
|
||||||
)
|
)
|
||||||
class ForumMessageCreateView(CanCreateMixin, CreateView):
|
class ForumMessageCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||||
model = ForumMessage
|
model = ForumMessage
|
||||||
form_class = forms.modelform_factory(
|
form_class = forms.modelform_factory(
|
||||||
model=ForumMessage,
|
model=ForumMessage,
|
||||||
@@ -413,11 +421,14 @@ class ForumMessageCreateView(CanCreateMixin, CreateView):
|
|||||||
)
|
)
|
||||||
template_name = "forum/reply.jinja"
|
template_name = "forum/reply.jinja"
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
@cached_property
|
||||||
self.topic = get_object_or_404(ForumTopic, id=self.kwargs["topic_id"])
|
def topic(self):
|
||||||
if not request.user.can_view(self.topic):
|
return get_object_or_404(ForumTopic, id=self.kwargs["topic_id"])
|
||||||
raise PermissionDenied
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
def test_func(self):
|
||||||
|
return self.request.user.has_perm(
|
||||||
|
"forum.add_forummessage"
|
||||||
|
) or self.request.user.can_view(self.topic)
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
init = super().get_initial()
|
init = super().get_initial()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from pedagogy.utbm_api import UtbmApiClient
|
|||||||
class UvController(ControllerBase):
|
class UvController(ControllerBase):
|
||||||
@route.get(
|
@route.get(
|
||||||
"/{code}",
|
"/{code}",
|
||||||
auth=[SessionAuth(), ApiKeyAuth()],
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
permissions=[
|
permissions=[
|
||||||
# this route will almost always be called in the context
|
# this route will almost always be called in the context
|
||||||
# of a UV creation/edition
|
# of a UV creation/edition
|
||||||
@@ -45,7 +45,7 @@ class UvController(ControllerBase):
|
|||||||
"",
|
"",
|
||||||
response=PaginatedResponseSchema[SimpleUvSchema],
|
response=PaginatedResponseSchema[SimpleUvSchema],
|
||||||
url_name="fetch_uvs",
|
url_name="fetch_uvs",
|
||||||
auth=[SessionAuth(), ApiKeyAuth()],
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
permissions=[HasPerm("pedagogy.view_uv")],
|
permissions=[HasPerm("pedagogy.view_uv")],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=100)
|
@paginate(PageNumberPaginationExtra, page_size=100)
|
||||||
|
|||||||
17
sas/api.py
17
sas/api.py
@@ -8,7 +8,6 @@ from ninja.security import SessionAuth
|
|||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
from ninja_extra.exceptions import NotFound, PermissionDenied
|
from ninja_extra.exceptions import NotFound, PermissionDenied
|
||||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||||
from ninja_extra.permissions import IsAuthenticated
|
|
||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
from pydantic import NonNegativeInt
|
from pydantic import NonNegativeInt
|
||||||
|
|
||||||
@@ -41,7 +40,6 @@ class AlbumController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[AlbumSchema],
|
response=PaginatedResponseSchema[AlbumSchema],
|
||||||
permissions=[IsAuthenticated],
|
|
||||||
url_name="search-album",
|
url_name="search-album",
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
@@ -54,7 +52,7 @@ class AlbumController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/autocomplete-search",
|
"/autocomplete-search",
|
||||||
response=PaginatedResponseSchema[AlbumAutocompleteSchema],
|
response=PaginatedResponseSchema[AlbumAutocompleteSchema],
|
||||||
auth=[SessionAuth(), ApiKeyAuth()],
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
@@ -74,12 +72,7 @@ class AlbumController(ControllerBase):
|
|||||||
|
|
||||||
@api_controller("/sas/picture")
|
@api_controller("/sas/picture")
|
||||||
class PicturesController(ControllerBase):
|
class PicturesController(ControllerBase):
|
||||||
@route.get(
|
@route.get("", response=PaginatedResponseSchema[PictureSchema], url_name="pictures")
|
||||||
"",
|
|
||||||
response=PaginatedResponseSchema[PictureSchema],
|
|
||||||
permissions=[IsAuthenticated],
|
|
||||||
url_name="pictures",
|
|
||||||
)
|
|
||||||
@paginate(PageNumberPaginationExtra, page_size=100)
|
@paginate(PageNumberPaginationExtra, page_size=100)
|
||||||
def fetch_pictures(self, filters: Query[PictureFilterSchema]):
|
def fetch_pictures(self, filters: Query[PictureFilterSchema]):
|
||||||
"""Find pictures viewable by the user corresponding to the given filters.
|
"""Find pictures viewable by the user corresponding to the given filters.
|
||||||
@@ -141,7 +134,7 @@ class PicturesController(ControllerBase):
|
|||||||
|
|
||||||
@route.get(
|
@route.get(
|
||||||
"/{picture_id}/identified",
|
"/{picture_id}/identified",
|
||||||
permissions=[IsAuthenticated, CanView],
|
permissions=[CanView],
|
||||||
response=list[IdentifiedUserSchema],
|
response=list[IdentifiedUserSchema],
|
||||||
)
|
)
|
||||||
def fetch_identifications(self, picture_id: int):
|
def fetch_identifications(self, picture_id: int):
|
||||||
@@ -149,7 +142,7 @@ class PicturesController(ControllerBase):
|
|||||||
picture = self.get_object_or_exception(Picture, pk=picture_id)
|
picture = self.get_object_or_exception(Picture, pk=picture_id)
|
||||||
return picture.people.select_related("user")
|
return picture.people.select_related("user")
|
||||||
|
|
||||||
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView])
|
@route.put("/{picture_id}/identified", permissions=[CanView])
|
||||||
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):
|
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):
|
||||||
picture = self.get_object_or_exception(
|
picture = self.get_object_or_exception(
|
||||||
Picture.objects.select_related("parent"), pk=picture_id
|
Picture.objects.select_related("parent"), pk=picture_id
|
||||||
@@ -209,7 +202,7 @@ class PicturesController(ControllerBase):
|
|||||||
|
|
||||||
@api_controller("/sas/relation", tags="User identification on SAS pictures")
|
@api_controller("/sas/relation", tags="User identification on SAS pictures")
|
||||||
class UsersIdentifiedController(ControllerBase):
|
class UsersIdentifiedController(ControllerBase):
|
||||||
@route.delete("/{relation_id}", permissions=[IsAuthenticated])
|
@route.delete("/{relation_id}")
|
||||||
def delete_relation(self, relation_id: NonNegativeInt):
|
def delete_relation(self, relation_id: NonNegativeInt):
|
||||||
"""Untag a user from a SAS picture.
|
"""Untag a user from a SAS picture.
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class TestPictureSearch(TestSas):
|
|||||||
|
|
||||||
def test_anonymous_user_forbidden(self):
|
def test_anonymous_user_forbidden(self):
|
||||||
res = self.client.get(self.url)
|
res = self.client.get(self.url)
|
||||||
assert res.status_code == 403
|
assert res.status_code == 401
|
||||||
|
|
||||||
def test_filter_by_album(self):
|
def test_filter_by_album(self):
|
||||||
self.client.force_login(self.user_b)
|
self.client.force_login(self.user_b)
|
||||||
@@ -148,7 +148,7 @@ class TestPictureRelation(TestSas):
|
|||||||
relation = PeoplePictureRelation.objects.exclude(user=self.user_a).first()
|
relation = PeoplePictureRelation.objects.exclude(user=self.user_a).first()
|
||||||
|
|
||||||
res = self.client.delete(f"/api/sas/relation/{relation.id}")
|
res = self.client.delete(f"/api/sas/relation/{relation.id}")
|
||||||
assert res.status_code == 403
|
assert res.status_code == 401
|
||||||
|
|
||||||
for user in baker.make(User), self.user_a:
|
for user in baker.make(User), self.user_a:
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from datetime import date
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
@@ -35,17 +35,13 @@ from django.forms.models import modelform_factory
|
|||||||
from django.http import Http404, HttpResponseRedirect
|
from django.http import Http404, HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, RedirectView, TemplateView, View
|
from django.views.generic import DetailView, RedirectView, TemplateView, View
|
||||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||||
|
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
from core.auth.mixins import (
|
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
|
||||||
CanCreateMixin,
|
|
||||||
CanEditMixin,
|
|
||||||
CanEditPropMixin,
|
|
||||||
CanViewMixin,
|
|
||||||
)
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views.forms import SelectDate
|
from core.views.forms import SelectDate
|
||||||
from core.views.mixins import TabedViewMixin
|
from core.views.mixins import TabedViewMixin
|
||||||
@@ -117,19 +113,25 @@ class TrombiForm(forms.ModelForm):
|
|||||||
widgets = {"subscription_deadline": SelectDate, "comments_deadline": SelectDate}
|
widgets = {"subscription_deadline": SelectDate, "comments_deadline": SelectDate}
|
||||||
|
|
||||||
|
|
||||||
class TrombiCreateView(CanCreateMixin, CreateView):
|
class TrombiCreateView(UserPassesTestMixin, CreateView):
|
||||||
"""Create a trombi for a club."""
|
"""Create a trombi for a club."""
|
||||||
|
|
||||||
model = Trombi
|
model = Trombi
|
||||||
form_class = TrombiForm
|
form_class = TrombiForm
|
||||||
template_name = "core/create.jinja"
|
template_name = "core/create.jinja"
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def club(self):
|
||||||
|
return get_object_or_404(Club, id=self.kwargs["club_id"])
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
return self.request.user.can_edit(self.club)
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""Affect club."""
|
"""Affect club."""
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
club = get_object_or_404(Club, id=self.kwargs["club_id"])
|
form.instance.club = self.club
|
||||||
form.instance.club = club
|
|
||||||
ret = self.form_valid(form)
|
ret = self.form_valid(form)
|
||||||
return ret
|
return ret
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user