Compare commits

..
1 Commits
Author SHA1 Message Date
thomas girod d914fa0658 Merge pull request #1463 from ae-utbm/taiste
Pre school year release
2026-08-25 22:37:49 +02:00
116 changed files with 1892 additions and 2078 deletions
+1 -1
View File
@@ -1 +1 @@
3.14 3.12
+2 -6
View File
@@ -1,15 +1,11 @@
from typing import TYPE_CHECKING
from django.contrib import admin, messages from django.contrib import admin, messages
from django.db.models import QuerySet
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from api.hashers import generate_key from api.hashers import generate_key
from api.models import ApiClient, ApiKey from api.models import ApiClient, ApiKey
if TYPE_CHECKING:
from django.db.models import QuerySet
from django.http import HttpRequest
@admin.register(ApiClient) @admin.register(ApiClient)
class ApiClientAdmin(admin.ModelAdmin): class ApiClientAdmin(admin.ModelAdmin):
+1 -5
View File
@@ -1,13 +1,9 @@
from typing import TYPE_CHECKING from django.http import HttpRequest
from ninja.security import APIKeyHeader from ninja.security import APIKeyHeader
from api.hashers import get_hasher from api.hashers import get_hasher
from api.models import ApiClient, ApiKey from api.models import ApiClient, ApiKey
if TYPE_CHECKING:
from django.http import HttpRequest
class ApiKeyAuth(APIKeyHeader): class ApiKeyAuth(APIKeyHeader):
"""Authentication through client api keys.""" """Authentication through client api keys."""
+4 -6
View File
@@ -39,17 +39,15 @@ Example:
import operator import operator
from functools import reduce from functools import reduce
from typing import TYPE_CHECKING, Any, Callable from typing import Any, Callable
from django.contrib.auth.models import Permission
from django.http import HttpRequest
from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission from ninja_extra.permissions import BasePermission
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
if TYPE_CHECKING:
from django.contrib.auth.models import Permission
from django.http import HttpRequest
from ninja_extra import ControllerBase
class IsInGroup(BasePermission): class IsInGroup(BasePermission):
"""Check that the user is in the group whose primary key is given.""" """Check that the user is in the group whose primary key is given."""
+4 -8
View File
@@ -12,16 +12,12 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from typing import TYPE_CHECKING
from django.contrib import admin from django.contrib import admin
from django.forms.models import ModelForm
from django.http import HttpRequest
from club.models import Club, ClubLink, ClubRole, LinkType, Membership from club.models import Club, ClubLink, ClubRole, LinkType, Membership
if TYPE_CHECKING:
from django.forms.models import ModelForm
from django.http import HttpRequest
@admin.register(Club) @admin.register(Club)
class ClubAdmin(admin.ModelAdmin): class ClubAdmin(admin.ModelAdmin):
@@ -50,8 +46,8 @@ class ClubAdmin(admin.ModelAdmin):
@admin.register(ClubRole) @admin.register(ClubRole)
class ClubRoleAdmin(admin.ModelAdmin): class ClubRoleAdmin(admin.ModelAdmin):
list_display = ("name", "club", "is_board", "is_presidency") list_display = ("name", "club", "is_board", "is_presidency")
search_fields = ("name", "club__name") search_fields = ("name",)
autocomplete_fields = ("club", "linked_groups") autocomplete_fields = ("club",)
list_select_related = ("club",) list_select_related = ("club",)
list_filter = ( list_filter = (
"is_board", "is_board",
-7
View File
@@ -479,13 +479,6 @@ class ClubRoleCreateForm(forms.ModelForm):
class ClubRoleBaseFormSet(forms.BaseInlineFormSet): class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
ordering_widget = forms.HiddenInput() ordering_widget = forms.HiddenInput()
def __init__(self, *args, queryset=None, **kwargs):
if queryset is None:
queryset = self.model._default_manager
super().__init__(
*args, queryset=queryset.prefetch_related("linked_groups"), **kwargs
)
ClubRoleFormSet = forms.inlineformset_factory( ClubRoleFormSet = forms.inlineformset_factory(
Club, Club,
@@ -1,16 +1,12 @@
# Generated by Django 4.2.16 on 2024-11-20 17:08 # Generated by Django 4.2.16 on 2024-11-20 17:08
from typing import TYPE_CHECKING
import django.db.models.deletion import django.db.models.deletion
import django.db.models.functions.datetime import django.db.models.functions.datetime
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Q from django.db.models import Q
from django.utils.timezone import localdate from django.utils.timezone import localdate
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
# Before the club role rework, the maximum free role # Before the club role rework, the maximum free role
# was the hardcoded highest non-board role # was the hardcoded highest non-board role
MAXIMUM_FREE_ROLE = 1 MAXIMUM_FREE_ROLE = 1
@@ -1,14 +1,10 @@
# Generated by Django 5.2.3 on 2025-06-21 21:59 # Generated by Django 5.2.3 on 2025-06-21 21:59
from typing import TYPE_CHECKING
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When from django.db.models import Case, When
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
PRESIDENCY_ROLES = [10, 9] PRESIDENCY_ROLES = [10, 9]
MAXIMUM_FREE_ROLE = 1 MAXIMUM_FREE_ROLE = 1
SITH_CLUB_ROLES = { SITH_CLUB_ROLES = {
@@ -1,26 +0,0 @@
# Generated by Django 5.2.17 on 2026-09-01 14:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("club", "0017_linktype_clublink"),
("core", "0050_alter_sithfile_moderator"),
]
operations = [
migrations.AddField(
model_name="clubrole",
name="linked_groups",
field=models.ManyToManyField(
help_text=(
"Groups that are automatically given or removed "
"to user receiving or losing this club role"
),
related_name="club_roles",
to="core.group",
verbose_name="Linked groups",
),
),
]
+12 -33
View File
@@ -23,8 +23,6 @@
# #
from __future__ import annotations from __future__ import annotations
import operator
from functools import reduce
from typing import Iterable, Self from typing import Iterable, Self
from django.conf import settings from django.conf import settings
@@ -284,15 +282,6 @@ class ClubRole(OrderedModel):
"If the role is inactive, people joining the club won't be able to get it." "If the role is inactive, people joining the club won't be able to get it."
), ),
) )
linked_groups = models.ManyToManyField(
Group,
verbose_name=_("Linked groups"),
help_text=_(
"Groups that are automatically given or removed "
"to user receiving or losing this club role"
),
related_name="linked_roles",
)
order_with_respect_to = "club" order_with_respect_to = "club"
@@ -545,7 +534,7 @@ class Membership(models.Model):
def _remove_club_groups( def _remove_club_groups(
memberships: Iterable[Membership], memberships: Iterable[Membership],
) -> tuple[int, dict[str, int]]: ) -> tuple[int, dict[str, int]]:
"""Remove users of those memberships from the club and club role groups. """Remove users of those memberships from the club groups.
For example, if a user is in the Troll club board, For example, if a user is in the Troll club board,
he is in the board group and the members group of the Troll. he is in the board group and the members group of the Troll.
@@ -564,19 +553,15 @@ class Membership(models.Model):
clubs = {m.club_id for m in memberships} clubs = {m.club_id for m in memberships}
users = {m.user_id for m in memberships} users = {m.user_id for m in memberships}
groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs)) groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs))
role_groups = [
Q(user_id=m.user_id, group__linked_roles=m.role_id) for m in memberships
]
return User.groups.through.objects.filter( return User.groups.through.objects.filter(
(Q(group__in=groups) & Q(user__in=users)) Q(group__in=groups) & Q(user__in=users)
| reduce(operator.or_, role_groups)
).delete() ).delete()
@staticmethod @staticmethod
def _add_club_groups( def _add_club_groups(
memberships: Iterable[Membership], memberships: Iterable[Membership],
) -> list[User.groups.through]: ) -> list[User.groups.through]:
"""Add users of those memberships to the club and club role groups. """Add users of those memberships to the club groups.
For example, if a user just joined the Troll club board, For example, if a user just joined the Troll club board,
he will be added in both the members group and the board group he will be added in both the members group and the board group
@@ -597,40 +582,34 @@ class Membership(models.Model):
memberships = [m for m in memberships if m.end_date is None] memberships = [m for m in memberships if m.end_date is None]
if not memberships: if not memberships:
return [] return []
nb_prefetched = sum(
1 for m in memberships if not hasattr(m, "club") or not hasattr(m, "role") if sum(1 for m in memberships if not hasattr(m, "club")) > 1:
)
if nb_prefetched > 1:
# if more than one membership hasn't its `club` attribute set # if more than one membership hasn't its `club` attribute set
# it's less expensive to reload the whole query with # it's less expensive to reload the whole query with
# a select_related than perform a distinct query # a select_related than perform a distinct query
# to fetch each club. # to fetch each club.
ids = {m.id for m in memberships} ids = {m.id for m in memberships}
memberships = list( memberships = list(
Membership.objects.filter(id__in=ids) Membership.objects.filter(id__in=ids).select_related("club")
.select_related("club", "role")
.prefetch_related("role__linked_groups")
) )
groups = [] club_groups = []
for membership in memberships: for membership in memberships:
groups.append( club_groups.append(
User.groups.through( User.groups.through(
user_id=membership.user_id, user_id=membership.user_id,
group_id=membership.club.members_group_id, group_id=membership.club.members_group_id,
) )
) )
if membership.role.is_board: if membership.role.is_board:
groups.append( club_groups.append(
User.groups.through( User.groups.through(
user_id=membership.user_id, user_id=membership.user_id,
group_id=membership.club.board_group_id, group_id=membership.club.board_group_id,
) )
) )
groups.extend( return User.groups.through.objects.bulk_create(
User.groups.through(user_id=membership.user_id, group_id=g.id) club_groups, ignore_conflicts=True
for g in membership.role.linked_groups.all() )
)
return User.groups.through.objects.bulk_create(groups, ignore_conflicts=True)
class Mailing(models.Model): class Mailing(models.Model):
+6 -11
View File
@@ -15,6 +15,9 @@
{% endblock %} {% endblock %}
{% else %} {% else %}
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endblock %}
{% block description -%} {% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %} {% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %} {%- endblock %}
@@ -23,12 +26,6 @@
{%- endblock %} {%- endblock %}
{% endif %} {% endif %}
{% block additional_css %}
{% if not is_fragment %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endif %}
{% endblock %}
{% from "core/macros.jinja" import paginate_htmx %} {% from "core/macros.jinja" import paginate_htmx %}
{% block content %} {% block content %}
@@ -36,17 +33,15 @@
<h3>{% trans %}Filters{% endtrans %}</h3> <h3>{% trans %}Filters{% endtrans %}</h3>
<form <form
id="club-list-filters" id="club-list-filters"
method="GET" hx-get="{{ url("club:club_list") }}"
hx-action="{{ url("club:club_list") }}"
hx-target="#content" hx-target="#content"
hx-swap="innerHTML" hx-swap="outerHtml"
hx-push-url="true" hx-push-url="true"
hx-disable="find input, find button"
> >
<div class="row gap-4x"> <div class="row gap-4x">
{{ form }} {{ form }}
</div> </div>
<button class="btn btn-blue margin-bottom"> <button type="submit" class="btn btn-blue margin-bottom">
<i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %} <i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %}
</button> </button>
</form> </form>
-14
View File
@@ -49,20 +49,6 @@
{{ subform.is_active.help_text }} {{ subform.is_active.help_text }}
</span> </span>
</div> </div>
{% set groups = subform.instance.linked_groups.all()|list %}
{% if groups %}
<div>
<p>
<strong>{% trans %}Linked groups : {% endtrans %}</strong>
{{ groups|map(attribute="name")|join(", ") }}
</p>
<p class="helptext">
{% trans trimmed %}
Users receiving this role will also be assigned to those groups
{% endtrans %}
</p>
</div>
{% endif %}
</div> </div>
</details> </details>
</div> </div>
@@ -7,7 +7,7 @@
<form <form
hx-post="{{ url('club:club_new_members', club_id=club.id) }}" hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
hx-disable="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#member-fragment-container" hx-target="#member-fragment-container"
id="add_club_members_form" id="add_club_members_form"
+1 -4
View File
@@ -1,9 +1,9 @@
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.db import ProgrammingError from django.db import ProgrammingError
from django.test import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localdate from django.utils.timezone import localdate
from model_bakery import baker from model_bakery import baker
@@ -14,9 +14,6 @@ from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import User from core.models import User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
def test_club_queryset_having_board_member(): def test_club_queryset_having_board_member():
+1 -4
View File
@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING from collections.abc import Callable
import pytest import pytest
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
@@ -14,9 +14,6 @@ from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User from core.models import AnonymousUser, User
if TYPE_CHECKING:
from collections.abc import Callable
def make_club(): def make_club():
# unittest-style tests cannot use fixture, so we create a function # unittest-style tests cannot use fixture, so we create a function
+1 -5
View File
@@ -1,6 +1,5 @@
from typing import TYPE_CHECKING
import pytest import pytest
from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
@@ -8,9 +7,6 @@ from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
def test_club_board_member_cannot_edit_club_properties(client: Client): def test_club_board_member_cannot_edit_club_properties(client: Client):
+2 -28
View File
@@ -1,6 +1,6 @@
import itertools import itertools
from collections.abc import Callable
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING
import pytest import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -17,10 +17,7 @@ from club.forms import ClubAddMemberForm, JoinClubForm
from club.models import Club, ClubRole, Membership from club.models import Club, ClubRole, Membership
from club.tests.base import TestClub from club.tests.base import TestClub
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User from core.models import AnonymousUser, User
if TYPE_CHECKING:
from collections.abc import Callable
class TestMembershipQuerySet(TestClub): class TestMembershipQuerySet(TestClub):
@@ -503,29 +500,6 @@ class TestMembership(TestClub):
assert self.subscriber.groups.contains(self.club.members_group) assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group) assert self.subscriber.groups.contains(self.club.board_group)
def test_add_to_club_role_group(self):
groups = baker.make(Group, _quantity=4)
self.subscriber.groups.set(groups[:1])
self.board_role.linked_groups.set(groups[1:3])
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
assert set(self.subscriber.groups.all()) == {
*groups[:3],
self.club.board_group,
self.club.members_group,
}
def test_remove_from_club_role_group(self):
groups = baker.make(Group, _quantity=3)
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
self.subscriber.groups.set(groups[:1])
self.board_role.linked_groups.set(groups[1:])
self.subscriber.memberships.update(end_date=localdate())
assert set(self.subscriber.groups.all()) == {groups[0]}
def test_change_position_in_club(self): def test_change_position_in_club(self):
"""Test that when moving from board to members, club group change""" """Test that when moving from board to members, club group change"""
membership = baker.make( membership = baker.make(
+1 -5
View File
@@ -1,8 +1,7 @@
from typing import TYPE_CHECKING
import pytest import pytest
from aemark import markdown from aemark import markdown
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects from pytest_django.asserts import assertHTMLEqual, assertRedirects
@@ -11,9 +10,6 @@ from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import PageRev, User from core.models import PageRev, User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
def test_page_display_on_club_main_page(client: Client): def test_page_display_on_club_main_page(client: Client):
+1 -5
View File
@@ -1,6 +1,5 @@
from typing import TYPE_CHECKING
import pytest import pytest
from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
@@ -8,9 +7,6 @@ from club.models import Club
from com.models import Poster from com.models import Poster
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"]) @pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"])
+1 -4
View File
@@ -1,8 +1,8 @@
import csv import csv
import itertools import itertools
from typing import TYPE_CHECKING
import pytest import pytest
from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
@@ -12,9 +12,6 @@ from core.models import User
from counter.baker_recipes import product_recipe, sale_recipe from counter.baker_recipes import product_recipe, sale_recipe
from counter.models import Counter, Customer, Product, Selling from counter.models import Counter, Customer, Product, Selling
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
def test_sales_page_doesnt_crash(client: Client): def test_sales_page_doesnt_crash(client: Client):
+1 -4
View File
@@ -1,5 +1,4 @@
from datetime import date from datetime import date
from typing import TYPE_CHECKING
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
@@ -10,13 +9,11 @@ from django.utils.translation import gettext_lazy as _
from club.models import Club from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub from club.widgets.ajax_select import AutoCompleteSelectClub
from com.models import News, NewsDate, Poster from com.models import News, NewsDate, Poster
from core.models import User
from core.utils import get_end_of_semester from core.utils import get_end_of_semester
from core.views.forms import SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
if TYPE_CHECKING:
from core.models import User
class PosterForm(forms.ModelForm): class PosterForm(forms.ModelForm):
class Meta: class Meta:
+3 -8
View File
@@ -1,10 +1,11 @@
from typing import TYPE_CHECKING from pathlib import Path
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.contrib.syndication.views import add_domain from django.contrib.syndication.views import add_domain
from django.db.models import Count, OuterRef, QuerySet, Subquery from django.db.models import Count, OuterRef, QuerySet, Subquery
from django.http import HttpRequest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from ical.calendar import Calendar from ical.calendar import Calendar
@@ -13,13 +14,7 @@ from ical.event import Event
from ical.types import Frequency, Recur from ical.types import Frequency, Recur
from com.models import News, NewsDate from com.models import News, NewsDate
from core.models import User
if TYPE_CHECKING:
from pathlib import Path
from django.http import HttpRequest
from core.models import User
def as_absolute_url(url: str, request: HttpRequest | None = None) -> str: def as_absolute_url(url: str, request: HttpRequest | None = None) -> str:
+1 -4
View File
@@ -1,11 +1,11 @@
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
import pytest import pytest
from aemark import markdown from aemark import markdown
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.http import HttpResponse
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
@@ -16,9 +16,6 @@ from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate from com.models import News, NewsDate
from core.models import User from core.models import User
if TYPE_CHECKING:
from django.http import HttpResponse
def accel_redirect_to_file(response: HttpResponse) -> Path | None: def accel_redirect_to_file(response: HttpResponse) -> Path | None:
redirect = Path(response.headers.get("X-Accel-Redirect", "")) redirect = Path(response.headers.get("X-Accel-Redirect", ""))
+11
View File
@@ -3,6 +3,7 @@ from typing import Annotated, Literal
from annotated_types import Ge, Le, MinLen from annotated_types import Ge, Le, MinLen
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from django.http import HttpResponse
from ninja import File, Query from ninja import File, Query
from ninja.security import SessionAuth from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
@@ -17,6 +18,7 @@ from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import ( from core.schemas import (
FamilyGodfatherSchema, FamilyGodfatherSchema,
GroupSchema, GroupSchema,
MarkdownSchema,
SithFileSchema, SithFileSchema,
UploadedFileSchema, UploadedFileSchema,
UploadedImage, UploadedImage,
@@ -26,9 +28,18 @@ from core.schemas import (
UserSchema, UserSchema,
ValidationErrorSchema, ValidationErrorSchema,
) )
from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
@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("/upload") @api_controller("/upload")
class UploadController(ControllerBase): class UploadController(ControllerBase):
@route.post( @route.post(
@@ -1,13 +1,9 @@
# Generated by Django 4.2.17 on 2025-01-04 16:42 # Generated by Django 4.2.17 on 2025-01-04 16:42
from typing import TYPE_CHECKING
from django.db import migrations from django.db import migrations
from django.db.migrations.state import StateApps
from django.db.models import F from django.db.models import F
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def invert_is_manually_manageable(apps: StateApps, schema_editor): def invert_is_manually_manageable(apps: StateApps, schema_editor):
"""Invert `is_manually_manageable`. """Invert `is_manually_manageable`.
@@ -1,14 +1,10 @@
# Generated by Django 4.2.17 on 2024-12-31 13:30 # Generated by Django 4.2.17 on 2024-12-31 13:30
from typing import TYPE_CHECKING
import django.contrib.auth.models import django.contrib.auth.models
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def migrate_ban_groups(apps: StateApps, schema_editor): def migrate_ban_groups(apps: StateApps, schema_editor):
@@ -1,15 +1,11 @@
# Generated by Django 5.2.12 on 2026-05-01 08:59 # Generated by Django 5.2.12 on 2026-05-01 08:59
from typing import TYPE_CHECKING
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import F from django.db.models import F
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def set_updated_at(apps: StateApps, schema_editor): def set_updated_at(apps: StateApps, schema_editor):
SithFile = apps.get_model("core", "SithFile") SithFile = apps.get_model("core", "SithFile")
+4
View File
@@ -161,6 +161,10 @@ class UserFilterSchema(FilterSchema):
return value return value
class MarkdownSchema(Schema):
text: str
class FamilyGodfatherSchema(Schema): class FamilyGodfatherSchema(Schema):
godfather: int godfather: int
godchild: int godchild: int
+12 -26
View File
@@ -6,15 +6,10 @@
* for more efficient tree-shaking and gzip compression. * for more efficient tree-shaking and gzip compression.
*/ */
// Must be loaded before Apline
import htmx from "htmx.org";
import "htmx.org/dist/ext/hx-alpine-compat.js";
import "htmx.org/dist/ext/hx-prompt.js";
import "htmx.org/dist/ext/hx-download.js";
import sort from "@alpinejs/sort"; import sort from "@alpinejs/sort";
import Alpine from "alpinejs"; import Alpine from "alpinejs";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill"; import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import htmx from "htmx.org";
import { limitedChoices } from "#core:alpine/limited-choices"; import { limitedChoices } from "#core:alpine/limited-choices";
import { expireOldStorage } from "#core:core/localstorage"; import { expireOldStorage } from "#core:core/localstorage";
import { default as navbar } from "#core:core/navbar"; import { default as navbar } from "#core:core/navbar";
@@ -48,28 +43,19 @@ polyfillCountryFlagEmojis();
/** /**
* HTMX * HTMX
*/ */
htmx.registerExtension("aria-busy", { document.body.addEventListener(
// biome-ignore lint/style/useNamingConvention: api's name "htmx:beforeRequest" as keyof HTMLElementEventMap,
htmx_before_request: ( (event) => {
_: HTMLElement, (event as CustomEvent).detail.target.ariaBusy = true;
detail: { ctx: { target: HTMLElement | undefined } },
) => {
if (detail.ctx.target !== undefined) {
(detail.ctx.target as HTMLElement).ariaBusy = "true";
}
}, },
// biome-ignore lint/style/useNamingConvention: api's name );
htmx_after_swap: (
_: HTMLElement,
detail: { ctx: { target: HTMLElement | undefined } },
) => {
if (detail.ctx.target !== undefined) {
(detail.ctx.target as HTMLElement).ariaBusy = null;
}
},
});
htmx.config.transitions = true; document.body.addEventListener(
"htmx:beforeSwap" as keyof HTMLElementEventMap,
(event) => {
(event as CustomEvent).detail.target.ariaBusy = null;
},
);
Object.assign(window, { htmx }); Object.assign(window, { htmx });
@@ -3,12 +3,16 @@
import "codemirror/lib/codemirror.css"; import "codemirror/lib/codemirror.css";
// @ts-expect-error 2307 // @ts-expect-error 2307
import "easymde/src/css/easymde.css"; import "easymde/src/css/easymde.css";
import { markdown } from "@ae_utbm/aemark";
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE // biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
import type CodeMirror from "codemirror"; // biome-ignore lint/style/useNamingConvention: This is how they called their namespace import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde"; import EasyMDE from "easymde";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { type UploadUploadImageErrors, uploadUploadImage } from "#openapi"; import {
markdownRenderMarkdown,
type UploadUploadImageErrors,
uploadUploadImage,
} from "#openapi";
const loadEasyMde = (textarea: HTMLTextAreaElement) => { const loadEasyMde = (textarea: HTMLTextAreaElement) => {
const easymde = new EasyMDE({ const easymde = new EasyMDE({
@@ -60,7 +64,19 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}); });
easymde.codemirror.replaceSelection("\n"); easymde.codemirror.replaceSelection("\n");
}, },
previewRender: (plainText) => markdown(plainText), previewRender: (plainText, preview) => {
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce(() => {
const func = async () => {
preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } })
).data as string;
return null;
};
func().then();
return null;
}, 300)();
},
forceSync: true, // Avoid validation error on generic create view forceSync: true, // Avoid validation error on generic create view
imageTexts: { imageTexts: {
sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."), sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."),
-1
View File
@@ -42,7 +42,6 @@ details.accordion>.accordion-content {
background: #ffffff; background: #ffffff;
color: #333333; color: #333333;
padding: 1em 2.2em; padding: 1em 2.2em;
margin-top: 0;
border: 1px solid #dddddd; border: 1px solid #dddddd;
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px; border-bottom-left-radius: 3px;
-4
View File
@@ -4,10 +4,6 @@
<div id="page"> <div id="page">
<h3>{% trans %}404, Not Found{% endtrans %}</h3> <h3>{% trans %}404, Not Found{% endtrans %}</h3>
<blockquote>
{% trans %}Impossible, perhaps the archives are incomplete{% endtrans %}
</blockquote>
</div> </div>
{% endblock %} {% endblock %}
-2
View File
@@ -14,8 +14,6 @@
{%- endblock %}" {%- endblock %}"
> >
<meta property="og:site_name" content="Association des Étudiants de l'UTBM" /> <meta property="og:site_name" content="Association des Étudiants de l'UTBM" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:locale:alternate" content="en_GB" />
{% block metatags %} {% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" /> <meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
+26 -28
View File
@@ -4,11 +4,15 @@
{% trans %}Delete confirmation{% endtrans %} {% trans %}Delete confirmation{% endtrans %}
{% endblock %} {% endblock %}
{# Don't display tabs and errors #} {% if is_fragment %}
{% block tabs %}
{% endblock %} {# Don't display tabs and errors #}
{% block errors %} {% block tabs %}
{% endblock %} {% endblock %}
{% block errors %}
{% endblock %}
{% endif %}
{% block file %} {% block file %}
<h2>{% trans %}Delete confirmation{% endtrans %}</h2> <h2>{% trans %}Delete confirmation{% endtrans %}</h2>
@@ -19,36 +23,30 @@
{% set action = current %} {% set action = current %}
{% endif %} {% endif %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p> <form action="{{ action }}" method="post">
<div class="row gap"> {% csrf_token %}
<form
style="margin-right: 0" <p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
method="post" <button
{% if is_fragment %} {% if is_fragment %}
hx-action="{{ action }}" hx-post="{{ action }}"
hx-target="#content" hx-target="#content"
hx-swap="innerHTML" hx-swap="outerHtml"
{% else %}
action="{{ action }}"
{% endif %} {% endif %}
> >{% trans %}Confirm{% endtrans %}</button>
{% csrf_token %}
<input type="submit" value="{% trans %}Confirm{% endtrans %}" /> <button
</form>
<form
style="margin-left: 0"
method="get"
{% if is_fragment %} {% if is_fragment %}
hx-action="{{ previous }}" hx-get="{{ previous }}"
hx-target="#content" hx-target="#content"
hx-swap="innerHTML" hx-swap="outerHtml"
{% else %} {% else %}
action="javascript:history.back();" action="window.history.back()"
{% endif %} {% endif %}
> >{% trans %}Cancel{% endtrans %}</button>
<input type="submit" name="cancel" value="{% trans %}Cancel{% endtrans %}" />
</form> </form>
</div>
{% endblock %} {% endblock %}
+2 -2
View File
@@ -39,13 +39,13 @@
<p><button <p><button
hx-get="{{ url('core:file_moderate', file_id=f.id) }}" hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
hx-target="#content" hx-target="#content"
hx-swap="innerHTML" hx-swap="outerHtml"
>{% trans %}Moderate{% endtrans %}</button> - >{% trans %}Moderate{% endtrans %}</button> -
{% set current_page = url('core:file_moderation') + "?page=" + page_obj.number | string %} {% set current_page = url('core:file_moderation') + "?page=" + page_obj.number | string %}
<button <button
hx-get="{{ url('core:file_delete', file_id=f.id) }}?next={{ current_page | urlencode }}&previous={{ current_page | urlencode }}" hx-get="{{ url('core:file_delete', file_id=f.id) }}?next={{ current_page | urlencode }}&previous={{ current_page | urlencode }}"
hx-target="#file-{{ loop.index }}" hx-target="#file-{{ loop.index }}"
hx-swap="innerHTML" hx-swap="outerHtml"
>{% trans %}Delete{% endtrans %}</button></p> >{% trans %}Delete{% endtrans %}</button></p>
</div> </div>
{% endfor %} {% endfor %}
@@ -1,6 +1,6 @@
<form <form
hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}" hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}"
hx-disable="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }" hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }"
> >
{% for message in messages %} {% for message in messages %}
+2 -4
View File
@@ -20,15 +20,13 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from typing import TYPE_CHECKING, Callable from typing import Callable
import honeypot.templatetags.honeypot as honeypot_filters import honeypot.templatetags.honeypot as honeypot_filters
from django.template.loader import render_to_string from django.template.loader import render_to_string
from jinja2 import Environment, nodes from jinja2 import Environment, nodes
from jinja2.ext import Extension from jinja2.ext import Extension
from jinja2.parser import Parser
if TYPE_CHECKING:
from jinja2.parser import Parser
class HoneypotExtension(Extension): class HoneypotExtension(Extension):
+1 -4
View File
@@ -23,18 +23,15 @@
# #
import datetime import datetime
from typing import TYPE_CHECKING
import phonenumbers import phonenumbers
from aemark import markdown as md from aemark import markdown as md
from django import template from django import template
from django.forms import BoundField
from django.template.defaultfilters import stringfilter from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ngettext from django.utils.translation import ngettext
if TYPE_CHECKING:
from django.forms import BoundField
register = template.Library() register = template.Library()
+1 -4
View File
@@ -1,5 +1,4 @@
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING
import freezegun import freezegun
import pytest import pytest
@@ -7,6 +6,7 @@ from aemark import markdown
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
@@ -16,9 +16,6 @@ from club.models import Club, Membership
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.models import AnonymousUser, Page, PageRev, User from core.models import AnonymousUser, Page, PageRev, User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
class TestEditPage: class TestEditPage:
-16
View File
@@ -141,22 +141,6 @@ class TestSearchUsersView(TestSearchUsers):
response = self.client.get(reverse("core:search")) response = self.client.get(reverse("core:search"))
assert response.status_code == 200 assert response.status_code == 200
def test_search_with_whitelist_unique(self):
"""Test that when a user has a whitelist and appears in the results,
it appears only once.
This is a regression test (cf #1463)
"""
user = subscriber_user.make(is_viewable=False)
user.whitelisted_users.add(
*subscriber_user.make(_quantity=4, _bulk_create=True)
)
self.client.force_login(user)
response = self.client.get(
reverse("core:search", query={"query": user.last_name})
)
assert response.context_data["users"] == [user]
@pytest.mark.django_db @pytest.mark.django_db
def test_user_account_not_found(client: Client): def test_user_account_not_found(client: Client):
+22 -6
View File
@@ -21,7 +21,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from django.urls import path, register_converter from django.urls import path, re_path, register_converter
from django.views.generic import RedirectView from django.views.generic import RedirectView
from com.views import NewsListView from com.views import NewsListView
@@ -193,11 +193,27 @@ urlpatterns = [
name="user_gift_delete", name="user_gift_delete",
), ),
# File views # File views
path("file/", FileListView.as_view(), name="file_list"), re_path(r"^file/$", FileListView.as_view(), name="file_list"),
path("file/<int:file_id>/", FileView.as_view(), name="file_detail"), re_path(
path("file/<int:file_id>/edit/", FileEditView.as_view(), name="file_edit"), r"^file/(?P<file_id>[0-9]+)/$",
path("file/<int:file_id>/prop/", FileEditPropView.as_view(), name="file_prop"), FileView.as_view(),
path("file/<int:file_id>/delete/", FileDeleteView.as_view(), name="file_delete"), name="file_detail",
),
re_path(
r"^file/(?P<file_id>[0-9]+)/edit/$",
FileEditView.as_view(),
name="file_edit",
),
re_path(
r"^file/(?P<file_id>[0-9]+)/prop/$",
FileEditPropView.as_view(),
name="file_prop",
),
re_path(
r"^file/(?P<file_id>[0-9]+)/delete/$",
FileDeleteView.as_view(),
name="file_delete",
),
path("file/moderation/", FileModerationView.as_view(), name="file_moderation"), path("file/moderation/", FileModerationView.as_view(), name="file_moderation"),
path( path(
"file/<int:file_id>/moderate/", FileModerateView.as_view(), name="file_moderate" "file/<int:file_id>/moderate/", FileModerateView.as_view(), name="file_moderate"
+3 -5
View File
@@ -17,18 +17,16 @@ from datetime import date, timedelta
# Image utils # Image utils
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING, Final from typing import Final
import PIL import PIL
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL.Image import Image, Resampling from PIL.Image import Image, Resampling
if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
RED_PIXEL_PNG: Final[bytes] = ( RED_PIXEL_PNG: Final[bytes] = (
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52" b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53" b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
+10 -6
View File
@@ -13,7 +13,7 @@
# #
# #
import mimetypes import mimetypes
from typing import TYPE_CHECKING from pathlib import Path
from urllib.parse import quote, urljoin from urllib.parse import quote, urljoin
# This file contains all the views that concern the page model # This file contains all the views that concern the page model
@@ -48,9 +48,6 @@ from core.views.widgets.ajax_select import (
) )
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
if TYPE_CHECKING:
from pathlib import Path
def send_raw_file(path: Path) -> HttpResponse: def send_raw_file(path: Path) -> HttpResponse:
"""Send a file located in the MEDIA_ROOT """Send a file located in the MEDIA_ROOT
@@ -356,8 +353,15 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
if "next" in self.request.GET: if "next" in self.request.GET:
return self.request.GET["next"] return self.request.GET["next"]
if self.object.parent is None: if self.object.parent is None:
return reverse("core:file_list") return reverse(
return reverse("core:file_detail", kwargs={"file_id": self.object.parent.id}) "core:file_list",
)
return reverse(
"core:file_detail",
kwargs={
"file_id": self.object.parent.id,
},
)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
+1 -4
View File
@@ -22,8 +22,8 @@
# #
import re import re
from copy import copy from copy import copy
from datetime import date, datetime
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING
from captcha.fields import CaptchaField from captcha.fields import CaptchaField
from django import forms from django import forms
@@ -59,9 +59,6 @@ from core.views.widgets.ajax_select import (
) )
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
if TYPE_CHECKING:
from datetime import date, datetime
# Widgets # Widgets
+2 -7
View File
@@ -22,11 +22,11 @@
# #
# #
from typing import TYPE_CHECKING
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import F from django.db.models import F
from django.db.models.query import QuerySet
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.views.generic import ListView, TemplateView from django.views.generic import ListView, TemplateView
@@ -34,10 +34,6 @@ from club.models import Club
from core.models import Notification, User from core.models import Notification, User
from core.schemas import UserFilterSchema from core.schemas import UserFilterSchema
if TYPE_CHECKING:
from django.db.models.query import QuerySet
from django.http import HttpRequest
class NotificationList(LoginRequiredMixin, ListView): class NotificationList(LoginRequiredMixin, ListView):
model = Notification model = Notification
@@ -69,7 +65,6 @@ class SearchView(LoginRequiredMixin, TemplateView):
UserFilterSchema(search=query) UserFilterSchema(search=query)
.filter(User.objects.viewable_by(self.request.user)) .filter(User.objects.viewable_by(self.request.user))
.order_by(F("last_login").desc(nulls_last=True)) .order_by(F("last_login").desc(nulls_last=True))
.distinct()
) )
clubs = list(Club.objects.filter(name__icontains=query)[:5]) clubs = list(Club.objects.filter(name__icontains=query)[:5])
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs} return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}
+3 -5
View File
@@ -1,16 +1,14 @@
import copy import copy
import inspect import inspect
from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack from typing import Any, ClassVar, LiteralString, Protocol, Unpack
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.views import View from django.views import View
from django.views.generic.base import ContextMixin, TemplateResponseMixin from django.views.generic.base import ContextMixin, TemplateResponseMixin
if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponse
from django.utils.safestring import SafeString
class TabedViewMixin(View): class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template.""" """Basic functions for displaying tabs in the template."""
+1 -4
View File
@@ -27,7 +27,6 @@ from datetime import timedelta
# This file contains all the views that concern the user model # This file contains all the views that concern the user model
from operator import itemgetter from operator import itemgetter
from smtplib import SMTPException from smtplib import SMTPException
from typing import TYPE_CHECKING
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import login, views from django.contrib.auth import login, views
@@ -44,6 +43,7 @@ from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.views.generic import ( from django.views.generic import (
@@ -73,9 +73,6 @@ from counter.models import Refilling, Selling
from eboutic.models import Invoice from eboutic.models import Invoice
from trombi.views import UserTrombiForm from trombi.views import UserTrombiForm
if TYPE_CHECKING:
from django.utils.safestring import SafeString
class SithLoginView(views.LoginView): class SithLoginView(views.LoginView):
"""The login View.""" """The login View."""
+4 -7
View File
@@ -1,18 +1,15 @@
from typing import TYPE_CHECKING, Any from collections.abc import Collection
from typing import Any
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from django.db.models import Model, QuerySet
from django.forms import Select, SelectMultiple from django.forms import Select, SelectMultiple
from ninja import ModelSchema
from pydantic import TypeAdapter from pydantic import TypeAdapter
from core.models import Group, SithFile, User from core.models import Group, SithFile, User
from core.schemas import GroupSchema, SithFileSchema, UserProfileSchema from core.schemas import GroupSchema, SithFileSchema, UserProfileSchema
if TYPE_CHECKING:
from collections.abc import Collection
from django.db.models import Model, QuerySet
from ninja import ModelSchema
class AutoCompleteSelectMixin: class AutoCompleteSelectMixin:
component_name = "autocomplete-select" component_name = "autocomplete-select"
+2 -4
View File
@@ -3,7 +3,7 @@ import math
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from typing import TYPE_CHECKING, ClassVar from typing import ClassVar
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
@@ -11,6 +11,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet from django.forms import BaseModelFormSet
from django.http import HttpRequest
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -58,9 +59,6 @@ from counter.widgets.ajax_select import (
AutoCompleteSelectProduct, AutoCompleteSelectProduct,
) )
if TYPE_CHECKING:
from django.http import HttpRequest
class BillingInfoForm(forms.ModelForm): class BillingInfoForm(forms.ModelForm):
class Meta: class Meta:
+1 -4
View File
@@ -1,5 +1,5 @@
from collections.abc import Iterable
from operator import attrgetter from operator import attrgetter
from typing import TYPE_CHECKING
from django.conf import settings from django.conf import settings
from django.core.mail import send_mass_mail from django.core.mail import send_mass_mail
@@ -13,9 +13,6 @@ from django.utils.translation import gettext as _
from core.models import User, UserQuerySet from core.models import User, UserQuerySet
from counter.models import AccountDump, Counter, Customer, Product, Selling from counter.models import AccountDump, Counter, Customer, Product, Selling
if TYPE_CHECKING:
from collections.abc import Iterable
class Command(BaseCommand): class Command(BaseCommand):
"""Effectively dump the inactive users. """Effectively dump the inactive users.
+2 -3
View File
@@ -1,14 +1,13 @@
from typing import TYPE_CHECKING, Callable from typing import TYPE_CHECKING, Callable
from django.http import HttpRequest, HttpResponse
from django.utils.functional import SimpleLazyObject from django.utils.functional import SimpleLazyObject
from core.models import User
from counter.models import Permanency from counter.models import Permanency
if TYPE_CHECKING: if TYPE_CHECKING:
from django.contrib.sessions.backends.base import SessionBase from django.contrib.sessions.backends.base import SessionBase
from django.http import HttpRequest, HttpResponse
from core.models import User
SESSION_PERMANENCES_KEY = "permanence_ids" SESSION_PERMANENCES_KEY = "permanence_ids"
@@ -1,14 +1,11 @@
# Generated by Django 4.2.17 on 2024-12-08 13:30 # Generated by Django 4.2.17 on 2024-12-08 13:30
from operator import attrgetter from operator import attrgetter
from typing import TYPE_CHECKING
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Count from django.db.models import Count
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def delete_duplicates(apps: StateApps, schema_editor): def delete_duplicates(apps: StateApps, schema_editor):
"""Delete cards of users with more than one student cards. """Delete cards of users with more than one student cards.
@@ -1,11 +1,7 @@
# Generated by Django 4.2.17 on 2024-12-15 17:53 # Generated by Django 4.2.17 on 2024-12-15 17:53
from typing import TYPE_CHECKING
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def move_priority_to_order(apps: StateApps, schema_editor): def move_priority_to_order(apps: StateApps, schema_editor):
@@ -1,13 +1,9 @@
# Generated by Django 5.2.8 on 2025-11-19 17:59 # Generated by Django 5.2.8 on 2025-11-19 17:59
from typing import TYPE_CHECKING
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When from django.db.models import Case, When
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def migrate_selling_payment_method(apps: StateApps, schema_editor): def migrate_selling_payment_method(apps: StateApps, schema_editor):
# 0 <=> SITH_ACCOUNT is the default value, so no need to migrate it # 0 <=> SITH_ACCOUNT is the default value, so no need to migrate it
@@ -1,16 +1,13 @@
# Generated by Django 5.2.8 on 2026-02-10 15:40 # Generated by Django 5.2.8 on 2026-02-10 15:40
from operator import attrgetter from operator import attrgetter
from typing import TYPE_CHECKING
import django.utils.timezone import django.utils.timezone
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import OuterRef, Subquery from django.db.models import OuterRef, Subquery
from counter.models import Selling from counter.models import Selling
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def apply_product_history_dates(apps: StateApps, schema_editor): def apply_product_history_dates(apps: StateApps, schema_editor):
"""Approximate a posteriori the value of created_at and updated_at.""" """Approximate a posteriori the value of created_at and updated_at."""
+1 -5
View File
@@ -1,15 +1,11 @@
# Generated by Django 5.2.11 on 2026-02-18 13:30 # Generated by Django 5.2.11 on 2026-02-18 13:30
from typing import TYPE_CHECKING
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps
import counter.fields import counter.fields
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def migrate_prices(apps: StateApps, schema_editor): def migrate_prices(apps: StateApps, schema_editor):
Product = apps.get_model("counter", "Product") Product = apps.get_model("counter", "Product")
@@ -28,23 +28,18 @@ export class ProductAjaxSelect extends AjaxSelect {
return []; return [];
} }
// In the context in which this method is called, `this` might be shadowed private getName(item: SimpleProductSchema, sanitize: typeof escape_html): string {
// We need to call it explicitly from the class itself
private static getName(
item: SimpleProductSchema,
sanitize: typeof escape_html,
): string {
return item.code ? `${sanitize(item.code)} - ${sanitize(item.name)}` : item.name; return item.code ? `${sanitize(item.code)} - ${sanitize(item.name)}` : item.name;
} }
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) { protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<div class="select-item"> return `<div class="select-item">
<span class="select-item-text">${ProductAjaxSelect.getName(item, sanitize)}</span> <span class="select-item-text">${this.getName(item, sanitize)}</span>
</div>`; </div>`;
} }
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) { protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<span>${ProductAjaxSelect.getName(item, sanitize)}</span>`; return `<span>${this.getName(item, sanitize)}</span>`;
} }
} }
@@ -1,10 +1,5 @@
<div id="student_card_form"> <div id="student_card_form">
<form <form hx-post="{{ action }}" hx-swap="outerHTML" hx-target="#student_card_form">
hx-post="{{ action }}"
hx-swap="outerHTML"
hx-target="#student_card_form"
hx-disable="input[type='submit']"
>
{% csrf_token %} {% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p> <p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" /> <input type="submit" value="{% trans %}Confirm{% endtrans %}" />
+1 -4
View File
@@ -1,5 +1,5 @@
from collections.abc import Iterable
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING
import freezegun import freezegun
import pytest import pytest
@@ -17,9 +17,6 @@ from counter.management.commands.dump_warning_mail import Command as WarningComm
from counter.models import AccountDump, Customer, Refilling, Selling from counter.models import AccountDump, Customer, Refilling, Selling
from subscription.models import Subscription from subscription.models import Subscription
if TYPE_CHECKING:
from collections.abc import Iterable
class TestAccountDump(TestCase): class TestAccountDump(TestCase):
@classmethod @classmethod
+1 -4
View File
@@ -1,9 +1,9 @@
import json import json
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.test import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from django_celery_beat.models import ClockedSchedule from django_celery_beat.models import ClockedSchedule
@@ -18,9 +18,6 @@ from counter.forms import (
) )
from counter.models import Product, ProductType, ScheduledProductAction from counter.models import Product, ProductType, ScheduledProductAction
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
def test_edit_product(client: Client): def test_edit_product(client: Client):
+1 -4
View File
@@ -15,7 +15,6 @@
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import timedelta from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from typing import TYPE_CHECKING
import pytest import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -23,6 +22,7 @@ from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission, make_password from django.contrib.auth.models import Permission, make_password
from django.contrib.messages import DEFAULT_LEVELS, get_messages from django.contrib.messages import DEFAULT_LEVELS, get_messages
from django.http import HttpResponse
from django.shortcuts import resolve_url from django.shortcuts import resolve_url
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
@@ -48,9 +48,6 @@ from counter.models import (
Selling, Selling,
) )
if TYPE_CHECKING:
from django.http import HttpResponse
def set_age(user: User, age: int): def set_age(user: User, age: int):
user.date_of_birth = localdate().replace(year=localdate().year - age) user.date_of_birth = localdate().replace(year=localdate().year - age)
+1 -4
View File
@@ -1,10 +1,10 @@
from datetime import date, datetime from datetime import date, datetime
from typing import TYPE_CHECKING
import pytest import pytest
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
@@ -16,9 +16,6 @@ from counter.baker_recipes import sale_recipe
from counter.forms import InvoiceCallForm from counter.forms import InvoiceCallForm
from counter.models import Customer, InvoiceCall, Selling from counter.models import Customer, InvoiceCall, Selling
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.parametrize( @pytest.mark.parametrize(
+1 -5
View File
@@ -1,7 +1,6 @@
from typing import TYPE_CHECKING
import pytest import pytest
from django.conf import settings from django.conf import settings
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
@@ -9,9 +8,6 @@ from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User from core.models import Group, User
from counter.models import ProductType from counter.models import ProductType
if TYPE_CHECKING:
from django.test import Client
@pytest.fixture @pytest.fixture
def product_types(db) -> list[ProductType]: def product_types(db) -> list[ProductType]:
+1 -4
View File
@@ -1,11 +1,8 @@
from typing import TYPE_CHECKING
from urllib.parse import urlparse from urllib.parse import urlparse
from django.http import HttpRequest
from django.urls import resolve from django.urls import resolve
if TYPE_CHECKING:
from django.http import HttpRequest
def is_logged_in_counter(request: HttpRequest) -> bool: def is_logged_in_counter(request: HttpRequest) -> bool:
"""Check if the request is sent from a device logged to a counter. """Check if the request is sent from a device logged to a counter.
+1 -5
View File
@@ -13,8 +13,6 @@
# #
# #
from typing import TYPE_CHECKING
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
@@ -24,6 +22,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import BaseFormView, UpdateView from django.views.generic.edit import BaseFormView, UpdateView
from core.models import User
from counter.forms import CashSummaryFormBase from counter.forms import CashSummaryFormBase
from counter.models import ( from counter.models import (
CashRegisterSummary, CashRegisterSummary,
@@ -36,9 +35,6 @@ from counter.views.mixins import (
CounterTabsMixin, CounterTabsMixin,
) )
if TYPE_CHECKING:
from core.models import User
class CashRegisterSummaryForm(forms.Form): class CashRegisterSummaryForm(forms.Form):
"""Provide the cash summary form.""" """Provide the cash summary form."""
+3 -7
View File
@@ -14,7 +14,6 @@
# #
import random import random
from collections import defaultdict from collections import defaultdict
from typing import TYPE_CHECKING
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@@ -23,11 +22,14 @@ from django.db.models import Q
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, resolve_url from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import CreateView, FormView from django.views.generic import CreateView, FormView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest
from core.auth.mixins import CanViewMixin from core.auth.mixins import CanViewMixin
from core.models import User
from core.views.mixins import FragmentMixin, UseFragmentsMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BasketForm, RefillForm from counter.forms import BasketForm, RefillForm
from counter.models import ( from counter.models import (
@@ -42,12 +44,6 @@ from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin from counter.views.mixins import CounterTabsMixin
from counter.views.student_card import StudentCardFormFragment from counter.views.student_card import StudentCardFormFragment
if TYPE_CHECKING:
from django.utils.safestring import SafeString
from ninja.main import HttpRequest
from core.models import User
def get_operator(request: HttpRequest, counter: Counter, customer: Customer) -> User: def get_operator(request: HttpRequest, counter: Counter, customer: Customer) -> User:
if counter.type != "BAR": if counter.type != "BAR":
+1 -4
View File
@@ -13,7 +13,6 @@
# #
# #
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@@ -22,6 +21,7 @@ from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.safestring import SafeString
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.views.generic import DetailView from django.views.generic import DetailView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
@@ -35,9 +35,6 @@ from counter.models import Counter, Permanency
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin from counter.views.mixins import CounterTabsMixin
if TYPE_CHECKING:
from django.utils.safestring import SafeString
class CounterLoginFragment(FragmentMixin, SingleObjectMixin, FormView): class CounterLoginFragment(FragmentMixin, SingleObjectMixin, FormView):
model = Counter model = Counter
+1 -5
View File
@@ -13,12 +13,11 @@
# #
# #
from typing import TYPE_CHECKING
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpRequest, HttpResponse from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic.edit import DeleteView, FormView from django.views.generic.edit import DeleteView, FormView
@@ -28,9 +27,6 @@ from counter.forms import StudentCardForm
from counter.models import Customer, StudentCard from counter.models import Customer, StudentCard
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
if TYPE_CHECKING:
from django.utils.safestring import SafeString
class StudentCardDeleteView(DeleteView): class StudentCardDeleteView(DeleteView):
"""View used to delete a card from a user. This is a fragment view !""" """View used to delete a card from a user. This is a fragment view !"""
+1 -1
View File
@@ -217,7 +217,7 @@ C'est une technologie simple et puissante qui se veut comme le jQuery du web mod
### Htmx ### Htmx
[Site officiel](https://four.htmx.org/) [Site officiel](https://htmx.org/)
En plus de AlpineJS, linteractivité sur le site est augmentée via Htmx. En plus de AlpineJS, linteractivité sur le site est augmentée via Htmx.
C'est une librairie js qui s'utilise également au moyen d'attributs HTML à C'est une librairie js qui s'utilise également au moyen d'attributs HTML à
-51
View File
@@ -1,51 +0,0 @@
## syntaxe aemark
Le site AE utilise markdown pour le rendu de la plupart
des textes saisis par les utilisateurs.
Cependant, la syntaxe utilisée n'est pas celle officielle
telle que définie par John Gruber,
mais est basée sur [CommonMark](https://commonmark.org/),
avec quelques variations pour les usages particuliers du site AE.
Les deux principaux ajouts sont :
- Les urls commençant par `page://` sont modifiées pour commencer par `/page/`
- Des modificateurs de taille peuvent être indiqués directement dans la source
d'une image
Les variations d'aemark sont documentées sur
[le site AE](https://ae.utbm.fr/page/Aide_sur_la_syntaxe/).
Le code est hébergé sur le dépôt Git [aemark](https://github.com/ae-utbm/ae-markdown).
## Utiliser aemark
Le code du parser aemark est écrit en Rust dans une librairie
indépendante, avec des bindings vers différents langages.
Les librairies mises à disposition exposent une seule fonction,
qui prend simplement du markdown en entrée et renvoie l'HTML correspondant.
Les librairies pour les différents langages sont toutes
basées sur le même code Rust.
La seule différence entre chacune tient uniquement dans la manière
d'intégrer ce code dans les bindings.
De cette manière, le comportement est assuré d'être le même
sur toutes les plateformes disponibles, avec en prime des performances
respectables.
=== ":simple-python: Python"
```python
from aemark import markdown
result = markdown("this is some *markdown text* with __formatting__")
```
=== ":simple-javascript: Javascript"
```typescript
import { markdown } from "@ae_utbm/aemark"
const result = markdown("this is some *markdown text* with __formatting__");
```
+2 -5
View File
@@ -1,12 +1,13 @@
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING
import freezegun import freezegun
import pytest import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.http import HttpResponse
from django.test import TestCase from django.test import TestCase
from django.test.client import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localdate, now from django.utils.timezone import localdate, now
from model_bakery import baker from model_bakery import baker
@@ -30,10 +31,6 @@ from counter.models import (
from counter.tests.test_counter import BasketItem from counter.tests.test_counter import BasketItem
from eboutic.models import Basket from eboutic.models import Basket
if TYPE_CHECKING:
from django.http import HttpResponse
from django.test.client import Client
@pytest.mark.django_db @pytest.mark.django_db
def test_get_eboutic(): def test_get_eboutic():
+2 -4
View File
@@ -1,6 +1,7 @@
from typing import TYPE_CHECKING, Callable from typing import Callable
import pytest import pytest
from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
@@ -9,9 +10,6 @@ from core.baker_recipes import subscriber_user
from core.models import User from core.models import User
from counter.models import BillingInfo from counter.models import BillingInfo
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
class TestBillingInfo: class TestBillingInfo:
+1 -4
View File
@@ -1,7 +1,6 @@
from datetime import timedelta from datetime import timedelta
from itertools import groupby, islice from itertools import groupby, islice
from operator import attrgetter from operator import attrgetter
from typing import TYPE_CHECKING
from django import forms from django import forms
from django.conf import settings from django.conf import settings
@@ -14,6 +13,7 @@ from django.utils.translation import gettext_lazy as _
from club.forms import ClubRoleChoiceField from club.forms import ClubRoleChoiceField
from club.models import ClubRole, Membership from club.models import ClubRole, Membership
from club.widgets.ajax_select import AutoCompleteSelectMultipleClub from club.widgets.ajax_select import AutoCompleteSelectMultipleClub
from core.models import User
from core.views.forms import SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
AutoCompleteSelect, AutoCompleteSelect,
@@ -23,9 +23,6 @@ from core.views.widgets.ajax_select import (
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
from election.models import Candidature, Election, ElectionList, Role from election.models import Candidature, Election, ElectionList, Role
if TYPE_CHECKING:
from core.models import User
class LimitedCheckboxField(forms.ModelMultipleChoiceField): class LimitedCheckboxField(forms.ModelMultipleChoiceField):
"""A `ModelMultipleChoiceField`, with a max limit of selectable inputs.""" """A `ModelMultipleChoiceField`, with a max limit of selectable inputs."""
@@ -41,7 +41,7 @@
hx-post="{{ url("election:apply_result", election_id=form.election.id) }}" hx-post="{{ url("election:apply_result", election_id=form.election.id) }}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#apply-election-result-fragment" hx-target="#apply-election-result-fragment"
hx-disable="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
> >
{% csrf_token %} {% csrf_token %}
{{ form }} {{ form }}
+1 -5
View File
@@ -13,19 +13,15 @@
# #
# #
from typing import TYPE_CHECKING
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.test import Client
from django.urls import reverse from django.urls import reverse
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from core.models import User from core.models import User
from forum.models import Forum, ForumMessage, ForumTopic from forum.models import Forum, ForumMessage, ForumTopic
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db @pytest.mark.django_db
class TestTopicCreation: class TestTopicCreation:
+71 -143
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-14 11:48+0200\n" "POT-Creation-Date: 2026-08-21 14:10+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -260,18 +260,6 @@ msgstr ""
"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui " "Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui "
"rejoignent le club." "rejoignent le club."
#: club/models.py
msgid "Linked groups"
msgstr "Groupes liés"
#: club/models.py
msgid ""
"Groups that are automatically given or removed to user receiving or losing "
"this club role"
msgstr ""
"Les groupes qui sont automatiquement donnés ou retirés quand l'utilisateur "
"reçoit ou perd ce rôle de club"
#: club/models.py election/models.py #: club/models.py election/models.py
msgid "club role" msgid "club role"
msgstr "rôle de club" msgstr "rôle de club"
@@ -497,14 +485,6 @@ msgstr "Du"
msgid "To" msgid "To"
msgstr "Au" msgstr "Au"
#: club/templates/club/club_roles.jinja
msgid "Linked groups : "
msgstr "Groupes liés : "
#: club/templates/club/club_roles.jinja
msgid "Users receiving this role will also be assigned to those groups"
msgstr "Les utilisateurs recevant ce rôle seront aussi assignés à ces groupes"
#: club/templates/club/club_roles.jinja #: club/templates/club/club_roles.jinja
msgid "" msgid ""
"Roles give rights on the club. Higher roles grant more rights, and the " "Roles give rights on the club. Higher roles grant more rights, and the "
@@ -736,9 +716,8 @@ msgstr "Méthode de paiement"
#: counter/templates/counter/fragments/create_student_card.jinja #: counter/templates/counter/fragments/create_student_card.jinja
#: counter/templates/counter/last_ops.jinja #: counter/templates/counter/last_ops.jinja
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja #: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja #: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja
#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja
#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja #: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
#: trombi/templates/trombi/detail.jinja #: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja #: trombi/templates/trombi/edit_profile.jinja
@@ -932,9 +911,8 @@ msgstr "Outils"
#: counter/templates/counter/cash_summary_list.jinja #: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/counter_list.jinja #: counter/templates/counter/counter_list.jinja
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja #: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja #: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja
#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja #: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja #: trombi/templates/trombi/edit_profile.jinja
msgid "Edit" msgid "Edit"
@@ -1190,9 +1168,8 @@ msgstr "Actions"
#: com/templates/com/mailing_admin.jinja com/templates/com/poster_list.jinja #: com/templates/com/mailing_admin.jinja com/templates/com/poster_list.jinja
#: core/templates/core/file_detail.jinja #: core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja #: core/templates/core/file_moderation.jinja sas/templates/sas/moderation.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja #: sas/templates/sas/picture.jinja
#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
msgid "Moderate" msgid "Moderate"
msgstr "Modérer" msgstr "Modérer"
@@ -1494,6 +1471,7 @@ msgstr "Descendre"
#: com/templates/com/weekmail_preview.jinja #: com/templates/com/weekmail_preview.jinja
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: trombi/templates/trombi/comment_moderation.jinja #: trombi/templates/trombi/comment_moderation.jinja
#: trombi/templates/trombi/export.jinja #: trombi/templates/trombi/export.jinja
msgid "Back" msgid "Back"
@@ -1844,9 +1822,7 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
#: core/models.py core/templates/core/macros.jinja #: core/models.py core/templates/core/macros.jinja
#: core/templates/core/user_detail.jinja core/templates/core/user_edit.jinja #: core/templates/core/user_detail.jinja core/templates/core/user_edit.jinja
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja #: forum/templates/forum/macros.jinja trombi/templates/trombi/user_tools.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: trombi/templates/trombi/user_tools.jinja
msgid "Profile" msgid "Profile"
msgstr "Profil" msgstr "Profil"
@@ -2073,10 +2049,6 @@ msgstr "403, Non autorisé"
msgid "404, Not Found" msgid "404, Not Found"
msgstr "404. Non trouvé" msgstr "404. Non trouvé"
#: core/templates/core/404.jinja
msgid "Impossible, perhaps the archives are incomplete"
msgstr "Impossible, les archives sont peut-être incomplètes"
#: core/templates/core/500.jinja #: core/templates/core/500.jinja
msgid "500, Server Error" msgid "500, Server Error"
msgstr "500, Erreur Serveur" msgstr "500, Erreur Serveur"
@@ -2237,7 +2209,6 @@ msgstr "Confirmation"
#: core/templates/core/file_delete_confirm.jinja #: core/templates/core/file_delete_confirm.jinja
#: counter/templates/counter/counter_click.jinja #: counter/templates/counter/counter_click.jinja
#: counter/templates/counter/fragments/delete_student_card.jinja #: counter/templates/counter/fragments/delete_student_card.jinja
#: pedagogy/templates/pedagogy/fragments/comment_report.jinja
#: sas/templates/sas/ask_picture_removal.jinja #: sas/templates/sas/ask_picture_removal.jinja
msgid "Cancel" msgid "Cancel"
msgstr "Annuler" msgstr "Annuler"
@@ -3784,6 +3755,7 @@ msgstr "Coffre vidé"
#: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py #: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py
#: pedagogy/templates/pedagogy/moderation.jinja #: pedagogy/templates/pedagogy/moderation.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: trombi/templates/trombi/comment.jinja #: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/user_tools.jinja #: trombi/templates/trombi/user_tools.jinja
msgid "Comment" msgid "Comment"
@@ -5113,74 +5085,10 @@ msgstr "signaler"
msgid "reporter" msgid "reporter"
msgstr "signalant" msgstr "signalant"
#: pedagogy/templates/pedagogy/fragments/comment_report.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
msgid "Report"
msgstr "Signaler"
#: pedagogy/templates/pedagogy/fragments/ue_comment_form.jinja
msgid "Leave comment"
msgstr "Laisser un commentaire"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: trombi/templates/trombi/export.jinja
msgid "Comments"
msgstr "Commentaires"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
msgid "This comment has been reported"
msgstr "Ce commentaire a été signalé"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
msgid "It will be hidden until moderated."
msgstr "Il sera caché tant qu'il ne sera pas modéré."
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/grade.jinja
msgid "Global grade"
msgstr "Note globale"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/grade.jinja
msgid "Utility"
msgstr "Utilité"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/grade.jinja
msgid "Interest"
msgstr "Intérêt"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/grade.jinja
msgid "Teaching"
msgstr "Enseignement"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/grade.jinja
msgid "Workload"
msgstr "Charge de travail"
#: pedagogy/templates/pedagogy/fragments/ue_detail/ue_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid ""
"You already posted a comment on this UE. If you want to comment again, "
"please modify or delete your previous comment."
msgstr ""
"Vous avez déjà commenté cette UE. Si vous voulez de nouveau commenter, "
"veuillez modifier ou supprimer votre commentaire précédent."
#: pedagogy/templates/pedagogy/guide.jinja #: pedagogy/templates/pedagogy/guide.jinja
msgid "A guide of courses available at UTBM." msgid "A guide of courses available at UTBM."
msgstr "Un guide de tous les cours disponibles à l'UTBM." msgstr "Un guide de tous les cours disponibles à l'UTBM."
#: pedagogy/templates/pedagogy/guide.jinja
msgid "Search UE"
msgstr "Recherche d'UE"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "Hide closed UEs"
msgstr "Cacher les UEs fermées"
#: pedagogy/templates/pedagogy/guide.jinja #: pedagogy/templates/pedagogy/guide.jinja
#, python-format #, python-format
msgid "%(display_name)s" msgid "%(display_name)s"
@@ -5234,20 +5142,44 @@ msgid "UE Details"
msgstr "Détails d'UE" msgstr "Détails d'UE"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Category" msgid "CM: "
msgstr "Catégorie" msgstr "CM : "
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Credits" msgid "TD: "
msgstr "Crédits" msgstr "TD : "
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Availability" msgid "TP: "
msgstr "Disponibilité" msgstr "TP : "
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Manager" msgid "TE: "
msgstr "Responsable" msgstr "TE : "
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "THE: "
msgstr "THE : "
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Global grade"
msgstr "Note globale"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Utility"
msgstr "Utilité"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Interest"
msgstr "Intérêt"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Teaching"
msgstr "Enseignement"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Work load"
msgstr "Charge de travail"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Objectives" msgid "Objectives"
@@ -5265,6 +5197,35 @@ msgstr "Compétences acquises"
msgid "Key concepts" msgid "Key concepts"
msgstr "Concepts clefs" msgstr "Concepts clefs"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "UE manager: "
msgstr "Gestionnaire d'UE : "
#: pedagogy/templates/pedagogy/ue_detail.jinja pedagogy/tests/tests.py
msgid ""
"You already posted a comment on this UE. If you want to comment again, "
"please modify or delete your previous comment."
msgstr ""
"Vous avez déjà commenté cette UE. Si vous voulez de nouveau commenter, "
"veuillez modifier ou supprimer votre commentaire précédent."
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Leave comment"
msgstr "Laisser un commentaire"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: trombi/templates/trombi/export.jinja
msgid "Comments"
msgstr "Commentaires"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "This comment has been reported"
msgstr "Ce commentaire a été signalé"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Report this comment"
msgstr "Signaler ce commentaire"
#: pedagogy/templates/pedagogy/ue_edit.jinja #: pedagogy/templates/pedagogy/ue_edit.jinja
msgid "Edit UE" msgid "Edit UE"
msgstr "Éditer l'UE" msgstr "Éditer l'UE"
@@ -5797,18 +5758,6 @@ msgstr "fin de la cotisation"
msgid "location" msgid "location"
msgstr "lieu" msgstr "lieu"
#: subscription/models.py
msgid "created_at"
msgstr "créé le"
#: subscription/models.py
msgid ""
"When this subscription was created. This date may differ from the start of "
"the subscription."
msgstr ""
"Quand la cotisation a été créée. Cette date peut différer du début effectif "
"de la cotisation."
#: subscription/models.py #: subscription/models.py
msgid "You can not subscribe many time for the same period" msgid "You can not subscribe many time for the same period"
msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période" msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période"
@@ -5863,27 +5812,6 @@ msgstr "les groupes pouvant créer des cotisations"
msgid "Timetable generator" msgid "Timetable generator"
msgstr "Générateur d'emploi du temps" msgstr "Générateur d'emploi du temps"
#: timetable/templates/timetable/generator.jinja
msgid "Get a nice schedule from the raw text in your student file"
msgstr ""
"Générez un joli emploi du temps à partir du texte brut de votre dossier "
"étudiant"
#: timetable/templates/timetable/generator.jinja
msgid ""
"Wrong timetable format. Make sure you copied it from your student folder."
msgstr ""
"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis "
"votre dossier étudiant."
#: timetable/templates/timetable/generator.jinja
msgid "Incorrect row"
msgstr "Ligne incorrecte"
#: timetable/templates/timetable/generator.jinja
msgid "Paste your schedule (without the header)"
msgstr "Colle ton emploi du temps (sans l'entête)"
#: timetable/templates/timetable/generator.jinja #: timetable/templates/timetable/generator.jinja
msgid "Generate" msgid "Generate"
msgstr "Générer" msgstr "Générer"
+9 -3
View File
@@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-08 23:36+0200\n" "POT-Creation-Date: 2026-05-17 10:03+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -22,8 +22,8 @@ msgid ""
"You're going to remove your own role from the presidency. You may lock " "You're going to remove your own role from the presidency. You may lock "
"yourself out of this page. Do you want to continue ? " "yourself out of this page. Do you want to continue ? "
msgstr "" msgstr ""
"Vous vous apprêtez à retirer votre propre rôle de la présidence. Vous " "Vous vous apprêtez à retirer votre propre rôle de la présidence. Vous risquez "
"risquez de perdre l'accès à cette page. Voulez-vous continuer ?" "de perdre l'accès à cette page. Voulez-vous continuer ?"
#: com/static/bundled/com/components/ics-calendar-index.ts #: com/static/bundled/com/components/ics-calendar-index.ts
msgid "More info" msgid "More info"
@@ -279,3 +279,9 @@ msgstr "Il n'a pas été possible de modérer l'image"
msgid "Couldn't delete picture" msgid "Couldn't delete picture"
msgstr "Il n'a pas été possible de supprimer l'image" msgstr "Il n'a pas été possible de supprimer l'image"
#: timetable/static/bundled/timetable/generator-index.ts
msgid ""
"Wrong timetable format. Make sure you copied if from your student folder."
msgstr ""
"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis "
"votre dossier étudiant."
-1
View File
@@ -71,7 +71,6 @@ nav:
- API: - API:
- Développement: tutorial/api/dev.md - Développement: tutorial/api/dev.md
- Connexion à l'API: tutorial/api/connect.md - Connexion à l'API: tutorial/api/connect.md
- Markdown AE: tutorial/markdown.md
- Etransactions: tutorial/etransaction.md - Etransactions: tutorial/etransaction.md
- How-to: - How-to:
- L'ORM de Django: howto/querysets.md - L'ORM de Django: howto/querysets.md
+5 -17
View File
@@ -9,7 +9,6 @@
"version": "3", "version": "3",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@ae_utbm/aemark": "^0.1.3",
"@alpinejs/sort": "^3.16.2", "@alpinejs/sort": "^3.16.2",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.8.0", "@floating-ui/dom": "^1.8.0",
@@ -31,7 +30,7 @@
"easymde": "^2.21.0", "easymde": "^2.21.0",
"glob": "^13.0.6", "glob": "^13.0.6",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"htmx.org": "^4.0.0", "htmx.org": "^2.0.10",
"js-cookie": "^3.0.8", "js-cookie": "^3.0.8",
"lit-html": "^3.3.3", "lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
@@ -57,11 +56,6 @@
"vite": "^8.2.2" "vite": "^8.2.2"
} }
}, },
"node_modules/@ae_utbm/aemark": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@ae_utbm/aemark/-/aemark-0.1.3.tgz",
"integrity": "sha512-JHb9XDgYxy9J0f7bUWtS7eVptKbeB/4+VQoHO3g1ASwNHZpIpW+RRdJPXA+oi2/RDrdPbEtCMkkVROc+8ifFYQ=="
},
"node_modules/@alpinejs/sort": { "node_modules/@alpinejs/sort": {
"version": "3.16.2", "version": "3.16.2",
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.16.2.tgz", "resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.16.2.tgz",
@@ -3807,16 +3801,10 @@
} }
}, },
"node_modules/htmx.org": { "node_modules/htmx.org": {
"version": "4.0.0", "version": "2.0.10",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-4.0.0.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz",
"integrity": "sha512-T/171FUY93Kdfp8t+DnHdk45QvKRiBhVhhrwSzrXgUi4pHKvhp77dUA/qg8FAjsFWPIHNbmUuIdCrcVHuiZWng==", "integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==",
"license": "BSD-0-Clause", "license": "0BSD"
"workspaces": [
"ext/*"
],
"bin": {
"upgrade-check": "dist/scripts/upgrade-check.js"
}
}, },
"node_modules/ical.js": { "node_modules/ical.js": {
"version": "1.5.0", "version": "1.5.0",
+2 -3
View File
@@ -34,13 +34,12 @@
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"@typescript/native": "npm:typescript@^7.0.2",
"rollup-plugin-visualizer": "^7.1.1", "rollup-plugin-visualizer": "^7.1.1",
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2",
"vite": "^8.2.2" "vite": "^8.2.2"
}, },
"dependencies": { "dependencies": {
"@ae_utbm/aemark": "^0.1.3",
"@alpinejs/sort": "^3.16.2", "@alpinejs/sort": "^3.16.2",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.8.0", "@floating-ui/dom": "^1.8.0",
@@ -62,7 +61,7 @@
"easymde": "^2.21.0", "easymde": "^2.21.0",
"glob": "^13.0.6", "glob": "^13.0.6",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"htmx.org": "^4.0.0", "htmx.org": "^2.0.10",
"js-cookie": "^3.0.8", "js-cookie": "^3.0.8",
"lit-html": "^3.3.3", "lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
+5 -2
View File
@@ -29,11 +29,14 @@ class UeController(ControllerBase):
response=UeSchema, response=UeSchema,
) )
def fetch_from_utbm_api( def fetch_from_utbm_api(
self, code: str, year: Query[Annotated[int, Ge(2010)] | None] = None self,
code: str,
lang: Query[str] = "fr",
year: Query[Annotated[int, Ge(2010)] | None] = None,
): ):
"""Fetch UE data from the UTBM API and returns it after some parsing.""" """Fetch UE data from the UTBM API and returns it after some parsing."""
with UtbmApiClient() as client: with UtbmApiClient() as client:
res = client.find_ue(code, year) res = client.find_ue(lang, code, year)
if res is None: if res is None:
raise NotFound raise NotFound
return res return res
+4 -13
View File
@@ -26,7 +26,6 @@ from django.conf import settings
from django.core import validators from django.core import validators
from django.db import models from django.db import models
from django.db.models import Exists, OuterRef from django.db.models import Exists, OuterRef
from django.db.models.query_utils import Q
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -92,6 +91,7 @@ class UE(models.Model):
# All texts about the UE # All texts about the UE
title = models.CharField(_("title"), max_length=300) title = models.CharField(_("title"), max_length=300)
manager = models.CharField(_("ue manager"), max_length=300)
objectives = models.TextField(_("objectives")) objectives = models.TextField(_("objectives"))
program = models.TextField(_("program")) program = models.TextField(_("program"))
skills = models.TextField(_("skills")) skills = models.TextField(_("skills"))
@@ -124,10 +124,8 @@ class UE(models.Model):
This function checks that no other comment has been posted by a specified user. This function checks that no other comment has been posted by a specified user.
Returns: Returns:
True if the user has already posted a comment on this UE or is anonymous, else False. True if the user has already posted a comment on this UE, else False.
""" """
if user.is_anonymous:
return True
return self.comments.filter(author=user).exists() return self.comments.filter(author=user).exists()
@cached_property @cached_property
@@ -158,14 +156,7 @@ class UECommentQuerySet(models.QuerySet):
# so he can view non-moderated comments # so he can view non-moderated comments
return self return self
if user.has_perm("pedagogy.view_uecomment"): if user.has_perm("pedagogy.view_uecomment"):
return self.filter( return self.filter(reports=None)
Q(author=user)
| ~Exists(
UECommentReport.objects.filter(
~Q(reporter=user), comment=OuterRef("pk")
)
)
).distinct()
return self.filter(author=user) return self.filter(author=user)
def annotate_is_reported(self) -> Self: def annotate_is_reported(self) -> Self:
@@ -267,7 +258,7 @@ class UEResult(models.Model):
class UECommentReport(models.Model): class UECommentReport(models.Model):
"""Report an inappropriate comment.""" """Report an inapropriate comment."""
comment = models.ForeignKey( comment = models.ForeignKey(
UEComment, UEComment,
-9
View File
@@ -153,7 +153,6 @@ class UeFilterSchema(FilterSchema):
set[Literal["CS", "TM", "EC", "OM", "QC"]] | None, set[Literal["CS", "TM", "EC", "OM", "QC"]] | None,
FilterLookup("credit_type__in"), FilterLookup("credit_type__in"),
] = None ] = None
is_open: bool | None = None
language: str = "FR" language: str = "FR"
department: Annotated[set[str] | None, FilterLookup("department__in")] = None department: Annotated[set[str] | None, FilterLookup("department__in")] = None
@@ -188,11 +187,3 @@ class UeFilterSchema(FilterSchema):
return Q() return Q()
value.add("AUTUMN_AND_SPRING") value.add("AUTUMN_AND_SPRING")
return Q(semester__in=value) return Q(semester__in=value)
def filter_is_open(self, value: bool | None) -> Q: # noqa: FBT001
"""Filter by open/closed status."""
if value is None:
return Q()
if not value:
return Q(semester="CLOSED")
return ~Q(semester="CLOSED")
@@ -0,0 +1,118 @@
import {
getCurrentUrlParams,
History,
updateQueryString,
} from "#core:utils/history.ts";
import { ueFetchUeList } from "#openapi";
const pageDefault = 1;
const pageSizeDefault = 100;
document.addEventListener("alpine:init", () => {
Alpine.data("ue_search", () => ({
ues: {
count: 0,
next: null,
previous: null,
results: [],
},
loading: false,
page: pageDefault,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size: pageSizeDefault,
search: "",
department: [],
// biome-ignore lint/style/useNamingConvention: api is in snake_case
credit_type: [],
semester: [],
// biome-ignore lint/style/useNamingConvention: api is in snake_case
to_change: [],
pushstate: History.Push,
update: undefined,
initializeArgs() {
const url = getCurrentUrlParams();
this.pushstate = History.Replace;
this.page = Number.parseInt(url.get("page"), 10) || pageDefault;
this.page_size = Number.parseInt(url.get("page_size"), 10) || pageSizeDefault;
this.search = url.get("search") || "";
this.department = url.getAll("department");
this.credit_type = url.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 */
this.semester = url.has("semester") ? url.get("semester").split("_AND_") : [];
this.update();
},
async init() {
this.update = Alpine.debounce(async () => {
/* Create the whole url before changing everything all at once */
const first = this.to_change.shift();
let url = updateQueryString(first.param, first.value, History.None);
for (const value of this.to_change) {
url = updateQueryString(value.param, value.value, History.None, url);
}
updateQueryString(first.param, first.value, this.pushstate, url);
await this.fetchData(); /* reload data on form change */
this.to_change = [];
this.pushstate = History.Push;
}, 50);
const searchParams = ["search", "department", "credit_type", "semester"];
const paginationParams = ["page", "page_size"];
for (const param of searchParams) {
this.$watch(param, () => {
if (this.pushstate !== History.Push) {
/* This means that we are doing a mass param edit */
return;
}
/* Reset pagination on search */
this.page = pageDefault;
this.page_size = pageSizeDefault;
});
}
for (const param of searchParams.concat(paginationParams)) {
this.$watch(param, (value) => {
this.to_change.push({ param: param, value: value });
this.update();
});
}
window.addEventListener("popstate", () => {
this.initializeArgs();
});
this.initializeArgs();
},
async fetchData() {
this.loading = true;
const args = {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size: this.page_size,
};
for (const [param, value] of new URL(
window.location.href,
).searchParams.entries()) {
// Deal with array type params
if (["credit_type", "department", "semester"].includes(param)) {
if (args[param] === undefined) {
args[param] = [];
}
args[param].push(value);
} else {
args[param] = value;
}
}
this.ues = (await ueFetchUeList({ query: args })).data;
this.loading = false;
},
maxPage() {
return Math.ceil(this.ues.count / this.page_size);
},
}));
});
@@ -1,120 +0,0 @@
import { getCurrentUrlParams, updateQueryString } from "#core:utils/history";
import { type SimpleUeSchema, ueFetchUeList } from "#openapi";
type CreditType = "CS" | "TM" | "EC" | "OM" | "QC";
type Semester = "AUTUMN" | "SPRING";
const pageDefault = 1;
const pageSizeDefault = 100;
document.addEventListener("alpine:init", () => {
Alpine.data("ue_search", () => ({
ues: {
count: 0,
next: null as string | null,
previous: null as string | null,
results: [] as SimpleUeSchema[],
},
loading: false,
page: pageDefault,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size: pageSizeDefault,
search: "",
hideClosedUes: true,
department: [] as string[],
// biome-ignore lint/style/useNamingConvention: api is in snake_case
credit_type: [] as CreditType[],
semester: [] as Semester[],
// biome-ignore lint/style/useNamingConvention: api is in snake_case
to_change: [] as { param: string; value: string }[],
// dummy implementation to make TS happy.
// The real function is initialized in init
update: () => {
console.warn("Update not yet initialized");
},
initializeArgs() {
const url = getCurrentUrlParams();
this.page = Number.parseInt(url.get("page") || pageDefault.toString(), 10);
this.page_size = Number.parseInt(
url.get("page_size") || pageSizeDefault.toString(),
10,
);
this.search = url.get("search") || "";
this.hideClosedUes = [null, "true"].includes(url.get("hideClosed"));
this.department = url.getAll("department");
this.credit_type = url.getAll("credit_type") as CreditType[];
/* 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 */
this.semester = (url.get("semester")?.split("_AND_") || []) as Semester[];
this.update();
},
async init() {
this.update = Alpine.debounce(async () => {
/* Create the whole url before changing everything all at once */
for (const val of this.to_change) {
updateQueryString(val.param, val.value);
}
await this.fetchData(); /* reload data on form change */
this.to_change = [];
}, 50);
const searchParams = [
"search",
"hideClosedUes",
"department",
"credit_type",
"semester",
];
const paginationParams = ["page", "page_size"];
for (const param of searchParams) {
this.$watch(param, () => {
/* Reset pagination on search */
this.page = pageDefault;
this.page_size = pageSizeDefault;
});
}
for (const param of searchParams.concat(paginationParams)) {
this.$watch(param, (value: string) => {
this.to_change.push({ param: param, value: value });
this.update();
});
}
this.initializeArgs();
window.addEventListener("popstate", () => {
this.initializeArgs();
});
},
async fetchData() {
this.loading = true;
const res = await ueFetchUeList({
query: {
page: this.page,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size: this.page_size,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
credit_type: this.credit_type.length > 0 ? this.credit_type : undefined,
semester: this.semester.length > 0 ? this.semester : undefined,
// biome-ignore lint/style/useNamingConvention: api is snake_case
is_open: this.hideClosedUes ? true : undefined,
department: this.department.length > 0 ? this.department : undefined,
search: this.search || undefined,
},
});
if (res.data !== undefined) {
this.ues = res.data;
}
this.loading = false;
},
maxPage() {
return Math.ceil(this.ues.count / this.page_size);
},
}));
});
+431 -123
View File
@@ -1,22 +1,52 @@
@import "core/static/core/devices"; @import "core/static/core/devices";
@import "core/static/core/colors"; @import "core/static/core/colors";
$pedagogy-blue: #1bb9ea;
$pedagogy-orange: #ea7900; $pedagogy-orange: #ea7900;
$pedagogy-hover-blue: #0e97ce;
$pedagogy-light-blue: #caf0ff;
$pedagogy-white-text: #f0f0f0; $pedagogy-white-text: #f0f0f0;
.pedagogy { .pedagogy {
.star-not-checked { &.star-not-checked {
color: lightgray; color: #f7f7f7;
margin-bottom: 0; margin-bottom: 0;
margin-top: 0; margin-top: 0;
} }
.star-checked { &.star-checked {
color: $pedagogy-orange; color: $pedagogy-orange;
margin-bottom: 0; margin-bottom: 0;
margin-top: 0; margin-top: 0;
} }
&.grade-without-star {
display: none;
}
@media screen and (max-width: $large-devices) {
&.star-not-checked {
margin-left: 5px;
margin-right: 5px;
}
&.star-checked {
margin-left: 5px;
margin-right: 5px;
}
}
@media screen and (max-width: $small-devices) {
&.grade-without-star {
display: block;
}
&.grade-with-star {
display: none;
}
}
#ue-list { #ue-list {
font-size: 1.1em; font-size: 1.1em;
@@ -34,98 +64,177 @@ $pedagogy-white-text: #f0f0f0;
} }
#search_form { #search_form {
.radio-guide fieldset { .search-form-container {
input[type="checkbox"] { display: grid;
display: none; grid-template-columns: auto auto;
grid-template-rows: auto auto auto;
grid-template-areas:
"action-bar action-bar"
"search-bar search-bar"
"radio-department radio-department"
"radio-credit-type radio-semester";
}
.action-bar {
grid-area: action-bar;
margin-bottom: 10px;
}
.search-bar {
grid-area: search-bar;
display: grid;
grid-template-columns: auto 200px;
grid-template-rows: auto;
grid-template-areas: "search-bar-input search-bar-button";
@media screen and (max-width: $medium-devices) {
grid-template-columns: auto auto;
grid-template-rows: auto;
grid-template-areas: "search-bar-input search-bar-button";
} }
@media screen and (max-width: $small-devices) {
grid-template-columns: auto;
grid-template-rows: auto;
grid-template-areas: "search-bar-input";
.search-bar-button {
display: none;
}
}
.search-bar-input {
grid-area: search-bar-input;
background: $pedagogy-light-blue;
}
.search-bar-button {
grid-area: search-bar-button;
background: $pedagogy-orange;
color: white;
font-weight: bold;
margin-left: 20px;
}
}
.radio-department {
grid-area: radio-department;
}
.radio-credit-type {
grid-area: radio-credit-type;
}
.radio-semester {
grid-area: radio-semester;
}
.radio-guide input[type="radio"],
input[type="checkbox"] {
display: none;
}
.radio-guide {
margin-top: 10px; margin-top: 10px;
margin-bottom: 0;
color: white; color: white;
}
label { .radio-guide label {
padding: 10px 20px; display: inline-block;
} background-color: $pedagogy-blue;
padding: 10px 20px;
font-family: Arial, sans-serif;
font-size: 16px;
border-radius: 4px;
}
input[type="checkbox"]:checked+label { .radio-guide input[type="radio"]:checked+label {
background-color: $pedagogy-orange; background-color: $pedagogy-orange;
@include shadow; }
}
.radio-guide input[type="checkbox"]:checked+label {
background-color: $pedagogy-orange;
}
.radio-guide label:hover {
background-color: $pedagogy-hover-blue;
} }
} }
&#ue_detail { #ue_detail {
.ue-infos { color: #062f38;
display: flex;
gap: 2em; .ue-quick-info-container {
flex-direction: row;
@media screen and (max-width: 700px) {
flex-direction: column;
}
}
.infos-column {
display: flex;
flex-direction: column;
gap: 1em;
min-width: 300px;
}
.info-container {
// Darken the background color just enough to make this part
// stand out a little, while not being too perceptible
background: darken($white-color, 1%);
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 20% 20% 20% 20% auto;
border-radius: 5px; grid-template-rows: auto auto;
padding: .75rem 1rem ; grid-template-areas:
grid-gap: .5rem; "hours-cm hours-td hours-tp hours-te hours-the"
@include shadow; "department credit-type semester . .";
}
.info-container-item { .department {
display: grid; grid-area: department;
grid-column: span 2; }
grid-template-rows: subgrid;
grid-template-columns: subgrid;
grid-column-gap: 1rem;
div { .credit-type {
display: flex; grid-area: credit-type;
align-items: center; }
}
:first-child { .semester {
justify-content: right; grid-area: semester;
text-align: right; }
}
}
hr { .hours-cm {
grid-column: span 2; grid-area: hours-cm;
width: 50%; }
margin: auto;
.hours-td {
grid-area: hours-td;
}
.hours-tp {
grid-area: hours-tp;
}
.hours-te {
grid-area: hours-te;
}
.hours-the {
grid-area: hours-the;
}
#leave_comment_not_allowed {
p {
text-align: center;
color: red;
} }
} }
.leave-comment { #leave_comment {
.comment-form-content { .leave-comment-grid-container {
display: flex; display: grid;
gap: 2em; grid-template-columns: 270px auto;
grid-template-rows: 100%;
grid-template-areas: "stars comment";
@media screen and (max-width: $large-devices) { @media screen and (max-width: $large-devices) {
gap: 1em; grid-template-columns: 100%;
flex-direction: column; grid-template-rows: auto auto;
grid-template-areas:
"stars"
"comment";
} }
} }
.form-stars { .form-stars {
flex: 0; grid-area: stars;
min-width: 250px;
display: flex;
align-content: flex-start;
justify-content: center;
flex-wrap: wrap;
gap: 1rem 2em;
height: unset;
} }
.form-comment { .form-comment {
flex: 2; grid-area: comment;
} }
.ui-accordion-header-icon { .ui-accordion-header-icon {
@@ -133,83 +242,282 @@ $pedagogy-white-text: #f0f0f0;
margin-right: 10px; margin-right: 10px;
} }
input[type="submit"] { .input-stars {
display: block; margin-top: 20px;
margin-left: auto;
} }
summary { input[type="submit"] {
background: $pedagogy-orange; float: right;
color: $pedagogy-white-text;
@media screen and (min-width: $large-devices) {
clip-path: polygon(0 0%, 0 100%, 30% 100%, 33% 0);
}
} }
} }
.ue-details-container {
display: grid;
grid-template-columns: 150px 130px auto;
grid-template-rows: 156px 1fr;
grid-template-areas:
"grade grade-stars ue-infos"
". . ue-infos";
@media screen and (max-width: $large-devices) {
grid-template-columns: 50% 50%;
grid-template-rows: auto auto;
grid-template-areas:
"grade grade-stars"
"ue-infos ue-infos";
}
}
.grade {
grid-area: grade;
color: $pedagogy-white-text;
background-color: $pedagogy-blue;
padding-right: 10px;
>p {
text-align: right;
font-weight: bold;
}
}
.grade-stars {
grid-area: grade-stars;
color: $pedagogy-white-text;
background-color: $pedagogy-blue;
font-weight: bold;
}
.ue-infos { .ue-infos {
grid-area: ue-infos; grid-area: ue-infos;
padding-left: 10px; padding-left: 10px;
} }
.comment { .comment-container {
padding: 15px; display: grid;
margin: 20px; grid-template-columns: 300px auto;
display: flex; grid-template-rows: auto auto auto;
flex-direction: column; grid-template-areas:
gap: 10px; "grade-block comment"
background: $primary-neutral-light-color; "grade-block info"
border-radius: 5px; "comment-end-bar comment-end-bar";
border: 1px darken($secondary-neutral-light-color, 10%) solid; margin-bottom: 30px;
margin-top: 10px;
&.reported { @media screen and (max-width: $large-devices) {
border: #fc8181 1px solid; grid-template-columns: auto;
grid-template-rows: auto auto auto auto;
grid-template-areas:
"grade-block"
"comment"
"info"
"comment-end-bar";
} }
.comment-header { .grade-block {
display: flex; grid-area: grade-block;
gap: 20px; width: 300px;
img { display: grid;
width: 50px; grid-template-columns: 150px 150px;
height: 50px; grid-template-rows: 156px auto;
border-radius: 50%; grid-template-areas:
"grade-type grade-stars"
"grade-extension grade-extension";
grid-gap: 15px;
clip-path: polygon(0 0, 0 100%, 100% 100%, 100% 30px, 270px 0);
align-items: start;
background-color: $pedagogy-blue;
@media screen and (max-width: $large-devices) {
grid-template-columns: 50% auto;
grid-template-rows: auto;
grid-template-areas: "grade-type grade-stars";
width: auto;
clip-path: none;
align-content: space-evenly;
align-items: end;
} }
.comment-metadata { .grade-extension {
display: flex; grid-area: grade-extension;
flex-direction: column; background-color: $pedagogy-blue;
justify-content: center;
} }
.comment-options { .grade-type {
flex: 2; grid-area: grade-type;
display: flex;
justify-content: right; >p {
gap: 15px; color: $pedagogy-white-text;
font-weight: bold;
text-align: right;
}
}
.grade-stars {
grid-area: grade-stars;
} }
} }
.comment-stars { .comment {
display: flex; grid-area: comment;
justify-content: space-evenly;
flex-wrap: wrap; display: grid;
gap: 1em 2em; grid-template-columns: auto;
padding: 1.5em; grid-template-rows: auto auto;
border-top: 0.0625rem grey dotted; grid-template-areas:
border-bottom: 0.0625rem grey dotted; "anchor"
"markdown";
@media screen and (max-width: $large-devices) {
border-left: solid;
border-right: solid;
border-color: $pedagogy-blue;
}
.anchor {
grid-area: anchor;
text-align: right;
margin-right: 15px;
}
.markdown {
grid-area: markdown;
min-height: 139px;
margin-top: 0;
margin-right: 0;
padding: 10px;
text-align: justify;
overflow: auto;
}
} }
.comment-content { .info {
text-align: justify; grid-area: info;
padding: .5em 20px 1em; padding-bottom: 10px;
@media screen and (max-width: $large-devices) {
border-left: solid;
border-right: solid;
border-color: $pedagogy-blue;
}
.status-reported {
color: red;
float: left;
padding-left: 10px;
}
.actions {
float: right;
}
}
.comment-end-bar {
grid-area: comment-end-bar;
display: grid;
grid-template-columns: 33% auto auto;
grid-template-rows: 2.5em;
grid-template-areas: "author date report";
background-color: $pedagogy-blue;
margin-top: -1px;
@media screen and (max-width: $large-devices) {
grid-template-columns: auto;
grid-template-rows: auto auto auto;
grid-template-areas:
"report"
"date"
"author";
margin-top: 0;
text-align: center;
}
.author {
grid-area: author;
padding-top: 6px;
padding-left: 20px;
background-color: $pedagogy-orange;
clip-path: polygon(0 10px, 0 100%, 350px 200%, 300px 10px);
@media screen and (max-width: $large-devices) {
clip-path: none;
padding: 0;
padding-bottom: 7px;
}
a {
color: $pedagogy-white-text;
font-weight: bold;
}
a:hover {
color: $pedagogy-hover-blue;
}
}
.date {
grid-area: date;
color: $pedagogy-white-text;
@media screen and (max-width: $large-devices) {
padding-bottom: 7px;
}
}
.report {
grid-area: report;
justify-self: right;
padding-right: 30px;
padding-left: 30px;
a {
color: $pedagogy-white-text;
}
a:hover {
color: $pedagogy-hover-blue;
}
@media screen and (max-width: $large-devices) {
text-align: center;
justify-self: inherit;
padding-bottom: 7px;
background-color: $white-color;
border-left: solid;
border-right: solid;
border-color: $pedagogy-blue;
a {
color: $black-color;
}
}
}
} }
} }
} }
} }
.buttons { details.accordion summary {
display: flex;
justify-content: flex-end; background: $pedagogy-orange !important;
align-items: center; color: $pedagogy-white-text !important;
clip-path: polygon(0 0%, 0 100%, 30% 100%, 33% 0);
@media screen and (max-width: $large-devices) {
clip-path: none;
}
}
details.accordion>.accordion-content {
background-color: $white-color;
border-color: $pedagogy-orange;
border-right: none;
} }
@@ -1,31 +0,0 @@
<form
class="report-form"
hx-post="{{ request.get_full_path() }}"
hx-target="this"
hx-swap="outerHTML"
hx-disable="button input[type='submit']"
hx-trigger="submit"
>
{% csrf_token %}
{{ form.non_field_errors() }}
{{ form.reason.errors }}
{{ form.reason }}
{# Hidden fields #}
{{ form.reporter }}
{{ form.comment }}
<div class="buttons row gap">
<button
class="btn btn-grey"
hx-get="{{ ue_detail_url }}"
hx-target="closest form"
hx-swap="outerHTML"
>
{% trans %}Cancel{% endtrans %}
</button>
<input class="btn btn-blue" type="submit" value="{% trans %}Report{% endtrans %}" />
</div>
</form>
@@ -1,64 +0,0 @@
<div class="leave-comment">
{% if form.is_creation %}
<details class="accordion" id="leave_comment" {% if form.errors %}open{% endif %}>
<summary>{% trans %}Leave comment{% endtrans %}</summary>
<div class="accordion-content">
{% endif %}
<form
class="accordion-content"
action="{{ url('pedagogy:ue_detail', ue_id=object.id) }}"
method="post"
enctype="multipart/form-data"
>
{% csrf_token %}
{{ form.non_field_errors() }}
{{ form.author.errors }}
{{ form.ue.errors }}
{{ form.author }}
{{ form.ue }}
<div class="comment-form-content">
<div class="form-stars">
<fieldset>
<legend>{{ form.grade_global.label }} :</legend>
{{ form.grade_global.errors }}
{{ form.grade_global }}
</fieldset>
<fieldset>
<legend>{{ form.grade_utility.label }} :</legend>
{{ form.grade_utility.errors }}
{{ form.grade_utility }}
</fieldset>
<fieldset>
<legend>{{ form.grade_interest.label }} :</legend>
{{ form.grade_interest.errors }}
{{ form.grade_interest }}
</fieldset>
<fieldset>
<legend>{{ form.grade_teaching.label }} :</legend>
{{ form.grade_teaching.errors }}
{{ form.grade_teaching }}
</fieldset>
<fieldset>
<legend>{{ form.grade_work_load.label }} :</legend>
{{ form.grade_work_load.errors }}
{{ form.grade_work_load }}
</fieldset>
</div>
<div class="form-comment">
<label for="{{ form.comment.id_for_label }}">{{ form.comment.label }} :</label>
{{ form.comment.errors }}
{{ form.comment }}
</div>
</div>
<p><input type="submit" class="btn btn-blue"/></p>
</form>
{% if form.is_creation %}
</div>
</details>
{% endif %}
<br>
</div>
@@ -1,129 +0,0 @@
{% from "pedagogy/macros.jinja" import display_star %}
{% from "core/macros.jinja" import user_profile_link %}
{% if comments %}
<h2>{% trans %}Comments{% endtrans %}</h2>
<br>
{% endif %}
<section>
{%- for comment in comments -%}
<article
id="comment-{{ comment.id }}"
class="comment {% if comment.is_reported %}reported{% endif %}"
>
{% if comment.is_reported %}
<div class="alert alert-red">
<div class="alert-main">
<h5 class="alert-title margin-bottom">
{% trans %}This comment has been reported{% endtrans %}
</h5>
<p>{% trans %}It will be hidden until moderated.{% endtrans %}</p>
{% if user.has_perm("pedagogy.view_uecommentreport") %}
{% for report in comment.reports.all() %}
<div class="markdown margin-bottom">
<blockquote>
<em>{{ report.reporter.get_display_name() }}</em>
{{ report.reason|markdown }}
</blockquote>
</div>
{% endfor %}
{% endif %}
</div>
{% if user.has_perm("pedagogy.delete_uecomment") %}
<div class="alert-aside">
<a
href="{{ url("pedagogy:comment_delete", comment_id=comment.id) }}"
class="btn btn-red"
>
<i class="fa fa-trash"></i>{% trans %}Delete{% endtrans %}
</a>
<a href="{{ url("pedagogy:moderation") }}" class="btn btn-green">
<i class="fa fa-check"></i>{% trans %}Moderate{% endtrans %}
</a>
</div>
{% endif %}
</div>
{% endif %}
<div class="comment-header">
<img
{% if comment.author.avatar_pict %}
src="{{ comment.author.avatar_pict.get_download_url() }}"
{% elif comment.author.profile_pict %}
src="{{ comment.author.profile_pict.get_download_url() }}"
{% else %}
src="{{ static("core/img/unknown.jpg") }}"
{% endif %}
alt="{% trans %}Profile{% endtrans %}"
/>
<div class="comment-metadata">
<a href="{{ comment.author.get_absolute_url() }}">
<strong>{{ comment.author.get_display_name() }}</strong>
</a>
<time datetime="{{ comment.publish_date.isoformat(timespec="seconds") }}">
{{ comment.publish_date|localtime|date(DATETIME_FORMAT) }}
{{ comment.publish_date|localtime|time(DATETIME_FORMAT) }}
</time>
</div>
<div class="comment-options">
{% if user.can_edit(comment) %}
<a
class="clickable"
hx-get="{{ url('pedagogy:comment_update', comment_id=comment.id) }}"
hx-swap="outerHTML"
hx-target="#comment-{{ comment.id }}"
tooltip="{% trans %}Edit{% endtrans %}"
>
<i class="fa fa-pencil edit-action"></i>
</a>
{% endif %}
{% if not comment.is_reported %}
<a
class="clickable"
hx-get="{{ url('pedagogy:comment_report', comment_id=comment.id) }}"
hx-swap="outerHTML"
hx-target="#comment-{{ comment.id }}"
tooltip="{% trans %}Report{% endtrans %}"
>
<i class="fa fa-exclamation delete-action"></i>
</a>
{% endif %}
{% if user.has_perm("pedagogy.delete_uecomment") %}
<a
href="{{ url("pedagogy:comment_delete", comment_id=comment.id) }}"
tooltip="{% trans %}Delete{% endtrans %}"
>
<i class="fa fa-trash-can delete-action"></i>
</a>
{% endif %}
</div>
</div>
<div class="comment-stars">
<div>
<span>{% trans %}Global grade{% endtrans %}</span>
<span>{{ display_star(comment.grade_global) }}</span>
</div>
<div>
<span>{% trans %}Utility{% endtrans %}</span>
<span>{{ display_star(comment.grade_utility) }}</span>
</div>
<div>
<span>{% trans %}Interest{% endtrans %}</span>
<span>{{ display_star(comment.grade_interest) }}</span>
</div>
<div>
<span>{% trans %}Teaching{% endtrans %}</span>
<span>{{ display_star(comment.grade_teaching) }}</span>
</div>
<div>
<span>{% trans %}Workload{% endtrans %}</span>
<span>{{ display_star(comment.grade_work_load) }}</span>
</div>
</div>
<div class="comment-content">
{{ comment.comment|markdown }}
</div>
</article>
{%- endfor -%}
</section>
@@ -1,20 +0,0 @@
<div class="info-container-item">
<span>{% trans %}Global grade{% endtrans %}</span>
<span>{{ display_star(object.grade_global_average) }}</span>
</div>
<div class="info-container-item">
<span>{% trans %}Utility{% endtrans %}</span>
<span>{{ display_star(object.grade_utility_average) }}</span>
</div>
<div class="info-container-item">
<span>{% trans %}Interest{% endtrans %}</span>
<span>{{ display_star(object.grade_interest_average) }}</span>
</div>
<div class="info-container-item">
<span>{% trans %}Teaching{% endtrans %}</span>
<span>{{ display_star(object.grade_teaching_average) }}</span>
</div>
<div class="info-container-item">
<span>{% trans %}Workload{% endtrans %}</span>
<span>{{ display_star(object.grade_work_load_average) }}</span>
</div>
@@ -1,25 +0,0 @@
{# A few hx-partials for swapping a few parts of the main ue_detail.jinja #}
{% from "pedagogy/macros.jinja" import display_star %}
<hx-partial id="ue-grade">
{% include "pedagogy/fragments/ue_detail/grade.jinja" %}
</hx-partial>
<hx-partial id="comment-form">
{% if object.has_user_already_commented(user) %}
<em>
{% trans trimmed %}
You already posted a comment on this UE.
If you want to comment again, please modify or delete your previous comment.
{% endtrans %}
</em>
<br>
{% elif user.has_perm("pedagogy.add_uecomment") %}
{{ add_comment_form }}
{% endif %}
</hx-partial>
<hx-partial id="comments" hx-swap="innerMorph">
{% include "pedagogy/fragments/ue_detail/comments.jinja" %}
</hx-partial>
+29 -34
View File
@@ -14,7 +14,7 @@
{% endblock %} {% endblock %}
{% block additional_js %} {% block additional_js %}
<script type="module" src="{{ static('bundled/pedagogy/guide-index.ts') }}"></script> <script type="module" src="{{ static('bundled/pedagogy/guide-index.js') }}"></script>
{% endblock %} {% endblock %}
{% block head %} {% block head %}
@@ -24,32 +24,30 @@
{% block content %} {% block content %}
{% if user.has_perm("pedagogy.add_ue") %} {% if user.has_perm("pedagogy.add_ue") %}
<div class="row gap"> <div class="action-bar">
<a href="{{ url('pedagogy:ue_create') }}" class="btn btn-blue"><i class="fa fa-plus"></i>{% trans %}Create UE{% endtrans %}</a> <p>
<a href="{{ url('pedagogy:moderation') }}" class="btn btn-grey">{% trans %}Moderate comments{% endtrans %}</a> <a href="{{ url('pedagogy:ue_create') }}">{% trans %}Create UE{% endtrans %}</a>
</p>
<p>
<a href="{{ url('pedagogy:moderation') }}">{% trans %}Moderate comments{% endtrans %}</a>
</p>
</div> </div>
<br/> <br/>
{% endif %} {% endif %}
<div class="pedagogy" x-data="ue_search" x-cloak> <div class="pedagogy" x-data="ue_search" x-cloak>
<form id="search_form" class=""> <form id="search_form">
<div class="search-form-container"> <div class="search-form-container">
<fieldset> <div class="search-bar">
<label for="search_input">{% trans %}Search UE{% endtrans %}</label>
<input <input
id="search_input" id="search_input"
class="search-bar-input" class="search-bar-input"
type="search" type="text"
name="search" name="search"
placeholder=""
x-model.debounce.500ms="search" x-model.debounce.500ms="search"
/> />
</fieldset> </div>
<fieldset> <div class="radio-department">
<input type="checkbox" class="switch" x-model="hideClosedUes" id="hide-closed-ues" name="hide-closed-ues"> <div class="radio-guide">
<label for="hide-closed-ues">{% trans %}Hide closed UEs{% endtrans %}</label>
</fieldset>
<div class="row gap-3x margin-bottom radio-guide">
<fieldset>
{% set departments = [ {% set departments = [
("EDIM", "EDIM"), ("ENERGIE", "EE"), ("IMSI", "IMSI"), ("EDIM", "EDIM"), ("ENERGIE", "EE"), ("IMSI", "IMSI"),
("INFO", "GI"), ("GMC", "MC"), ("HUMA", "HUMA"), ("TC", "TC") ("INFO", "GI"), ("GMC", "MC"), ("HUMA", "HUMA"), ("TC", "TC")
@@ -58,16 +56,16 @@
<input <input
type="checkbox" type="checkbox"
name="department" name="department"
id="radio_{{ real_name }}" id="radio{{ real_name }}"
value="{{ real_name }}" value="{{ real_name }}"
x-model="department" x-model="department"
/> />
<label for="radio_{{ real_name }}" class="btn btn-blue"> <label for="radio{{ real_name }}">{% trans %}{{ display_name }}{% endtrans %}</label>
{% trans %}{{ display_name }}{% endtrans %}
</label>
{% endfor %} {% endfor %}
</fieldset> </div>
<fieldset> </div>
<div class="radio-credit-type">
<div class="radio-guide">
{% for credit_type in ["CS", "TM", "EC", "QC", "OM"] %} {% for credit_type in ["CS", "TM", "EC", "QC", "OM"] %}
<input <input
type="checkbox" type="checkbox"
@@ -76,21 +74,18 @@
value="{{ credit_type }}" value="{{ credit_type }}"
x-model="credit_type" x-model="credit_type"
/> />
<label for="radio{{ credit_type }}" class="btn btn-blue"> <label for="radio{{ credit_type }}">{% trans %}{{ credit_type }}{% endtrans %}</label>
{% trans %}{{ credit_type }}{% endtrans %}
</label>
{% endfor %} {% endfor %}
</fieldset> </div>
<fieldset> </div>
<div class="radio-semester">
<div class="radio-guide">
<input type="checkbox" name="semester" id="radioAUTUMN" value="AUTUMN" x-model="semester"/> <input type="checkbox" name="semester" id="radioAUTUMN" value="AUTUMN" x-model="semester"/>
<label for="radioAUTUMN" class="btn btn-no-text btn-blue"> <label for="radioAUTUMN"><i class="fa fa-leaf"></i></label>
<i class="fa fa-leaf"></i>
</label>
<input type="checkbox" name="semester" id="radioSPRING" value="SPRING" x-model="semester"/> <input type="checkbox" name="semester" id="radioSPRING" value="SPRING" x-model="semester"/>
<label for="radioSPRING" class="btn btn-no-text btn-blue"> <label for="radioSPRING"><i class="fa-regular fa-sun"></i></label>
<i class="fa-regular fa-sun"></i> </div>
</label>
</fieldset>
</div> </div>
</div> </div>
</form> </form>
+4 -1
View File
@@ -1,4 +1,5 @@
{% macro display_star(grade) -%} {% macro display_star(grade) -%}
{% if grade >= 0 %} {% if grade >= 0 %}
{% for i in range(5) %} {% for i in range(5) %}
{% if i <= grade %} {% if i <= grade %}
@@ -7,7 +8,9 @@
<span class="fa-solid fa-star pedagogy star-not-checked grade-with-star"></span> <span class="fa-solid fa-star pedagogy star-not-checked grade-with-star"></span>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<span class="pedagogy grade-without-star">{{ grade }}/5</span>
{% else %} {% else %}
<span class="grade-text"> {% trans %} not rated {% endtrans %} </span> <span class="grade-text"> {% trans %} not rated {% endtrans %} </span>
{% endif %} {% endif %}
{%- endmacro %}
{%- endmacro %}
+198 -74
View File
@@ -1,7 +1,7 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "core/macros.jinja" import user_profile_link %}
{% from "pedagogy/macros.jinja" import display_star %} {% from "pedagogy/macros.jinja" import display_star %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static('pedagogy/css/pedagogy.scss') }}"> <link rel="stylesheet" href="{{ static('pedagogy/css/pedagogy.scss') }}">
{% endblock %} {% endblock %}
@@ -11,86 +11,210 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h1>{{ object.code }}</h1> <div class="pedagogy">
<h2 class="margin-bottom">{{ object.title }}</h2> <div id="ue_detail">
<div class="pedagogy" id="ue_detail"> <button onclick='(function(){
<div class="ue-infos"> // If comes from the guide page, go back with history
<div class="infos-column"> if (document.referrer.replace(/\?(.+)/gm,"").endsWith(`{{ url("pedagogy:guide") }}`)){
<aside class="info-container"> window.history.back();
<div class="info-container-item"> return;
<div>{% trans %}Category{% endtrans %}</div> }
<div>{{ object.credit_type }}</div> // Simply goes to the guide page
</div> window.location.href = `{{ url("pedagogy:guide") }}`;
<div class="info-container-item"> })()' hidden>{% trans %}Back{% endtrans %}</button>
<div>{% trans %}Credits{% endtrans %}</div>
<div>{{ object.credits }}</div> <h1>{{ object.code }} - {{ object.title }}</h1>
</div> <br>
<hr> <div class="ue-quick-info-container">
<div class="info-container-item"> <div class="hours-cm">
<div>Cours magistraux</div> <b>{% trans %}CM: {% endtrans %}</b>{{ object.hours_CM }}
<div>{{ object.hours_CM }}h</div> </div>
</div> <div class="hours-td">
<div class="info-container-item"> <b>{% trans %}TD: {% endtrans %}</b>{{ object.hours_TD }}
<div>Travaux dirigés</div> </div>
<div>{{ object.hours_TD }}h</div> <div class="hours-tp">
</div> <b>{% trans %}TP: {% endtrans %}</b>{{ object.hours_TP }}
<div class="info-container-item"> </div>
<div>Travaux pratiques</div> <div class="hours-te">
<div>{{ object.hours_TP }}h</div> <b>{% trans %}TE: {% endtrans %}</b>{{ object.hours_TE }}
</div> </div>
<div class="info-container-item"> <div class="hours-the">
<div>Travail hors encadrement</div> <b>{% trans %}THE: {% endtrans %}</b>{{ object.hours_THE }}
<div>{{ object.hours_THE }}h</div> </div>
</div>
<div class="info-container-item"> <div class="department">
<div>Cours magistraux</div> {{ object.department }}
<div>{{ object.hours_TE }}h</div> </div>
</div> <div class="credit-type">
<hr> {{ object.credit_type }}
<div class="info-container-item"> </div>
<div>{% trans %}Availability{% endtrans %}</div> <div class="semester">
<div>{{ object.get_semester_display() }}</div> {{ object.get_semester_display() }}
</div> </div>
<hr>
<div class="info-container-item">
<div>{% trans %}Manager{% endtrans %}</div>
<div>{{ object.manager }}</div>
</div>
</aside>
<aside class="info-container" id="ue-grade">
{% include "pedagogy/fragments/ue_detail/grade.jinja" %}
</aside>
</div> </div>
<section class="ue-main-infos">
<h3>{% trans %}Objectives{% endtrans %}</h3>
<p>{{ object.objectives|markdown }}</p>
<h3>{% trans %}Program{% endtrans %}</h3>
<p>{{ object.program|markdown }}</p>
<h3>{% trans %}Earned skills{% endtrans %}</h3>
<p>{{ object.skills|markdown }}</p>
<h3>{% trans %}Key concepts{% endtrans %}</h3>
<p>{{ object.key_concepts|markdown }}</p>
</section>
</div>
<br> <br>
<div id="comment-form"> <div class="ue-details-container">
<div class="grade">
<p>{% trans %}Global grade{% endtrans %}</p>
<p>{% trans %}Utility{% endtrans %}</p>
<p>{% trans %}Interest{% endtrans %}</p>
<p>{% trans %}Teaching{% endtrans %}</p>
<p>{% trans %}Work load{% endtrans %}</p>
</div>
<div class="grade-stars">
<p>{{ display_star(object.grade_global_average) }}</p>
<p>{{ display_star(object.grade_utility_average) }}</p>
<p>{{ display_star(object.grade_interest_average) }}</p>
<p>{{ display_star(object.grade_teaching_average) }}</p>
<p>{{ display_star(object.grade_work_load_average) }}</p>
</div>
<div class="ue-infos">
<p><b>{% trans %}Objectives{% endtrans %}</b></p>
<p>{{ object.objectives|markdown }}</p>
<p><b>{% trans %}Program{% endtrans %}</b></p>
<p>{{ object.program|markdown }}</p>
<p><b>{% trans %}Earned skills{% endtrans %}</b></p>
<p>{{ object.skills|markdown }}</p>
<p><b>{% trans %}Key concepts{% endtrans %}</b></p>
<p>{{ object.key_concepts|markdown }}</p>
<p><b>{% trans %}UE manager: {% endtrans %}</b>{{ object.manager }}</p>
</div>
</div>
<br>
{% if object.has_user_already_commented(user) %} {% if object.has_user_already_commented(user) %}
<em> <div id="leave_comment_not_allowed">
{% trans trimmed %} <p>{% trans %}You already posted a comment on this UE. If you want to comment again, please modify or delete your previous comment.{% endtrans %}</p>
You already posted a comment on this UE. </div>
If you want to comment again, please modify or delete your previous comment.
{% endtrans %}
</em>
<br>
{% elif user.has_perm("pedagogy.add_uecomment") %} {% elif user.has_perm("pedagogy.add_uecomment") %}
{{ add_comment_form }} <details class="accordion" id="leave_comment" {% if form.errors %}open{%endif%}>
<summary>{% trans %}Leave comment{% endtrans %}</summary>
<div class="accordion-content">
<form action="{{ url('pedagogy:ue_detail', ue_id=object.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="leave-comment-grid-container">
<div class="form-stars">
{{ form.non_field_errors() }}
{{ form.author.errors }}
{{ form.ue.errors }}
{{ form.author }}
{{ form.ue }}
<div class="input-stars">
<label for="{{ form.grade_global.id_for_label }}">{{ form.grade_global.label }} :</label>
{{ form.grade_global.errors }}
{{ form.grade_global }}
</div>
<div class="input-stars">
<label for="{{ form.grade_utility.id_for_label }}">{{ form.grade_utility.label }} :</label>
{{ form.grade_utility.errors }}
{{ form.grade_utility }}
</div>
<div class="input-stars">
<label for="{{ form.grade_interest.id_for_label }}">{{ form.grade_interest.label }} :</label>
{{ form.grade_interest.errors }}
{{ form.grade_interest }}
</div>
<div class="input-stars">
<label for="{{ form.grade_teaching.id_for_label }}">{{ form.grade_teaching.label }} :</label>
{{ form.grade_teaching.errors }}
{{ form.grade_teaching }}
</div>
<div class="input-stars">
<label for="{{ form.grade_work_load.id_for_label }}">{{ form.grade_work_load.label }} :</label>
{{ form.grade_work_load.errors }}
{{ form.grade_work_load }}
</div>
</div>
<div class="form-comment">
<label for="{{ form.comment.id_for_label }}">{{ form.comment.label }} :</label>
{{ form.comment.errors }}
{{ form.comment }}
</div>
</div>
<p><input type="submit" value="{% trans %}Comment{% endtrans %}" /></p>
</form>
</div>
</details>
{% endif %} {% endif %}
</div> <br>
{% if comments %}
<h2>{% trans %}Comments{% endtrans %}</h2>
{% for comment in comments %}
<div id="{{ comment.id }}" class="comment-container">
<div class="grade-block">
<div class="grade-type">
<p>{% trans %}Global grade{% endtrans %}</p>
<p>{% trans %}Utility{% endtrans %}</p>
<p>{% trans %}Interest{% endtrans %}</p>
<p>{% trans %}Teaching{% endtrans %}</p>
<p>{% trans %}Work load{% endtrans %}</p>
</div>
<div class="grade-stars">
<p>{{ display_star(comment.grade_global) }}</p>
<p>{{ display_star(comment.grade_utility) }}</p>
<p>{{ display_star(comment.grade_interest) }}</p>
<p>{{ display_star(comment.grade_teaching) }}</p>
<p>{{ display_star(comment.grade_work_load) }}</p>
</div>
<div class="grade-extension"></div>
</div>
<div class="comment">
<div class="anchor">
<a href="{{ url('pedagogy:ue_detail', ue_id=ue.id) }}#{{ comment.id }}"><i class="fa fa-paragraph"></i></a>
</div>
{{ comment.comment|markdown }}
</div>
<div class="info">
{% if comment.is_reported %}
<p class="status-reported">
{% trans %}This comment has been reported{% endtrans %}
</p>
{% endif %}
{% if comment.author_id == user.id or user.has_perm("pedagogy.change_comment") %}
<p class="actions">
<a href="{{ url('pedagogy:comment_update', comment_id=comment.id) }}">
{% trans %}Edit{% endtrans %}
</a>
{% endif %}
{% if comment.author_id == user.id or user.has_perm("pedagogy.delete_comment") %}
<a href="{{ url('pedagogy:comment_delete', comment_id=comment.id) }}">
{% trans %}Delete{% endtrans %}
</a>
</p>
{% endif %}
</div>
<div class="comment-end-bar">
<div class="report">
<p>
<a href="{{ url('pedagogy:comment_report', comment_id=comment.id) }}">
{% trans %}Report this comment{% endtrans %}
</a>
</p>
</div>
<div class="date"><p>{{ comment.publish_date.strftime('%d/%m/%Y') }}</p></div>
<div class="author"><p>{{ user_profile_link(comment.author) }}</p></div>
</div>
</div>
{% endfor %}
{% endif %}
<div id="comments">
{% include "pedagogy/fragments/ue_detail/comments.jinja" %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+46 -42
View File
@@ -1,6 +1,8 @@
import json
from django.conf import settings from django.conf import settings
from django.core.management import call_command
from django.test import TestCase from django.test import TestCase
from django.test.testcases import call_command
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
@@ -15,7 +17,7 @@ class TestUESearch(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.root = baker.make(User, is_superuser=True) cls.root = User.objects.get(username="root")
cls.url = reverse("api:fetch_ues") cls.url = reverse("api:fetch_ues")
ue_recipe = Recipe(UE, author=cls.root) ue_recipe = Recipe(UE, author=cls.root)
ues = [ ues = [
@@ -90,10 +92,10 @@ class TestUESearch(TestCase):
def test_format(self): def test_format(self):
"""Test that the return data format is correct""" """Test that the return data format is correct"""
self.client.force_login(self.root) self.client.force_login(self.root)
res = self.client.get(self.url, query_params={"search": "PA00"}) res = self.client.get(self.url + "?search=PA00")
ue = UE.objects.get(code="PA00") ue = UE.objects.get(code="PA00")
assert res.status_code == 200 assert res.status_code == 200
assert res.json() == { assert json.loads(res.content) == {
"count": 1, "count": 1,
"next": None, "next": None,
"previous": None, "previous": None,
@@ -119,77 +121,79 @@ class TestUESearch(TestCase):
def test_search_by_text(self): def test_search_by_text(self):
self.client.force_login(self.root) self.client.force_login(self.root)
for query, expected in ( for query, expected in (
# UE code search case-insensitive # UE code search case insensitive
("m", {"MT01", "MT10"}), ("m", {"MT01", "MT10"}),
("M", {"MT01", "MT10"}), ("M", {"MT01", "MT10"}),
("mt", {"MT01", "MT10"}), ("mt", {"MT01", "MT10"}),
("MT", {"MT01", "MT10"}), ("MT", {"MT01", "MT10"}),
("algèbre", {"MT01"}), # Title search case-insensitive ("algèbre", {"MT01"}), # Title search case insensitive
# Manager search # Manager search
("moss", {"TNEV"}), ("moss", {"TNEV"}),
("francky", {"DA50", "AP4A"}), ("francky", {"DA50", "AP4A"}),
): ):
res = self.client.get(self.url, query_params={"search": query}) res = self.client.get(self.url + f"?search={query}")
assert res.status_code == 200 assert res.status_code == 200
assert {ue["code"] for ue in res.json()["results"]} == expected assert {ue["code"] for ue in json.loads(res.content)["results"]} == expected
def test_search_by_credit_type(self): def test_search_by_credit_type(self):
self.client.force_login(self.root) self.client.force_login(self.root)
res = self.client.get(self.url, query_params={"credit_type": "CS"}) res = self.client.get(self.url + "?credit_type=CS")
assert res.status_code == 200 assert res.status_code == 200
codes = [ue["code"] for ue in res.json()["results"]] codes = [ue["code"] for ue in json.loads(res.content)["results"]]
assert codes == ["AP4A", "MT01", "PHYS11"] assert codes == ["AP4A", "MT01", "PHYS11"]
res = self.client.get(self.url, query_params={"credit_type": ["CS", "OM"]}) res = self.client.get(self.url + "?credit_type=CS&credit_type=OM")
assert res.status_code == 200 assert res.status_code == 200
codes = {ue["code"] for ue in res.json()["results"]} codes = {ue["code"] for ue in json.loads(res.content)["results"]}
assert codes == {"AP4A", "MT01", "PHYS11", "PA00"} assert codes == {"AP4A", "MT01", "PHYS11", "PA00"}
def test_search_by_semester(self): def test_search_by_semester(self):
self.client.force_login(self.root) self.client.force_login(self.root)
res = self.client.get(self.url, query_params={"semester": "SPRING"}) res = self.client.get(self.url + "?semester=SPRING")
assert res.status_code == 200 assert res.status_code == 200
codes = {ue["code"] for ue in res.json()["results"]} codes = {ue["code"] for ue in json.loads(res.content)["results"]}
assert codes == {"DA50", "TNEV", "PA00"} assert codes == {"DA50", "TNEV", "PA00"}
def test_search_multiple_filters(self): def test_search_multiple_filters(self):
self.client.force_login(self.root) self.client.force_login(self.root)
res = self.client.get( res = self.client.get(
self.url, self.url + "?semester=AUTUMN&credit_type=CS&department=TC"
query_params={
"semester": "AUTUMN",
"credit_type": "CS",
"department": "TC",
},
) )
assert res.status_code == 200 assert res.status_code == 200
codes = {ue["code"] for ue in res.json()["results"]} codes = {ue["code"] for ue in json.loads(res.content)["results"]}
assert codes == {"MT01", "PHYS11"} assert codes == {"MT01", "PHYS11"}
def test_search_fails(self): def test_search_fails(self):
self.client.force_login(self.root) self.client.force_login(self.root)
res = self.client.get( res = self.client.get(self.url + "?credit_type=CS&search=DA")
self.url, query_params={"search": "DA", "credit_type": "CS"}
)
assert res.status_code == 200 assert res.status_code == 200
assert res.json()["results"] == [] assert json.loads(res.content)["results"] == []
def test_search_closed_status(self):
# Simple heuristic : take a UE, and mark everything before it as closed
closed_ue = UE.objects.order_by("id")[2]
UE.objects.filter(id__lte=closed_ue.id).update(semester="CLOSED")
def test_search_pa00_fail(self):
self.client.force_login(self.root) self.client.force_login(self.root)
# Search with UE code
response = self.client.get(reverse("pedagogy:guide"), {"search": "IFC"})
self.assertNotContains(response, text="PA00")
res = self.client.get(self.url, query_params={"is_open": False}) # Search with first letter of UE code
assert res.status_code == 200 response = self.client.get(reverse("pedagogy:guide"), {"search": "I"})
ids = {ue["id"] for ue in res.json()["results"]} self.assertNotContains(response, text="PA00")
assert ids == set(
UE.objects.filter(semester="CLOSED").values_list("id", flat=True)
)
res = self.client.get(self.url, query_params={"is_open": True}) # Search with UE manager
assert res.status_code == 200 response = self.client.get(reverse("pedagogy:guide"), {"search": "GILLES"})
ids = {ue["id"] for ue in res.json()["results"]} self.assertNotContains(response, text="PA00")
assert ids == set(
UE.objects.exclude(semester="CLOSED").values_list("id", flat=True) # 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")
+36 -168
View File
@@ -25,9 +25,9 @@ from typing import Callable
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.db.models import Max
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
@@ -278,17 +278,13 @@ class TestUEUpdate(TestCase):
# UEComment class tests # UEComment class tests
def create_ue_comment_template( def create_ue_comment_template(user_id, ue_code="PA00", exclude_list=None):
user_id: int,
ue: int | str = "PA00",
exclude_list: list[str] | None = None,
):
"""Factory to help UEComment creation/update in post requests.""" """Factory to help UEComment creation/update in post requests."""
if exclude_list is None: if exclude_list is None:
exclude_list = [] exclude_list = []
comment = { comment = {
"author": user_id, "author": user_id,
"ue": UE.objects.get(code=ue).id if isinstance(ue, str) else ue, "ue": UE.objects.get(code=ue_code).id,
"grade_global": 4, "grade_global": 4,
"grade_utility": 4, "grade_utility": 4,
"grade_interest": 4, "grade_interest": 4,
@@ -301,111 +297,12 @@ def create_ue_comment_template(
return comment return comment
class TestUVCommentDisplay(TestCase): class TestUVCommentCreationAndDisplay(TestCase):
@classmethod """Test UEComment creation and its display.
def setUpTestData(cls):
cls.admin = baker.make(User, is_superuser=True)
cls.ue = baker.make(UE)
cls.ue_url = reverse("pedagogy:ue_detail", kwargs={"ue_id": cls.ue.id})
cls.pedagogy_admin = baker.make(
User,
user_permissions=[
Permission.objects.get(codename="view_ue"),
Permission.objects.get(codename="view_uecomment"),
Permission.objects.get(codename="view_uecommentreport"),
],
)
cls.subscriber = subscriber_user.make()
comments = baker.make(UEComment, ue=cls.ue, _quantity=10)
cls.unreported, cls.reported = comments[:5], comments[5:]
baker.make(
UECommentReport,
comment=iter(cls.reported),
_quantity=len(cls.reported),
_bulk_create=True,
)
def test_access_succses(self): Display and creation are the same view.
self.client.force_login(self.admin) """
assert self.client.get(self.ue_url).status_code == 200
self.client.force_login(self.pedagogy_admin)
assert self.client.get(self.ue_url).status_code == 200
self.client.force_login(self.subscriber)
assert self.client.get(self.ue_url).status_code == 200
def test_access_fail(self):
# Anonymous user
assertRedirects(
self.client.get(self.ue_url),
reverse("core:login", query={"next": self.ue_url}),
)
# Unauthorized user
self.client.force_login(baker.make(User))
assert self.client.get(self.ue_url).status_code == 403
def test_access_not_found(self):
self.client.force_login(self.admin)
res = self.client.get(
reverse(
"pedagogy:ue_detail",
kwargs={"ue_id": UE.objects.aggregate(id=Max("id"))["id"] + 1},
)
)
assert res.status_code == 404
def test_comments_normal_user(self):
# Normal user only see
# * Unreported comments
# * Comments that he wrote but were reported
# * Comments that he himself reported
self.client.force_login(self.subscriber)
comments = self.client.get(self.ue_url).context_data.get("comments", [])
assert len(comments) == len(self.unreported)
assert set(comments) == set(self.unreported)
# Make user comment
user_comment = baker.make(UEComment, ue=self.ue, author=self.subscriber)
comments = self.client.get(self.ue_url).context_data.get("comments", [])
assert len(comments) == len(self.unreported) + 1
assert set(comments) == {*self.unreported, user_comment}
# Report user comment
baker.make(UECommentReport, comment=user_comment)
comments = self.client.get(self.ue_url).context_data.get("comments", [])
assert len(comments) == len(self.unreported) + 1
assert set(comments) == {*self.unreported, user_comment}
# Report someone's else comment
comment_reported_by_user = baker.make(UEComment, ue=self.ue)
baker.make(
UECommentReport, comment=comment_reported_by_user, reporter=self.subscriber
)
comments = self.client.get(self.ue_url).context_data.get("comments", [])
assert len(comments) == len(self.unreported) + 2
assert set(comments) == {
*self.unreported,
user_comment,
comment_reported_by_user,
}
def test_comments_pedagogy_admin(self):
# Pedagogy admin sees everything
self.client.force_login(self.pedagogy_admin)
comments = self.client.get(self.ue_url).context_data.get("comments", [])
assert len(comments) == 10
def test_comments_admin(self):
# Admin sees everything
self.client.force_login(self.admin)
comments = self.client.get(self.ue_url).context_data.get("comments", [])
assert len(comments) == 10
class TestUECommentCreation(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.bibou = User.objects.get(username="root") cls.bibou = User.objects.get(username="root")
@@ -414,14 +311,11 @@ class TestUECommentCreation(TestCase):
cls.guy = User.objects.get(username="guy") cls.guy = User.objects.get(username="guy")
cls.ue = UE.objects.get(code="PA00") cls.ue = UE.objects.get(code="PA00")
cls.ue_url = reverse("pedagogy:ue_detail", kwargs={"ue_id": cls.ue.id}) cls.ue_url = reverse("pedagogy:ue_detail", kwargs={"ue_id": cls.ue.id})
cls.comment_create_url = reverse(
"pedagogy:comment_create", kwargs={"ue_id": cls.ue.id}
)
def test_create_ue_comment_admin_success(self): def test_create_ue_comment_admin_success(self):
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
response = self.client.post( response = self.client.post(
self.comment_create_url, create_ue_comment_template(self.bibou.id) self.ue_url, create_ue_comment_template(self.bibou.id)
) )
assertRedirects(response, self.ue_url) assertRedirects(response, self.ue_url)
response = self.client.get(self.ue_url) response = self.client.get(self.ue_url)
@@ -430,7 +324,7 @@ class TestUECommentCreation(TestCase):
def test_create_ue_comment_pedagogy_admin_success(self): def test_create_ue_comment_pedagogy_admin_success(self):
self.client.force_login(self.tutu) self.client.force_login(self.tutu)
response = self.client.post( response = self.client.post(
self.comment_create_url, create_ue_comment_template(self.tutu.id) self.ue_url, create_ue_comment_template(self.tutu.id)
) )
self.assertRedirects(response, self.ue_url) self.assertRedirects(response, self.ue_url)
response = self.client.get(self.ue_url) response = self.client.get(self.ue_url)
@@ -439,7 +333,7 @@ class TestUECommentCreation(TestCase):
def test_create_ue_comment_subscriber_success(self): def test_create_ue_comment_subscriber_success(self):
self.client.force_login(self.sli) self.client.force_login(self.sli)
response = self.client.post( response = self.client.post(
self.comment_create_url, create_ue_comment_template(self.sli.id) self.ue_url, create_ue_comment_template(self.sli.id)
) )
self.assertRedirects(response, self.ue_url) self.assertRedirects(response, self.ue_url)
response = self.client.get(self.ue_url) response = self.client.get(self.ue_url)
@@ -448,7 +342,7 @@ class TestUECommentCreation(TestCase):
def test_create_ue_empty_comment_fail(self): def test_create_ue_empty_comment_fail(self):
self.client.force_login(self.tutu) self.client.force_login(self.tutu)
response = self.client.post( response = self.client.post(
self.comment_create_url, self.ue_url,
{ {
"author": self.tutu.id, "author": self.tutu.id,
"ue": UE.objects.get(code="PA00").id, "ue": UE.objects.get(code="PA00").id,
@@ -466,40 +360,24 @@ class TestUECommentCreation(TestCase):
def test_create_ue_comment_unauthorized_fail(self): def test_create_ue_comment_unauthorized_fail(self):
nb_comments = self.ue.comments.count() nb_comments = self.ue.comments.count()
# Test with anonymous user # Test with anonymous user
response = self.client.post( response = self.client.post(self.ue_url, create_ue_comment_template(0))
self.comment_create_url, create_ue_comment_template(0) assertRedirects(response, reverse("core:login", query={"next": self.ue_url}))
)
assertRedirects(
response, reverse("core:login", query={"next": self.comment_create_url})
)
# Test with non subscribed user # Test with non subscribed user
self.client.force_login(self.guy) self.client.force_login(self.guy)
response = self.client.post( response = self.client.post(
self.comment_create_url, create_ue_comment_template(self.guy.id) self.ue_url, create_ue_comment_template(self.guy.id)
) )
assert response.status_code == 403 assert response.status_code == 403
# Check that no comment has been created # Check that no comment has been created
assert self.ue.comments.count() == nb_comments assert self.ue.comments.count() == nb_comments
def test_create_ue_comment_ue_not_exist_fails(self):
self.client.force_login(self.bibou)
not_existing_id = UE.objects.aggregate(id=Max("id"))["id"] + 1
response = self.client.post(
reverse("pedagogy:comment_create", kwargs={"ue_id": not_existing_id}),
create_ue_comment_template(
self.bibou.id,
ue=not_existing_id,
),
)
assert response.status_code == 404
def test_create_ue_comment_bad_form_fail(self): def test_create_ue_comment_bad_form_fail(self):
nb_comments = self.ue.comments.count() nb_comments = self.ue.comments.count()
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
response = self.client.post( response = self.client.post(
self.comment_create_url, self.ue_url,
create_ue_comment_template(self.bibou.id, exclude_list=["grade_global"]), create_ue_comment_template(self.bibou.id, exclude_list=["grade_global"]),
) )
@@ -507,55 +385,45 @@ class TestUECommentCreation(TestCase):
assert self.ue.comments.count() == nb_comments assert self.ue.comments.count() == nb_comments
def test_create_ue_comment_twice_fail(self): def test_create_ue_comment_twice_fail(self):
# Checks that the has_user_already_commented method works properly # Checks that the has_user_already_commented method works proprely
assert not self.ue.has_user_already_commented(self.bibou) assert not self.ue.has_user_already_commented(self.bibou)
# Create a first comment # Create a first comment
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
self.client.post( self.client.post(self.ue_url, create_ue_comment_template(self.bibou.id))
self.comment_create_url, create_ue_comment_template(self.bibou.id)
)
# Checks that the has_user_already_commented method works properly # Checks that the has_user_already_commented method works proprely
assert self.ue.has_user_already_commented(self.bibou) assert self.ue.has_user_already_commented(self.bibou)
# Create the second comment # Create the second comment
comment = create_ue_comment_template(self.bibou.id) comment = create_ue_comment_template(self.bibou.id)
comment["comment"] = "Twice" comment["comment"] = "Twice"
response = self.client.post(self.comment_create_url, comment) response = self.client.post(self.ue_url, comment)
assert response.status_code == 403 assert response.status_code == 200
assert UEComment.objects.filter(comment__contains="Superbe UE").exists() assert UEComment.objects.filter(comment__contains="Superbe UE").exists()
assert not UEComment.objects.filter(comment__contains="Twice").exists() assert not UEComment.objects.filter(comment__contains="Twice").exists()
self.assertContains(
def test_create_ue_comment_wrong_args(self): response,
self.client.force_login(self.bibou) _(
"You already posted a comment on this UE. "
"If you want to comment again, "
"please modify or delete your previous comment."
),
)
# Ensure that there is no crash when no ue or no author is given # Ensure that there is no crash when no ue or no author is given
response = self.client.post( self.client.post(
self.comment_create_url, self.ue_url, create_ue_comment_template(self.bibou.id, exclude_list=["ue"])
create_ue_comment_template(self.bibou.id, exclude_list=["ue"]),
) )
assert response.status_code == 200 assert response.status_code == 200
assert not self.ue.has_user_already_commented(self.bibou) self.client.post(
self.ue_url,
response = self.client.post(
self.comment_create_url,
create_ue_comment_template(self.bibou.id, exclude_list=["author"]), create_ue_comment_template(self.bibou.id, exclude_list=["author"]),
) )
assert response.status_code == 200 assert response.status_code == 200
assert not self.ue.has_user_already_commented(self.bibou)
# Ensure that we can't push the wrong UE id
other_ue = baker.make(UE)
response = self.client.post(
self.comment_create_url,
create_ue_comment_template(self.bibou.id, ue=other_ue.id),
)
assert response.status_code == 200
assert not self.ue.has_user_already_commented(self.bibou)
class TestUECommentDelete(TestCase): class TestUVCommentDelete(TestCase):
"""Test UEComment deletion rights.""" """Test UEComment deletion rights."""
@classmethod @classmethod
@@ -594,7 +462,7 @@ class TestUECommentDelete(TestCase):
assert UEComment.objects.filter(id=self.comment.id).exists() assert UEComment.objects.filter(id=self.comment.id).exists()
class TestUECommentUpdate(TestCase): class TestUVCommentUpdate(TestCase):
"""Test UEComment update rights.""" """Test UEComment update rights."""
@classmethod @classmethod
@@ -668,7 +536,7 @@ class TestUECommentUpdate(TestCase):
self.assertEqual(self.comment.author, self.krophil) self.assertEqual(self.comment.author, self.krophil)
class TestUEModerationForm(TestCase): class TestUVModerationForm(TestCase):
"""Assert access rights and if the form works well.""" """Assert access rights and if the form works well."""
@classmethod @classmethod
@@ -882,7 +750,7 @@ class TestUEModerationForm(TestCase):
assert UEComment.objects.filter(id=self.comment_2.id).exists() assert UEComment.objects.filter(id=self.comment_2.id).exists()
class TestUECommentReportCreate(TestCase): class TestUVCommentReportCreate(TestCase):
"""Test report creation view. """Test report creation view.
Assert access rights and if you can create with it. Assert access rights and if you can create with it.
+2 -8
View File
@@ -24,13 +24,12 @@
from django.urls import path from django.urls import path
from pedagogy.views import ( from pedagogy.views import (
UECommentCreateView,
UECommentDeleteView, UECommentDeleteView,
UECommentReportCreateView, UECommentReportCreateView,
UECommentUpdateView, UECommentUpdateView,
UECreateView, UECreateView,
UEDeleteView, UEDeleteView,
UEDetailView, UEDetailFormView,
UEGuideView, UEGuideView,
UEModerationFormView, UEModerationFormView,
UEUpdateView, UEUpdateView,
@@ -39,12 +38,7 @@ from pedagogy.views import (
urlpatterns = [ urlpatterns = [
# Urls displaying the actual application for visitors # Urls displaying the actual application for visitors
path("", UEGuideView.as_view(), name="guide"), path("", UEGuideView.as_view(), name="guide"),
path("ue/<int:ue_id>/", UEDetailView.as_view(), name="ue_detail"), path("ue/<int:ue_id>/", UEDetailFormView.as_view(), name="ue_detail"),
path(
"ue/<int:ue_id>/comment",
UECommentCreateView.as_view(),
name="comment_create",
),
path( path(
"comment/<int:comment_id>/edit/", "comment/<int:comment_id>/edit/",
UECommentUpdateView.as_view(), UECommentUpdateView.as_view(),
+33 -68
View File
@@ -16,7 +16,7 @@
# details. # details.
# #
# You should have received a copy of the GNU General Public License along with # You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple # this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
@@ -26,11 +26,9 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Exists, OuterRef from django.db.models import Exists, OuterRef
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.functional import cached_property
from django.views.generic import ( from django.views.generic import (
CreateView, CreateView,
DeleteView, DeleteView,
DetailView,
FormView, FormView,
TemplateView, TemplateView,
UpdateView, UpdateView,
@@ -38,7 +36,7 @@ from django.views.generic import (
from core.auth.mixins import PermissionOrAuthorRequiredMixin from core.auth.mixins import PermissionOrAuthorRequiredMixin
from core.models import Notification, User from core.models import Notification, User
from core.views.mixins import AllowFragment, FragmentMixin, UseFragmentsMixin from core.views import DetailFormView
from pedagogy.forms import ( from pedagogy.forms import (
UECommentForm, UECommentForm,
UECommentModerationForm, UECommentModerationForm,
@@ -48,59 +46,37 @@ from pedagogy.forms import (
from pedagogy.models import UE, UEComment, UECommentReport from pedagogy.models import UE, UEComment, UECommentReport
class UECommentCreateView(PermissionRequiredMixin, FragmentMixin, CreateView): class UEDetailFormView(PermissionRequiredMixin, DetailFormView):
model = UEComment """Display every comment of an UE and detailed infos about it.
template_name = "pedagogy/fragments/ue_comment_form.jinja"
form_class = UECommentForm
permission_required = "pedagogy.add_uecomment"
object = None # Avoid initialisation bug with FragmentMixin
@cached_property Allow to comment the UE.
def ue(self): """
return get_object_or_404(UE, id=self.kwargs.get("ue_id"))
model = UE
pk_url_kwarg = "ue_id"
template_name = "pedagogy/ue_detail.jinja"
form_class = UECommentForm
permission_required = "pedagogy.view_ue"
def has_permission(self): def has_permission(self):
if self.ue.has_user_already_commented(self.request.user): if self.request.method == "POST" and not self.request.user.has_perm(
"pedagogy.add_uecomment"
):
# if it's a POST request, the user is trying to add a new UEComment
# thus he also needs the "add_uecomment" permission
return False return False
return super().has_permission() return super().has_permission()
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs["author_id"] = self.request.user.id kwargs["author_id"] = self.request.user.id
kwargs["ue_id"] = self.ue.id kwargs["ue_id"] = self.object.id
kwargs["is_creation"] = True kwargs["is_creation"] = True
return kwargs return kwargs
def get_context_data(self, **kwargs): def form_valid(self, form):
return super().get_context_data(**kwargs) | { form.save()
"action": reverse("pedagogy:comment_create", kwargs={"ue_id": self.ue.id}), return super().form_valid(form)
"object": self.ue,
}
def get_success_url(self):
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.ue.id})
class UEDetailView(
PermissionRequiredMixin, UseFragmentsMixin, AllowFragment, DetailView
):
"""Display every comment of an UE and detailed infos about it."""
model = UE
pk_url_kwarg = "ue_id"
permission_required = "pedagogy.view_ue"
fragments = {"add_comment_form": UECommentCreateView}
def get_template_names(self) -> list[str]:
is_fragment = self.request.headers.get("HX-Request", False)
return (
["pedagogy/fragments/ue_detail/ue_detail.jinja"]
if is_fragment
else ["pedagogy/ue_detail.jinja"]
)
def get_fragment_data(self):
return {"add_comment_form": {"ue_id": self.object.id}}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | { return super().get_context_data(**kwargs) | {
@@ -112,14 +88,19 @@ class UEDetailView(
) )
} }
def get_success_url(self):
# once the new ue comment has been saved
# redirect to the same page we are currently
return self.request.path
class UECommentUpdateView(PermissionOrAuthorRequiredMixin, AllowFragment, UpdateView):
class UECommentUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
"""Allow edit of a given comment.""" """Allow edit of a given comment."""
model = UEComment model = UEComment
form_class = UECommentForm form_class = UECommentForm
pk_url_kwarg = "comment_id" pk_url_kwarg = "comment_id"
template_name = "pedagogy/fragments/ue_comment_form.jinja" template_name = "core/edit.jinja"
permission_required = "pedagogy.change_uecomment" permission_required = "pedagogy.change_uecomment"
author_field = "author" author_field = "author"
@@ -130,18 +111,11 @@ class UECommentUpdateView(PermissionOrAuthorRequiredMixin, AllowFragment, Update
kwargs["is_creation"] = False kwargs["is_creation"] = False
return kwargs return kwargs
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"action": reverse(
"pedagogy:comment_update", kwargs={"comment_id": self.object.id}
)
}
def get_success_url(self): def get_success_url(self):
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue_id}) return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue_id})
class UECommentDeleteView(PermissionOrAuthorRequiredMixin, AllowFragment, DeleteView): class UECommentDeleteView(PermissionOrAuthorRequiredMixin, DeleteView):
"""Allow to delete a given comment.""" """Allow to delete a given comment."""
model = UEComment model = UEComment
@@ -161,34 +135,24 @@ class UEGuideView(PermissionRequiredMixin, TemplateView):
permission_required = "pedagogy.view_ue" permission_required = "pedagogy.view_ue"
class UECommentReportCreateView(PermissionRequiredMixin, AllowFragment, CreateView): class UECommentReportCreateView(PermissionRequiredMixin, CreateView):
"""Create a new report for an inappropriate comment.""" """Create a new report for an inappropriate comment."""
model = UECommentReport model = UECommentReport
form_class = UECommentReportForm form_class = UECommentReportForm
template_name = "pedagogy/fragments/comment_report.jinja" template_name = "core/edit.jinja"
permission_required = "pedagogy.add_uecommentreport" permission_required = "pedagogy.add_uecommentreport"
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.ue_comment = get_object_or_404(UEComment, pk=kwargs["comment_id"]) self.ue_comment = get_object_or_404(UEComment, pk=kwargs["comment_id"])
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@cached_property
def ue_detail_url(self):
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.ue_comment.ue_id})
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs["reporter_id"] = self.request.user.id kwargs["reporter_id"] = self.request.user.id
kwargs["comment_id"] = self.ue_comment.id kwargs["comment_id"] = self.ue_comment.id
return kwargs return kwargs
def get_context_data(self, **kwargs):
return super().get_context_data() | {
"comment_id": self.ue_comment.id,
"ue_detail_url": self.ue_detail_url,
}
def form_valid(self, form): def form_valid(self, form):
resp = super().form_valid(form) resp = super().form_valid(form)
# Send a message to moderation admins # Send a message to moderation admins
@@ -204,10 +168,11 @@ class UECommentReportCreateView(PermissionRequiredMixin, AllowFragment, CreateVi
url=reverse("pedagogy:moderation"), url=reverse("pedagogy:moderation"),
type="PEDAGOGY_MODERATION", type="PEDAGOGY_MODERATION",
) )
return resp return resp
def get_success_url(self): def get_success_url(self):
return self.ue_detail_url return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.ue_comment.ue_id})
class UEModerationFormView(PermissionRequiredMixin, FormView): class UEModerationFormView(PermissionRequiredMixin, FormView):
+17 -24
View File
@@ -17,25 +17,25 @@ authors = [
{ name = "Vial", email = "robin.trioux@utbm.fr" }, { name = "Vial", email = "robin.trioux@utbm.fr" },
] ]
license = { text = "GPL-3.0-only" } license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.14" requires-python = "<4.0,>=3.12"
dependencies = [ dependencies = [
"django>=5.2.17,<6.0.0", "django>=5.2.17,<6.0.0",
"django-ninja>=1.7.0,<2.0.0", "django-ninja>=1.6.3,<2.0.0",
"django-ninja-extra>=0.31.7", "django-ninja-extra>=0.31.7",
"Pillow>=12.3.0,<13.0.0", "Pillow>=12.3.0,<13.0.0",
"aemark>=0.1.1", "aemark>=0.1.1",
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",
"cryptography>=50.0.1,<51.0.0", "cryptography>=50.0.0,<51.0.0",
"django-phonenumber-field>=8.5.0,<9.0.0", "django-phonenumber-field>=8.5.0,<9.0.0",
"phonenumbers>=9.0.38,<10.0.0", "phonenumbers>=9.0.37,<10.0.0",
"reportlab>=5.0.1,<6.0.0", "reportlab>=5.0.1,<6.0.0",
"django-haystack>=3.4.0,<4.0.0", "django-haystack>=3.4.0,<4.0.0",
"xapian-haystack>=4.0.0,<5.0.0", "xapian-haystack>=4.0.0,<5.0.0",
"libsass>=0.23.0,<1.0.0", "libsass>=0.23.0,<1.0.0",
"django-ordered-model>=3.7.4,<4.0.0", "django-ordered-model>=3.7.4,<4.0.0",
"django-simple-captcha>=0.7.0,<1.0.0", "django-simple-captcha>=0.7.0,<1.0.0",
"python-dateutil>=2.9.0.post0,<3.0.0", "python-dateutil>=2.9.0.post0,<3.0.0.0",
"sentry-sdk>=2.68.1,<3.0.0", "sentry-sdk>=2.68.0,<3.0.0",
"jinja2>=3.1.6,<4.0.0", "jinja2>=3.1.6,<4.0.0",
"django-countries>=9.0.0,<10.0.0", "django-countries>=9.0.0,<10.0.0",
"dict2xml>=1.7.8,<2.0.0", "dict2xml>=1.7.8,<2.0.0",
@@ -43,13 +43,13 @@ dependencies = [
"tomli>=2.4.1,<3.0.0", "tomli>=2.4.1,<3.0.0",
"django-honeypot>=1.3.0,<2", "django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.11.1,<3.0.0", "pydantic-extra-types>=2.11.1,<3.0.0",
"ical>=14.1.1", "ical>=12.0.0",
"redis[hiredis]>=6.4.0,<9.0.0", "redis[hiredis]>=6.4.0,<9.0.0",
"environs[django]>=15.1.0,<16", "environs[django]>=15.1.0,<16",
"requests>=2.34.2,<3.0.0", "requests>=2.34.2,<3.0.0",
"honcho>=2.0.0", "honcho>=2.0.0",
"psutil>=7.2.2,<8.0.0", "psutil>=7.2.2,<8.0.0",
"celery[redis]>=5.6.2,<7", "celery[redis]>=5.6.3,<8",
"django-celery-results>=2.6.0", "django-celery-results>=2.6.0",
"django-celery-beat>=2.9.0", "django-celery-beat>=2.9.0",
] ]
@@ -63,12 +63,12 @@ prod = [
"psycopg[c]>=3.3.4,<4.0.0", "psycopg[c]>=3.3.4,<4.0.0",
] ]
dev = [ dev = [
"django-debug-toolbar>=8.0.0,<9", "django-debug-toolbar>=7.1.1,<8",
"ipython>=9.17.1,<10.0.0", "ipython>=9.16.1,<10.0.0",
"pre-commit>=4.6.1,<5.0.0", "pre-commit>=4.6.0,<5.0.0",
"ruff>=0.16.6,<1.0.0", "ruff>=0.16.0,<1.0.0",
"djhtml>=3.0.11,<4.0.0", "djhtml>=3.0.11,<4.0.0",
"faker>=40.38.0,<41.0.0", "faker>=40.37.0,<41.0.0",
"rjsmin>=1.2.5,<2.0.0", "rjsmin>=1.2.5,<2.0.0",
] ]
tests = [ tests = [
@@ -78,7 +78,7 @@ tests = [
"pytest-django>=4.14.0,<5.0.0", "pytest-django>=4.14.0,<5.0.0",
"model-bakery>=1.24.0,<2.0.0", "model-bakery>=1.24.0,<2.0.0",
"beautifulsoup4>=4.15.0,<5", "beautifulsoup4>=4.15.0,<5",
"lxml>=6.1.3,<7", "lxml>=6.1.2,<7",
] ]
docs = [ docs = [
"mkdocs>=1.6.1,<2.0.0", "mkdocs>=1.6.1,<2.0.0",
@@ -92,11 +92,11 @@ docs = [
default-groups = ["dev", "tests", "docs"] default-groups = ["dev", "tests", "docs"]
[tool.xapian] [tool.xapian]
version = "2.1.0" version = "2.0.0"
# Those hashes are here to protect against supply chains attacks # Those hashes are here to protect against supply chains attacks
# See `https://ae-utbm.github.io/sith/howto/xapian/` for more information # See `https://ae-utbm.github.io/sith/howto/xapian/` for more information
core-sha256 = "8e1259586d342e3d12b5e1f772e9185a10f2ba16e541566b5c3c239f71b8aacc" core-sha256 = "6cea3f49952a47224439a40bdb3608f928d121ad8721b9921cc42802d548ecf8"
bindings-sha256 = "f52ec189f13b4fa66ea625a6eb94bb32dd651b9ec806be6a911dda54cbe3875c" bindings-sha256 = "9a544b69c31355a92edbcd4102cf0f1ec4407fd0a4645f4870fb52300b736910"
[tool.ruff] [tool.ruff]
output-format = "concise" # makes ruff error logs easier to read output-format = "concise" # makes ruff error logs easier to read
@@ -132,13 +132,6 @@ ignore = [
"RUF012" # mutable class attributes. This rule doesn't integrate well with django "RUF012" # mutable class attributes. This rule doesn't integrate well with django
] ]
[tool.ruff.lint.per-file-ignores]
# schemas and API routes are built upon ninja and pydantic,
# which heavily rely on type annotations at runtime.
# Systematically using TYPE_CHECKING blocks in those files is NOT safe
"schemas.py" = ["TCH"]
"api.py" = ["TCH"]
[tool.ruff.lint.pydocstyle] [tool.ruff.lint.pydocstyle]
convention = "google" convention = "google"
+1 -4
View File
@@ -1,8 +1,8 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import TYPE_CHECKING
import pytest import pytest
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localtime from django.utils.timezone import localtime
from model_bakery import baker from model_bakery import baker
@@ -10,9 +10,6 @@ from pytest_django.asserts import assertRedirects
from core.models import BanGroup, User, UserBan from core.models import BanGroup, User, UserBan
if TYPE_CHECKING:
from django.test import Client
@pytest.fixture @pytest.fixture
def operator(db) -> User: def operator(db) -> User:

Some files were not shown because too many files have changed in this diff Show More