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
119 changed files with 1505 additions and 1962 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.db.models import QuerySet
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
if TYPE_CHECKING:
from django.db.models import QuerySet
from django.http import HttpRequest
@admin.register(ApiClient)
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 api.hashers import get_hasher
from api.models import ApiClient, ApiKey
if TYPE_CHECKING:
from django.http import HttpRequest
class ApiKeyAuth(APIKeyHeader):
"""Authentication through client api keys."""
+4 -6
View File
@@ -39,17 +39,15 @@ Example:
import operator
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 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):
"""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"
#
#
from typing import TYPE_CHECKING
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
if TYPE_CHECKING:
from django.forms.models import ModelForm
from django.http import HttpRequest
@admin.register(Club)
class ClubAdmin(admin.ModelAdmin):
@@ -50,8 +46,8 @@ class ClubAdmin(admin.ModelAdmin):
@admin.register(ClubRole)
class ClubRoleAdmin(admin.ModelAdmin):
list_display = ("name", "club", "is_board", "is_presidency")
search_fields = ("name", "club__name")
autocomplete_fields = ("club", "linked_groups")
search_fields = ("name",)
autocomplete_fields = ("club",)
list_select_related = ("club",)
list_filter = (
"is_board",
-7
View File
@@ -479,13 +479,6 @@ class ClubRoleCreateForm(forms.ModelForm):
class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
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(
Club,
@@ -1,16 +1,12 @@
# 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.functions.datetime
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Q
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
# was the hardcoded highest non-board role
MAXIMUM_FREE_ROLE = 1
@@ -1,14 +1,10 @@
# Generated by Django 5.2.3 on 2025-06-21 21:59
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
PRESIDENCY_ROLES = [10, 9]
MAXIMUM_FREE_ROLE = 1
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",
),
),
]
+11 -32
View File
@@ -23,8 +23,6 @@
#
from __future__ import annotations
import operator
from functools import reduce
from typing import Iterable, Self
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."
),
)
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"
@@ -545,7 +534,7 @@ class Membership(models.Model):
def _remove_club_groups(
memberships: Iterable[Membership],
) -> 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,
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}
users = {m.user_id for m in memberships}
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(
(Q(group__in=groups) & Q(user__in=users))
| reduce(operator.or_, role_groups)
Q(group__in=groups) & Q(user__in=users)
).delete()
@staticmethod
def _add_club_groups(
memberships: Iterable[Membership],
) -> 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,
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]
if not memberships:
return []
nb_prefetched = sum(
1 for m in memberships if not hasattr(m, "club") or not hasattr(m, "role")
)
if nb_prefetched > 1:
if sum(1 for m in memberships if not hasattr(m, "club")) > 1:
# if more than one membership hasn't its `club` attribute set
# it's less expensive to reload the whole query with
# a select_related than perform a distinct query
# to fetch each club.
ids = {m.id for m in memberships}
memberships = list(
Membership.objects.filter(id__in=ids)
.select_related("club", "role")
.prefetch_related("role__linked_groups")
Membership.objects.filter(id__in=ids).select_related("club")
)
groups = []
club_groups = []
for membership in memberships:
groups.append(
club_groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.members_group_id,
)
)
if membership.role.is_board:
groups.append(
club_groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.board_group_id,
)
)
groups.extend(
User.groups.through(user_id=membership.user_id, group_id=g.id)
for g in membership.role.linked_groups.all()
return User.groups.through.objects.bulk_create(
club_groups, ignore_conflicts=True
)
return User.groups.through.objects.bulk_create(groups, ignore_conflicts=True)
class Mailing(models.Model):
+6 -11
View File
@@ -15,6 +15,9 @@
{% endblock %}
{% else %}
{% extends "core/base.jinja" %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
@@ -23,12 +26,6 @@
{%- endblock %}
{% 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 %}
{% block content %}
@@ -36,17 +33,15 @@
<h3>{% trans %}Filters{% endtrans %}</h3>
<form
id="club-list-filters"
method="GET"
hx-action="{{ url("club:club_list") }}"
hx-get="{{ url("club:club_list") }}"
hx-target="#content"
hx-swap="innerHTML"
hx-swap="outerHtml"
hx-push-url="true"
hx-disable="find input, find button"
>
<div class="row gap-4x">
{{ form }}
</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 %}
</button>
</form>
-14
View File
@@ -49,20 +49,6 @@
{{ subform.is_active.help_text }}
</span>
</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>
</details>
</div>
@@ -7,7 +7,7 @@
<form
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-target="#member-fragment-container"
id="add_club_members_form"
+1 -4
View File
@@ -1,9 +1,9 @@
from datetime import timedelta
from typing import TYPE_CHECKING
import pytest
from django.conf import settings
from django.db import ProgrammingError
from django.test import Client
from django.urls import reverse
from django.utils.timezone import localdate
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.models import User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
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
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.models import AnonymousUser, User
if TYPE_CHECKING:
from collections.abc import Callable
def make_club():
# 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
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
@@ -8,9 +7,6 @@ from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_club_board_member_cannot_edit_club_properties(client: Client):
+2 -28
View File
@@ -1,6 +1,6 @@
import itertools
from collections.abc import Callable
from datetime import timedelta
from typing import TYPE_CHECKING
import pytest
from bs4 import BeautifulSoup
@@ -17,10 +17,7 @@ from club.forms import ClubAddMemberForm, JoinClubForm
from club.models import Club, ClubRole, Membership
from club.tests.base import TestClub
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User
if TYPE_CHECKING:
from collections.abc import Callable
from core.models import AnonymousUser, User
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.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):
"""Test that when moving from board to members, club group change"""
membership = baker.make(
+1 -5
View File
@@ -1,8 +1,7 @@
from typing import TYPE_CHECKING
import pytest
from aemark import markdown
from bs4 import BeautifulSoup
from django.test import Client
from django.urls import reverse
from model_bakery import baker
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.models import PageRev, User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
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
from django.test import Client
from django.urls import reverse
from model_bakery import baker
@@ -8,9 +7,6 @@ from club.models import Club
from com.models import Poster
from core.baker_recipes import subscriber_user
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"])
+1 -4
View File
@@ -1,8 +1,8 @@
import csv
import itertools
from typing import TYPE_CHECKING
import pytest
from django.test import Client
from django.urls import reverse
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.models import Counter, Customer, Product, Selling
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_sales_page_doesnt_crash(client: Client):
+1 -4
View File
@@ -1,5 +1,4 @@
from datetime import date
from typing import TYPE_CHECKING
from dateutil.relativedelta import relativedelta
from django import forms
@@ -10,13 +9,11 @@ from django.utils.translation import gettext_lazy as _
from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub
from com.models import News, NewsDate, Poster
from core.models import User
from core.utils import get_end_of_semester
from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
if TYPE_CHECKING:
from core.models import User
class PosterForm(forms.ModelForm):
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 django.conf import settings
from django.contrib.sites.models import Site
from django.contrib.syndication.views import add_domain
from django.db.models import Count, OuterRef, QuerySet, Subquery
from django.http import HttpRequest
from django.urls import reverse
from django.utils import timezone
from ical.calendar import Calendar
@@ -13,13 +14,7 @@ from ical.event import Event
from ical.types import Frequency, Recur
from com.models import News, NewsDate
if TYPE_CHECKING:
from pathlib import Path
from django.http import HttpRequest
from core.models import User
from core.models import User
def as_absolute_url(url: str, request: HttpRequest | None = None) -> str:
+1 -4
View File
@@ -1,11 +1,11 @@
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from aemark import markdown
from django.conf import settings
from django.contrib.auth.models import Permission
from django.http import HttpResponse
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
@@ -16,9 +16,6 @@ from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate
from core.models import User
if TYPE_CHECKING:
from django.http import HttpResponse
def accel_redirect_to_file(response: HttpResponse) -> Path | None:
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 django.conf import settings
from django.db.models import F
from django.http import HttpResponse
from ninja import File, Query
from ninja.security import SessionAuth
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 (
FamilyGodfatherSchema,
GroupSchema,
MarkdownSchema,
SithFileSchema,
UploadedFileSchema,
UploadedImage,
@@ -26,9 +28,18 @@ from core.schemas import (
UserSchema,
ValidationErrorSchema,
)
from core.templatetags.renderer import markdown
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")
class UploadController(ControllerBase):
@route.post(
+4 -55
View File
@@ -1,16 +1,15 @@
from __future__ import annotations
import typing
from typing import TYPE_CHECKING
from django.conf import settings
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import Permission
from django.db.models import Exists, OuterRef, Q, QuerySet
from core.models import Group, User
from core.models import Group
if typing.TYPE_CHECKING:
from django.db.models.base import Model
if TYPE_CHECKING:
from core.models import User
class SithModelBackend(ModelBackend):
@@ -41,53 +40,3 @@ class SithModelBackend(ModelBackend):
return Permission.objects.filter(
group__group__in=groups.values_list("pk", flat=True)
)
@typing.override
def with_perm(
self,
perm: str | Permission,
is_active: bool | None = True,
include_superusers: bool = False,
obj: Model | None = None,
) -> QuerySet[User]:
"""Return users that have permission "perm".
Contrary to the base django method, superusers aren't included in the
result.
This is because the OR operation to include superusers in the query result
utterly destroy the query performances on postgres
(it makes it like 1000x slower, and I'm not even kidding).
To overcome that, we could use a UNION instead, but then we wouldn't
be able to perform further filter operations on the queryset.
Thus, the `include_superusers` argument is not used at all.
Because of that, it is useless to set `include_superusers`,
as it will be silently ignored.
The only reason it's still there is not to break the interface
of the base class.
"""
if isinstance(perm, str):
try:
app_label, codename = perm.split(".")
except ValueError as e:
raise ValueError(
"Permission name should be in the form "
"app_label.permission_codename."
) from e
permission_q = Q(codename=codename, content_type__app_label=app_label)
elif isinstance(perm, Permission):
permission_q = Q(pk=perm.pk)
else:
raise TypeError(
"The `perm` argument must be a string or a permission instance."
)
user_q = Exists(
Permission.objects.filter(
Q(group__group__users=OuterRef("pk")) | Q(user=OuterRef("pk")),
permission_q,
)
)
if is_active is not None:
user_q &= Q(is_active=is_active)
return User.objects.filter(user_q)
-1
View File
@@ -747,7 +747,6 @@ class Command(BaseCommand):
"add_subscription",
"add_membership",
"view_hidden_user",
"add_refilling",
]
)
)
@@ -1,13 +1,9 @@
# 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.migrations.state import StateApps
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):
"""Invert `is_manually_manageable`.
@@ -1,14 +1,10 @@
# Generated by Django 4.2.17 on 2024-12-31 13:30
from typing import TYPE_CHECKING
import django.contrib.auth.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
from django.db.migrations.state import StateApps
def migrate_ban_groups(apps: StateApps, schema_editor):
@@ -1,15 +1,11 @@
# Generated by Django 5.2.12 on 2026-05-01 08:59
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import F
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def set_updated_at(apps: StateApps, schema_editor):
SithFile = apps.get_model("core", "SithFile")
+4
View File
@@ -161,6 +161,10 @@ class UserFilterSchema(FilterSchema):
return value
class MarkdownSchema(Schema):
text: str
class FamilyGodfatherSchema(Schema):
godfather: int
godchild: int
+12 -26
View File
@@ -6,15 +6,10 @@
* 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 Alpine from "alpinejs";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import htmx from "htmx.org";
import { limitedChoices } from "#core:alpine/limited-choices";
import { expireOldStorage } from "#core:core/localstorage";
import { default as navbar } from "#core:core/navbar";
@@ -48,28 +43,19 @@ polyfillCountryFlagEmojis();
/**
* HTMX
*/
htmx.registerExtension("aria-busy", {
// biome-ignore lint/style/useNamingConvention: api's name
htmx_before_request: (
_: HTMLElement,
detail: { ctx: { target: HTMLElement | undefined } },
) => {
if (detail.ctx.target !== undefined) {
(detail.ctx.target as HTMLElement).ariaBusy = "true";
}
document.body.addEventListener(
"htmx:beforeRequest" as keyof HTMLElementEventMap,
(event) => {
(event as CustomEvent).detail.target.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 });
@@ -3,12 +3,16 @@
import "codemirror/lib/codemirror.css";
// @ts-expect-error 2307
import "easymde/src/css/easymde.css";
import { markdown } from "@ae_utbm/aemark";
// 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 { 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 easymde = new EasyMDE({
@@ -60,7 +64,19 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
});
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
imageTexts: {
sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."),
-4
View File
@@ -4,10 +4,6 @@
<div id="page">
<h3>{% trans %}404, Not Found{% endtrans %}</h3>
<blockquote>
{% trans %}Impossible, perhaps the archives are incomplete{% endtrans %}
</blockquote>
</div>
{% endblock %}
-2
View File
@@ -14,8 +14,6 @@
{%- endblock %}"
>
<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 %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
+28 -30
View File
@@ -4,11 +4,15 @@
{% trans %}Delete confirmation{% endtrans %}
{% endblock %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
{% block errors %}
{% endblock %}
{% if is_fragment %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
{% block errors %}
{% endblock %}
{% endif %}
{% block file %}
<h2>{% trans %}Delete confirmation{% endtrans %}</h2>
@@ -19,36 +23,30 @@
{% set action = current %}
{% endif %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<div class="row gap">
<form
style="margin-right: 0"
method="post"
{% if is_fragment %}
hx-action="{{ action }}"
hx-target="#content"
hx-swap="innerHTML"
{% else %}
action="{{ action }}"
{% endif %}
>
<form action="{{ action }}" method="post">
{% csrf_token %}
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
</form>
<form
style="margin-left: 0"
method="get"
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<button
{% if is_fragment %}
hx-action="{{ previous }}"
hx-post="{{ action }}"
hx-target="#content"
hx-swap="innerHTML"
{% else %}
action="javascript:history.back();"
hx-swap="outerHtml"
{% endif %}
>
<input type="submit" name="cancel" value="{% trans %}Cancel{% endtrans %}" />
>{% trans %}Confirm{% endtrans %}</button>
<button
{% if is_fragment %}
hx-get="{{ previous }}"
hx-target="#content"
hx-swap="outerHtml"
{% else %}
action="window.history.back()"
{% endif %}
>{% trans %}Cancel{% endtrans %}</button>
</form>
</div>
{% endblock %}
+2 -2
View File
@@ -39,13 +39,13 @@
<p><button
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
hx-target="#content"
hx-swap="innerHTML"
hx-swap="outerHtml"
>{% trans %}Moderate{% endtrans %}</button> -
{% set current_page = url('core:file_moderation') + "?page=" + page_obj.number | string %}
<button
hx-get="{{ url('core:file_delete', file_id=f.id) }}?next={{ current_page | urlencode }}&previous={{ current_page | urlencode }}"
hx-target="#file-{{ loop.index }}"
hx-swap="innerHTML"
hx-swap="outerHtml"
>{% trans %}Delete{% endtrans %}</button></p>
</div>
{% endfor %}
@@ -1,6 +1,6 @@
<form
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 }} }"
>
{% for message in messages %}
+2 -4
View File
@@ -20,15 +20,13 @@
# 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
from django.template.loader import render_to_string
from jinja2 import Environment, nodes
from jinja2.ext import Extension
if TYPE_CHECKING:
from jinja2.parser import Parser
from jinja2.parser import Parser
class HoneypotExtension(Extension):
+1 -4
View File
@@ -23,18 +23,15 @@
#
import datetime
from typing import TYPE_CHECKING
import phonenumbers
from aemark import markdown as md
from django import template
from django.forms import BoundField
from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe
from django.utils.translation import ngettext
if TYPE_CHECKING:
from django.forms import BoundField
register = template.Library()
-27
View File
@@ -1,27 +0,0 @@
import pytest
from django.contrib.auth.models import Permission
from model_bakery import baker
from core.models import Group, User
@pytest.mark.django_db
def test_with_perm():
"""Test that `SithModelBackend.with_perm` works as intended."""
perms = baker.make(Permission, _quantity=4)
groups = baker.make(Group, _quantity=2)
groups[0].permissions.set(perms[0:2])
groups[1].permissions.set(perms[2:4])
users = [
baker.make(User),
baker.make(User, groups=[groups[0]]),
baker.make(User, groups=[groups[1]]),
baker.make(User, user_permissions=[perms[0]]),
baker.make(User, user_permissions=[perms[2]]),
baker.make(User, groups=[groups[1]], user_permissions=[perms[0]]),
]
expected = [users[1], users[3], users[5]]
assert list(User.objects.with_perm(perms[0])) == expected
str_repr = f"{perms[0].content_type.app_label}.{perms[0].codename}"
assert list(User.objects.with_perm(str_repr)) == expected
+1 -4
View File
@@ -1,5 +1,4 @@
from datetime import timedelta
from typing import TYPE_CHECKING
import freezegun
import pytest
@@ -7,6 +6,7 @@ from aemark import markdown
from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from django.utils.timezone import now
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.models import AnonymousUser, Page, PageRev, User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
class TestEditPage:
-16
View File
@@ -141,22 +141,6 @@ class TestSearchUsersView(TestSearchUsers):
response = self.client.get(reverse("core:search"))
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
def test_user_account_not_found(client: Client):
+22 -6
View File
@@ -21,7 +21,7 @@
# 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 com.views import NewsListView
@@ -193,11 +193,27 @@ urlpatterns = [
name="user_gift_delete",
),
# File views
path("file/", FileListView.as_view(), name="file_list"),
path("file/<int:file_id>/", FileView.as_view(), name="file_detail"),
path("file/<int:file_id>/edit/", FileEditView.as_view(), name="file_edit"),
path("file/<int:file_id>/prop/", FileEditPropView.as_view(), name="file_prop"),
path("file/<int:file_id>/delete/", FileDeleteView.as_view(), name="file_delete"),
re_path(r"^file/$", FileListView.as_view(), name="file_list"),
re_path(
r"^file/(?P<file_id>[0-9]+)/$",
FileView.as_view(),
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/<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
from io import BytesIO
from typing import TYPE_CHECKING, Final
from typing import Final
import PIL
from django.conf import settings
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 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] = (
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"
+10 -6
View File
@@ -13,7 +13,7 @@
#
#
import mimetypes
from typing import TYPE_CHECKING
from pathlib import Path
from urllib.parse import quote, urljoin
# 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
if TYPE_CHECKING:
from pathlib import Path
def send_raw_file(path: Path) -> HttpResponse:
"""Send a file located in the MEDIA_ROOT
@@ -356,8 +353,15 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
if "next" in self.request.GET:
return self.request.GET["next"]
if self.object.parent is None:
return reverse("core:file_list")
return reverse("core:file_detail", kwargs={"file_id": self.object.parent.id})
return reverse(
"core:file_list",
)
return reverse(
"core:file_detail",
kwargs={
"file_id": self.object.parent.id,
},
)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
+1 -4
View File
@@ -22,8 +22,8 @@
#
import re
from copy import copy
from datetime import date, datetime
from io import BytesIO
from typing import TYPE_CHECKING
from captcha.fields import CaptchaField
from django import forms
@@ -59,9 +59,6 @@ from core.views.widgets.ajax_select import (
)
from core.views.widgets.markdown import MarkdownInput
if TYPE_CHECKING:
from datetime import date, datetime
# Widgets
+2 -7
View File
@@ -22,11 +22,11 @@
#
#
from typing import TYPE_CHECKING
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
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.views.generic import ListView, TemplateView
@@ -34,10 +34,6 @@ from club.models import Club
from core.models import Notification, User
from core.schemas import UserFilterSchema
if TYPE_CHECKING:
from django.db.models.query import QuerySet
from django.http import HttpRequest
class NotificationList(LoginRequiredMixin, ListView):
model = Notification
@@ -69,7 +65,6 @@ class SearchView(LoginRequiredMixin, TemplateView):
UserFilterSchema(search=query)
.filter(User.objects.viewable_by(self.request.user))
.order_by(F("last_login").desc(nulls_last=True))
.distinct()
)
clubs = list(Club.objects.filter(name__icontains=query)[:5])
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}
+3 -5
View File
@@ -1,16 +1,14 @@
import copy
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.http import HttpRequest, HttpResponse
from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.views import View
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):
"""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
from operator import itemgetter
from smtplib import SMTPException
from typing import TYPE_CHECKING
from django.contrib import messages
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.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _
from django.views.decorators.http import require_POST
from django.views.generic import (
@@ -73,9 +73,6 @@ from counter.models import Refilling, Selling
from eboutic.models import Invoice
from trombi.views import UserTrombiForm
if TYPE_CHECKING:
from django.utils.safestring import SafeString
class SithLoginView(views.LoginView):
"""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.db.models import Model, QuerySet
from django.forms import Select, SelectMultiple
from ninja import ModelSchema
from pydantic import TypeAdapter
from core.models import Group, SithFile, User
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:
component_name = "autocomplete-select"
+2 -4
View File
@@ -3,7 +3,7 @@ import math
import uuid
from collections import defaultdict
from datetime import date, datetime, timezone
from typing import TYPE_CHECKING, ClassVar
from typing import ClassVar
from dateutil.relativedelta import relativedelta
from django import forms
@@ -11,6 +11,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet
from django.http import HttpRequest
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -58,9 +59,6 @@ from counter.widgets.ajax_select import (
AutoCompleteSelectProduct,
)
if TYPE_CHECKING:
from django.http import HttpRequest
class BillingInfoForm(forms.ModelForm):
class Meta:
+1 -4
View File
@@ -1,5 +1,5 @@
from collections.abc import Iterable
from operator import attrgetter
from typing import TYPE_CHECKING
from django.conf import settings
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 counter.models import AccountDump, Counter, Customer, Product, Selling
if TYPE_CHECKING:
from collections.abc import Iterable
class Command(BaseCommand):
"""Effectively dump the inactive users.
+2 -3
View File
@@ -1,14 +1,13 @@
from typing import TYPE_CHECKING, Callable
from django.http import HttpRequest, HttpResponse
from django.utils.functional import SimpleLazyObject
from core.models import User
from counter.models import Permanency
if TYPE_CHECKING:
from django.contrib.sessions.backends.base import SessionBase
from django.http import HttpRequest, HttpResponse
from core.models import User
SESSION_PERMANENCES_KEY = "permanence_ids"
@@ -1,14 +1,11 @@
# Generated by Django 4.2.17 on 2024-12-08 13:30
from operator import attrgetter
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Count
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def delete_duplicates(apps: StateApps, schema_editor):
"""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
from typing import TYPE_CHECKING
from django.db import migrations, models
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
from django.db.migrations.state import StateApps
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
from typing import TYPE_CHECKING
from django.db import migrations, models
from django.db.migrations.state import StateApps
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):
# 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
from operator import attrgetter
from typing import TYPE_CHECKING
import django.utils.timezone
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import OuterRef, Subquery
from counter.models import Selling
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def apply_product_history_dates(apps: StateApps, schema_editor):
"""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
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
import counter.fields
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
def migrate_prices(apps: StateApps, schema_editor):
Product = apps.get_model("counter", "Product")
+6 -11
View File
@@ -669,18 +669,13 @@ class Counter(models.Model):
"""Update the barman activity to prevent timeout."""
self.permanencies.filter(end=None).update(activity=timezone.now())
@cached_property
def can_refill(self) -> bool:
"""Show if the counter authorize the refilling with physic money.
Refills are authorized if a user having the required permission
is currently logged in.
"""
return self.type == "BAR" and (
User.objects.with_perm("counter.add_refilling")
.filter(id__in=[u.id for u in self.barmen_list])
.exists()
)
"""Show if the counter authorize the refilling with physic money."""
if self.type != "BAR":
return False
# at least one of the barmen is in the AE board
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
return any(ae.get_membership_for(barman) for barman in self.barmen_list)
def get_top_barmen(self) -> QuerySet:
"""Return a QuerySet querying the office hours stats of all the barmen of all time
@@ -28,23 +28,18 @@ export class ProductAjaxSelect extends AjaxSelect {
return [];
}
// In the context in which this method is called, `this` might be shadowed
// We need to call it explicitly from the class itself
private static getName(
item: SimpleProductSchema,
sanitize: typeof escape_html,
): string {
private getName(item: SimpleProductSchema, sanitize: typeof escape_html): string {
return item.code ? `${sanitize(item.code)} - ${sanitize(item.name)}` : item.name;
}
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
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>`;
}
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">
<form
hx-post="{{ action }}"
hx-swap="outerHTML"
hx-target="#student_card_form"
hx-disable="input[type='submit']"
>
<form hx-post="{{ action }}" hx-swap="outerHTML" hx-target="#student_card_form">
{% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<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 typing import TYPE_CHECKING
import freezegun
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 subscription.models import Subscription
if TYPE_CHECKING:
from collections.abc import Iterable
class TestAccountDump(TestCase):
@classmethod
+1 -4
View File
@@ -1,9 +1,9 @@
import json
from datetime import timedelta
from typing import TYPE_CHECKING
import pytest
from django.conf import settings
from django.test import Client
from django.urls import reverse
from django.utils.timezone import now
from django_celery_beat.models import ClockedSchedule
@@ -18,9 +18,6 @@ from counter.forms import (
)
from counter.models import Product, ProductType, ScheduledProductAction
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_edit_product(client: Client):
+2 -12
View File
@@ -15,7 +15,6 @@
from dataclasses import asdict, dataclass
from datetime import timedelta
from decimal import Decimal
from typing import TYPE_CHECKING
import pytest
from bs4 import BeautifulSoup
@@ -23,6 +22,7 @@ from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth.models import Permission, make_password
from django.contrib.messages import DEFAULT_LEVELS, get_messages
from django.http import HttpResponse
from django.shortcuts import resolve_url
from django.test import Client, TestCase
from django.urls import reverse
@@ -48,9 +48,6 @@ from counter.models import (
Selling,
)
if TYPE_CHECKING:
from django.http import HttpResponse
def set_age(user: User, age: int):
user.date_of_birth = localdate().replace(year=localdate().year - age)
@@ -108,13 +105,6 @@ class TestFullClickBase(TestCase):
class TestRefilling(TestFullClickBase):
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.board_admin.user_permissions.add(
Permission.objects.get(codename="add_refilling")
)
def login_in_bar(self, barmen: User | None = None):
used_barman = barmen if barmen is not None else self.board_admin
self.client.post(
@@ -154,7 +144,7 @@ class TestRefilling(TestFullClickBase):
assert self.updated_amount(self.customer) == 0
def test_refilling_no_refer_fail(self):
"""Check that the refill fails if the HTTP_REFERER header is missing"""
"""Check that the refill fails is the HTTP_REFERER header is missing"""
def refill():
return self.client.post(
+1 -4
View File
@@ -1,10 +1,10 @@
from datetime import date, datetime
from typing import TYPE_CHECKING
import pytest
from dateutil.relativedelta import relativedelta
from django.contrib.auth.models import Permission
from django.core.exceptions import ValidationError
from django.test import Client
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
@@ -16,9 +16,6 @@ from counter.baker_recipes import sale_recipe
from counter.forms import InvoiceCallForm
from counter.models import Customer, InvoiceCall, Selling
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
@pytest.mark.parametrize(
+1 -5
View File
@@ -1,7 +1,6 @@
from typing import TYPE_CHECKING
import pytest
from django.conf import settings
from django.test import Client
from django.urls import reverse
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 counter.models import ProductType
if TYPE_CHECKING:
from django.test import Client
@pytest.fixture
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 django.http import HttpRequest
from django.urls import resolve
if TYPE_CHECKING:
from django.http import HttpRequest
def is_logged_in_counter(request: HttpRequest) -> bool:
"""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.conf import settings
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.edit import BaseFormView, UpdateView
from core.models import User
from counter.forms import CashSummaryFormBase
from counter.models import (
CashRegisterSummary,
@@ -36,9 +35,6 @@ from counter.views.mixins import (
CounterTabsMixin,
)
if TYPE_CHECKING:
from core.models import User
class CashRegisterSummaryForm(forms.Form):
"""Provide the cash summary form."""
+5 -9
View File
@@ -14,7 +14,6 @@
#
import random
from collections import defaultdict
from typing import TYPE_CHECKING
from django.contrib import messages
from django.core.exceptions import PermissionDenied
@@ -23,11 +22,14 @@ from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.urls import reverse
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _
from django.views.generic import CreateView, FormView
from django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest
from core.auth.mixins import CanViewMixin
from core.models import User
from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BasketForm, RefillForm
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.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:
if counter.type != "BAR":
@@ -204,7 +200,7 @@ class CounterClick(
res["student_card_fragment"] = StudentCardFormFragment.as_fragment()(
self.request, customer=self.customer
)
if self.object.can_refill:
if self.object.can_refill():
res["refilling_fragment"] = RefillingCreateView.as_fragment()(
self.request, customer=self.customer, counter=self.object
)
@@ -250,7 +246,7 @@ class RefillingCreateView(FragmentMixin, CreateView):
if not (
request.barmen
and request.barmen.issubset(self.counter.barmen_list)
and self.counter.can_refill
and self.counter.can_refill()
):
raise PermissionDenied
+1 -4
View File
@@ -13,7 +13,6 @@
#
#
from datetime import timedelta
from typing import TYPE_CHECKING
from django.conf import settings
from django.core.exceptions import PermissionDenied
@@ -22,6 +21,7 @@ from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import redirect
from django.urls import reverse
from django.utils import timezone
from django.utils.safestring import SafeString
from django.views.decorators.http import require_POST
from django.views.generic import DetailView
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.views.mixins import CounterTabsMixin
if TYPE_CHECKING:
from django.utils.safestring import SafeString
class CounterLoginFragment(FragmentMixin, SingleObjectMixin, FormView):
model = Counter
+1 -5
View File
@@ -13,12 +13,11 @@
#
#
from typing import TYPE_CHECKING
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _
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.utils import is_logged_in_counter
if TYPE_CHECKING:
from django.utils.safestring import SafeString
class StudentCardDeleteView(DeleteView):
"""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
[Site officiel](https://four.htmx.org/)
[Site officiel](https://htmx.org/)
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 à
-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
from datetime import datetime, timezone
from typing import TYPE_CHECKING
import freezegun
import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.http import HttpResponse
from django.test import TestCase
from django.test.client import Client
from django.urls import reverse
from django.utils.timezone import localdate, now
from model_bakery import baker
@@ -30,10 +31,6 @@ from counter.models import (
from counter.tests.test_counter import BasketItem
from eboutic.models import Basket
if TYPE_CHECKING:
from django.http import HttpResponse
from django.test.client import Client
@pytest.mark.django_db
def test_get_eboutic():
+2 -4
View File
@@ -1,6 +1,7 @@
from typing import TYPE_CHECKING, Callable
from typing import Callable
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
@@ -9,9 +10,6 @@ from core.baker_recipes import subscriber_user
from core.models import User
from counter.models import BillingInfo
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
class TestBillingInfo:
+1 -4
View File
@@ -1,7 +1,6 @@
from datetime import timedelta
from itertools import groupby, islice
from operator import attrgetter
from typing import TYPE_CHECKING
from django import forms
from django.conf import settings
@@ -14,6 +13,7 @@ from django.utils.translation import gettext_lazy as _
from club.forms import ClubRoleChoiceField
from club.models import ClubRole, Membership
from club.widgets.ajax_select import AutoCompleteSelectMultipleClub
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
@@ -23,9 +23,6 @@ from core.views.widgets.ajax_select import (
from core.views.widgets.markdown import MarkdownInput
from election.models import Candidature, Election, ElectionList, Role
if TYPE_CHECKING:
from core.models import User
class LimitedCheckboxField(forms.ModelMultipleChoiceField):
"""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-swap="outerHTML"
hx-target="#apply-election-result-fragment"
hx-disable="find input[type='submit']"
hx-disabled-elt="find input[type='submit']"
>
{% csrf_token %}
{{ form }}
+1 -5
View File
@@ -13,19 +13,15 @@
#
#
from typing import TYPE_CHECKING
import pytest
from django.conf import settings
from django.test import Client
from django.urls import reverse
from pytest_django.asserts import assertRedirects
from core.models import User
from forum.models import Forum, ForumMessage, ForumTopic
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
class TestTopicCreation:
+1 -62
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-09 07:21+0200\n"
"POT-Creation-Date: 2026-08-21 14:10+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@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 "
"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
msgid "club role"
msgstr "rôle de club"
@@ -497,14 +485,6 @@ msgstr "Du"
msgid "To"
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
msgid ""
"Roles give rights on the club. Higher roles grant more rights, and the "
@@ -2069,10 +2049,6 @@ msgstr "403, Non autorisé"
msgid "404, Not Found"
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
msgid "500, Server Error"
msgstr "500, Erreur Serveur"
@@ -5113,10 +5089,6 @@ msgstr "signalant"
msgid "A guide of courses available at 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
#, python-format
msgid "%(display_name)s"
@@ -5786,18 +5758,6 @@ msgstr "fin de la cotisation"
msgid "location"
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
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"
@@ -5852,27 +5812,6 @@ msgstr "les groupes pouvant créer des cotisations"
msgid "Timetable generator"
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
msgid "Generate"
msgstr "Générer"
+9 -3
View File
@@ -7,7 +7,7 @@
msgid ""
msgstr ""
"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"
"Last-Translator: Sli <antoine@bartuccio.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 "
"yourself out of this page. Do you want to continue ? "
msgstr ""
"Vous vous apprêtez à retirer votre propre rôle de la présidence. Vous "
"risquez de perdre l'accès à cette page. Voulez-vous continuer ?"
"Vous vous apprêtez à retirer votre propre rôle de la présidence. Vous risquez "
"de perdre l'accès à cette page. Voulez-vous continuer ?"
#: com/static/bundled/com/components/ics-calendar-index.ts
msgid "More info"
@@ -279,3 +279,9 @@ msgstr "Il n'a pas été possible de modérer l'image"
msgid "Couldn't delete picture"
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:
- Développement: tutorial/api/dev.md
- Connexion à l'API: tutorial/api/connect.md
- Markdown AE: tutorial/markdown.md
- Etransactions: tutorial/etransaction.md
- How-to:
- L'ORM de Django: howto/querysets.md
+5 -17
View File
@@ -9,7 +9,6 @@
"version": "3",
"license": "GPL-3.0-only",
"dependencies": {
"@ae_utbm/aemark": "^0.1.3",
"@alpinejs/sort": "^3.16.2",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.8.0",
@@ -31,7 +30,7 @@
"easymde": "^2.21.0",
"glob": "^13.0.6",
"html2canvas": "^1.4.1",
"htmx.org": "^4.0.0",
"htmx.org": "^2.0.10",
"js-cookie": "^3.0.8",
"lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1",
@@ -57,11 +56,6 @@
"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": {
"version": "3.16.2",
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.16.2.tgz",
@@ -3807,16 +3801,10 @@
}
},
"node_modules/htmx.org": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-4.0.0.tgz",
"integrity": "sha512-T/171FUY93Kdfp8t+DnHdk45QvKRiBhVhhrwSzrXgUi4pHKvhp77dUA/qg8FAjsFWPIHNbmUuIdCrcVHuiZWng==",
"license": "BSD-0-Clause",
"workspaces": [
"ext/*"
],
"bin": {
"upgrade-check": "dist/scripts/upgrade-check.js"
}
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz",
"integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==",
"license": "0BSD"
},
"node_modules/ical.js": {
"version": "1.5.0",
+2 -3
View File
@@ -34,13 +34,12 @@
"@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6",
"@types/node": "^26.2.0",
"@typescript/native": "npm:typescript@^7.0.2",
"rollup-plugin-visualizer": "^7.1.1",
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vite": "^8.2.2"
},
"dependencies": {
"@ae_utbm/aemark": "^0.1.3",
"@alpinejs/sort": "^3.16.2",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.8.0",
@@ -62,7 +61,7 @@
"easymde": "^2.21.0",
"glob": "^13.0.6",
"html2canvas": "^1.4.1",
"htmx.org": "^4.0.0",
"htmx.org": "^2.0.10",
"js-cookie": "^3.0.8",
"lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1",
+5 -2
View File
@@ -29,11 +29,14 @@ class UeController(ControllerBase):
response=UeSchema,
)
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."""
with UtbmApiClient() as client:
res = client.find_ue(code, year)
res = client.find_ue(lang, code, year)
if res is None:
raise NotFound
return res
+4 -13
View File
@@ -26,7 +26,6 @@ from django.conf import settings
from django.core import validators
from django.db import models
from django.db.models import Exists, OuterRef
from django.db.models.query_utils import Q
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
@@ -92,6 +91,7 @@ class UE(models.Model):
# All texts about the UE
title = models.CharField(_("title"), max_length=300)
manager = models.CharField(_("ue manager"), max_length=300)
objectives = models.TextField(_("objectives"))
program = models.TextField(_("program"))
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.
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()
@cached_property
@@ -158,14 +156,7 @@ class UECommentQuerySet(models.QuerySet):
# so he can view non-moderated comments
return self
if user.has_perm("pedagogy.view_uecomment"):
return self.filter(
Q(author=user)
| ~Exists(
UECommentReport.objects.filter(
~Q(reporter=user), comment=OuterRef("pk")
)
)
).distinct()
return self.filter(reports=None)
return self.filter(author=user)
def annotate_is_reported(self) -> Self:
@@ -267,7 +258,7 @@ class UEResult(models.Model):
class UECommentReport(models.Model):
"""Report an inappropriate comment."""
"""Report an inapropriate comment."""
comment = models.ForeignKey(
UEComment,
-9
View File
@@ -153,7 +153,6 @@ class UeFilterSchema(FilterSchema):
set[Literal["CS", "TM", "EC", "OM", "QC"]] | None,
FilterLookup("credit_type__in"),
] = None
is_open: bool | None = None
language: str = "FR"
department: Annotated[set[str] | None, FilterLookup("department__in")] = None
@@ -188,11 +187,3 @@ class UeFilterSchema(FilterSchema):
return Q()
value.add("AUTUMN_AND_SPRING")
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);
},
}));
});
+88 -16
View File
@@ -64,24 +64,100 @@ $pedagogy-white-text: #f0f0f0;
}
#search_form {
.search-form-container {
display: grid;
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";
}
.radio-guide fieldset {
.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-bottom: 0;
color: white;
}
label {
.radio-guide label {
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;
@include shadow;
}
.radio-guide input[type="checkbox"]:checked+label {
background-color: $pedagogy-orange;
}
.radio-guide label:hover {
background-color: $pedagogy-hover-blue;
}
}
@@ -129,14 +205,14 @@ $pedagogy-white-text: #f0f0f0;
grid-area: hours-the;
}
.leave-comment-not-allowed {
#leave_comment_not_allowed {
p {
text-align: center;
color: red;
}
}
.leave-comment {
#leave_comment {
.leave-comment-grid-container {
display: grid;
grid-template-columns: 270px auto;
@@ -169,6 +245,10 @@ $pedagogy-white-text: #f0f0f0;
.input-stars {
margin-top: 20px;
}
input[type="submit"] {
float: right;
}
}
.ue-details-container {
@@ -330,9 +410,8 @@ $pedagogy-white-text: #f0f0f0;
padding-left: 10px;
}
.action {
.actions {
float: right;
margin-top: 0;
}
}
@@ -400,7 +479,6 @@ $pedagogy-white-text: #f0f0f0;
a {
color: $pedagogy-white-text;
cursor: pointer;
}
a:hover {
@@ -443,9 +521,3 @@ details.accordion>.accordion-content {
border-color: $pedagogy-orange;
border-right: none;
}
.buttons {
display: flex;
justify-content: flex-end;
align-items: center;
}
@@ -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,71 +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
hx-post="{{ action }}"
hx-target="closest .leave-comment"
hx-swap="outerHTML"
hx-disabled-elt="find input[type='submit']"
>
{% 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>
<div class="buttons">
<input type="submit" value="{% trans %}Comment{% endtrans %}" />
</div>
</form>
{% if form.is_creation %}
</div>
</details>
{% endif %}
<br>
</div>
@@ -1,91 +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 %}
<div id="comment-{{ 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-{{ 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") %}
<button
class="btn btn-orange action"
hx-get="{{ url('pedagogy:comment_update', comment_id=comment.id) }}"
hx-swap="outerHTML"
hx-target="#comment-{{ comment.id }}"
>
<i class="fa fa-pencil"></i> {% trans %}Edit{% endtrans %}
</button>
{% endif %}
{% if comment.author_id == user.id or user.has_perm("pedagogy.delete_comment") %}
<form class="action"
hx-post="{{ url('pedagogy:comment_delete', comment_id=comment.id) }}"
hx-confirm='{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}'
hx-swap="outerHTML"
hx-target="#comment-{{ comment.id }}"
>
{% csrf_token %}
<button class="btn btn-red action">
<i class="fa fa-trash-can"></i> {% trans %}Delete{% endtrans %}
</button>
</form>
{% endif %}
</div>
<div class="comment-end-bar">
<div class="report">
<p>
<a
hx-get="{{ url('pedagogy:comment_report', comment_id=comment.id) }}"
hx-swap="outerHTML"
hx-target="#comment-{{ 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 %}
</section>
@@ -1,10 +0,0 @@
{% if object.has_user_already_commented(user) %}
<div class="leave-comment-not-allowed">
<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>
</div>
<br>
{% endif %}
{% if not object.has_user_already_commented(user) and user.has_perm("pedagogy.add_uecomment") %}
{{ add_comment_form }}
{% endif %}
@@ -1,27 +0,0 @@
<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>
+29 -34
View File
@@ -14,7 +14,7 @@
{% endblock %}
{% 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 %}
{% block head %}
@@ -24,32 +24,30 @@
{% block content %}
{% if user.has_perm("pedagogy.add_ue") %}
<div class="row gap">
<a href="{{ url('pedagogy:ue_create') }}" class="btn btn-blue"><i class="fa fa-plus"></i>{% trans %}Create UE{% endtrans %}</a>
<a href="{{ url('pedagogy:moderation') }}" class="btn btn-grey">{% trans %}Moderate comments{% endtrans %}</a>
<div class="action-bar">
<p>
<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>
<br/>
{% endif %}
<div class="pedagogy" x-data="ue_search" x-cloak>
<form id="search_form" class="">
<form id="search_form">
<div class="search-form-container">
<fieldset>
<label for="search_input">{% trans %}Search UE{% endtrans %}</label>
<div class="search-bar">
<input
id="search_input"
class="search-bar-input"
type="search"
type="text"
name="search"
placeholder=""
x-model.debounce.500ms="search"
/>
</fieldset>
<fieldset>
<input type="checkbox" class="switch" x-model="hideClosedUes" id="hide-closed-ues" name="hide-closed-ues">
<label for="hide-closed-ues">{% trans %}Hide closed UEs{% endtrans %}</label>
</fieldset>
<div class="row gap-3x margin-bottom radio-guide">
<fieldset>
</div>
<div class="radio-department">
<div class="radio-guide">
{% set departments = [
("EDIM", "EDIM"), ("ENERGIE", "EE"), ("IMSI", "IMSI"),
("INFO", "GI"), ("GMC", "MC"), ("HUMA", "HUMA"), ("TC", "TC")
@@ -58,16 +56,16 @@
<input
type="checkbox"
name="department"
id="radio_{{ real_name }}"
id="radio{{ real_name }}"
value="{{ real_name }}"
x-model="department"
/>
<label for="radio_{{ real_name }}" class="btn btn-blue">
{% trans %}{{ display_name }}{% endtrans %}
</label>
<label for="radio{{ real_name }}">{% trans %}{{ display_name }}{% endtrans %}</label>
{% endfor %}
</fieldset>
<fieldset>
</div>
</div>
<div class="radio-credit-type">
<div class="radio-guide">
{% for credit_type in ["CS", "TM", "EC", "QC", "OM"] %}
<input
type="checkbox"
@@ -76,21 +74,18 @@
value="{{ credit_type }}"
x-model="credit_type"
/>
<label for="radio{{ credit_type }}" class="btn btn-blue">
{% trans %}{{ credit_type }}{% endtrans %}
</label>
<label for="radio{{ credit_type }}">{% trans %}{{ credit_type }}{% endtrans %}</label>
{% endfor %}
</fieldset>
<fieldset>
</div>
</div>
<div class="radio-semester">
<div class="radio-guide">
<input type="checkbox" name="semester" id="radioAUTUMN" value="AUTUMN" x-model="semester"/>
<label for="radioAUTUMN" class="btn btn-no-text btn-blue">
<i class="fa fa-leaf"></i>
</label>
<label for="radioAUTUMN"><i class="fa fa-leaf"></i></label>
<input type="checkbox" name="semester" id="radioSPRING" value="SPRING" x-model="semester"/>
<label for="radioSPRING" class="btn btn-no-text btn-blue">
<i class="fa-regular fa-sun"></i>
</label>
</fieldset>
<label for="radioSPRING"><i class="fa-regular fa-sun"></i></label>
</div>
</div>
</div>
</form>
+160 -31
View File
@@ -1,32 +1,16 @@
{% extends "core/base.jinja" %}
{% from "core/macros.jinja" import user_profile_link %}
{% from "pedagogy/macros.jinja" import display_star %}
{% if is_fragment %}
<hx-partial id="ue-grade">
{% include "pedagogy/fragments/ue_details/grade.jinja" %}
</hx-partial>
<hx-partial id="comment-form">
{% include "pedagogy/fragments/ue_details/form.jinja" %}
</hx-partial>
<hx-partial id="comments" hx-swap="innerMorph">
{% include "pedagogy/fragments/ue_details/comments.jinja" %}
</hx-partial>
{% else %}
{% extends "core/base.jinja" %}
{% block additional_css %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('pedagogy/css/pedagogy.scss') }}">
{% endblock %}
{% endblock %}
{% block title %}
{% block title %}
{% trans %}UE Details{% endtrans %}
{% endblock %}
{% block content %}
{% endblock %}
{% block content %}
<div class="pedagogy">
<div id="ue_detail">
<button onclick='(function(){
@@ -71,21 +55,166 @@
<br>
<div id="ue-grade">
{% include "pedagogy/fragments/ue_details/grade.jinja" %}
<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) %}
<div id="leave_comment_not_allowed">
<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>
</div>
{% elif user.has_perm("pedagogy.add_uecomment") %}
<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 }}
<div id="comment-form">
{% include "pedagogy/fragments/ue_details/form.jinja" %}
{{ 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 id="comments">
{% include "pedagogy/fragments/ue_details/comments.jinja" %}
<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 %}
<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>
{% endblock %}
{% endif %}
</div>
{% endblock %}
+46 -42
View File
@@ -1,6 +1,8 @@
import json
from django.conf import settings
from django.core.management import call_command
from django.test import TestCase
from django.test.testcases import call_command
from django.urls import reverse
from model_bakery import baker
from model_bakery.recipe import Recipe
@@ -15,7 +17,7 @@ class TestUESearch(TestCase):
@classmethod
def setUpTestData(cls):
cls.root = baker.make(User, is_superuser=True)
cls.root = User.objects.get(username="root")
cls.url = reverse("api:fetch_ues")
ue_recipe = Recipe(UE, author=cls.root)
ues = [
@@ -90,10 +92,10 @@ class TestUESearch(TestCase):
def test_format(self):
"""Test that the return data format is correct"""
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")
assert res.status_code == 200
assert res.json() == {
assert json.loads(res.content) == {
"count": 1,
"next": None,
"previous": None,
@@ -119,77 +121,79 @@ class TestUESearch(TestCase):
def test_search_by_text(self):
self.client.force_login(self.root)
for query, expected in (
# UE code search case-insensitive
# UE code search case insensitive
("m", {"MT01", "MT10"}),
("M", {"MT01", "MT10"}),
("mt", {"MT01", "MT10"}),
("MT", {"MT01", "MT10"}),
("algèbre", {"MT01"}), # Title search case-insensitive
("algèbre", {"MT01"}), # Title search case insensitive
# Manager search
("moss", {"TNEV"}),
("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 {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):
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
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"]
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
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"}
def test_search_by_semester(self):
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
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"}
def test_search_multiple_filters(self):
self.client.force_login(self.root)
res = self.client.get(
self.url,
query_params={
"semester": "AUTUMN",
"credit_type": "CS",
"department": "TC",
},
self.url + "?semester=AUTUMN&credit_type=CS&department=TC"
)
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"}
def test_search_fails(self):
self.client.force_login(self.root)
res = self.client.get(
self.url, query_params={"search": "DA", "credit_type": "CS"}
)
res = self.client.get(self.url + "?credit_type=CS&search=DA")
assert res.status_code == 200
assert res.json()["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")
assert json.loads(res.content)["results"] == []
def test_search_pa00_fail(self):
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})
assert res.status_code == 200
ids = {ue["id"] for ue in res.json()["results"]}
assert ids == set(
UE.objects.filter(semester="CLOSED").values_list("id", flat=True)
)
# Search with first letter of UE code
response = self.client.get(reverse("pedagogy:guide"), {"search": "I"})
self.assertNotContains(response, text="PA00")
res = self.client.get(self.url, query_params={"is_open": True})
assert res.status_code == 200
ids = {ue["id"] for ue in res.json()["results"]}
assert ids == set(
UE.objects.exclude(semester="CLOSED").values_list("id", flat=True)
)
# Search with UE manager
response = self.client.get(reverse("pedagogy:guide"), {"search": "GILLES"})
self.assertNotContains(response, text="PA00")
# Search with department
response = self.client.get(reverse("pedagogy:guide"), {"department": "TC"})
self.assertNotContains(response, text="PA00")
# Search with semester
response = self.client.get(reverse("pedagogy:guide"), {"semester": "CLOSED"})
self.assertNotContains(response, text="PA00")
# Search with language
response = self.client.get(reverse("pedagogy:guide"), {"language": "EN"})
self.assertNotContains(response, text="PA00")
# Search with credit type
response = self.client.get(reverse("pedagogy:guide"), {"credit_type": "TM"})
self.assertNotContains(response, text="PA00")
+36 -168
View File
@@ -25,9 +25,9 @@ from typing import Callable
import pytest
from django.conf import settings
from django.contrib.auth.models import Permission
from django.db.models import Max
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from model_bakery import baker
from pytest_django.asserts import assertRedirects
@@ -278,17 +278,13 @@ class TestUEUpdate(TestCase):
# UEComment class tests
def create_ue_comment_template(
user_id: int,
ue: int | str = "PA00",
exclude_list: list[str] | None = None,
):
def create_ue_comment_template(user_id, ue_code="PA00", exclude_list=None):
"""Factory to help UEComment creation/update in post requests."""
if exclude_list is None:
exclude_list = []
comment = {
"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_utility": 4,
"grade_interest": 4,
@@ -301,111 +297,12 @@ def create_ue_comment_template(
return comment
class TestUVCommentDisplay(TestCase):
@classmethod
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,
)
class TestUVCommentCreationAndDisplay(TestCase):
"""Test UEComment creation and its display.
def test_access_succses(self):
self.client.force_login(self.admin)
assert self.client.get(self.ue_url).status_code == 200
Display and creation are the same view.
"""
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
def setUpTestData(cls):
cls.bibou = User.objects.get(username="root")
@@ -414,14 +311,11 @@ class TestUECommentCreation(TestCase):
cls.guy = User.objects.get(username="guy")
cls.ue = UE.objects.get(code="PA00")
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):
self.client.force_login(self.bibou)
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)
response = self.client.get(self.ue_url)
@@ -430,7 +324,7 @@ class TestUECommentCreation(TestCase):
def test_create_ue_comment_pedagogy_admin_success(self):
self.client.force_login(self.tutu)
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)
response = self.client.get(self.ue_url)
@@ -439,7 +333,7 @@ class TestUECommentCreation(TestCase):
def test_create_ue_comment_subscriber_success(self):
self.client.force_login(self.sli)
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)
response = self.client.get(self.ue_url)
@@ -448,7 +342,7 @@ class TestUECommentCreation(TestCase):
def test_create_ue_empty_comment_fail(self):
self.client.force_login(self.tutu)
response = self.client.post(
self.comment_create_url,
self.ue_url,
{
"author": self.tutu.id,
"ue": UE.objects.get(code="PA00").id,
@@ -466,40 +360,24 @@ class TestUECommentCreation(TestCase):
def test_create_ue_comment_unauthorized_fail(self):
nb_comments = self.ue.comments.count()
# Test with anonymous user
response = self.client.post(
self.comment_create_url, create_ue_comment_template(0)
)
assertRedirects(
response, reverse("core:login", query={"next": self.comment_create_url})
)
response = self.client.post(self.ue_url, create_ue_comment_template(0))
assertRedirects(response, reverse("core:login", query={"next": self.ue_url}))
# Test with non subscribed user
self.client.force_login(self.guy)
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
# Check that no comment has been created
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):
nb_comments = self.ue.comments.count()
self.client.force_login(self.bibou)
response = self.client.post(
self.comment_create_url,
self.ue_url,
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
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)
# Create a first comment
self.client.force_login(self.bibou)
self.client.post(
self.comment_create_url, create_ue_comment_template(self.bibou.id)
)
self.client.post(self.ue_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)
# Create the second comment
comment = create_ue_comment_template(self.bibou.id)
comment["comment"] = "Twice"
response = self.client.post(self.comment_create_url, comment)
assert response.status_code == 403
response = self.client.post(self.ue_url, comment)
assert response.status_code == 200
assert UEComment.objects.filter(comment__contains="Superbe UE").exists()
assert not UEComment.objects.filter(comment__contains="Twice").exists()
def test_create_ue_comment_wrong_args(self):
self.client.force_login(self.bibou)
self.assertContains(
response,
_(
"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
response = self.client.post(
self.comment_create_url,
create_ue_comment_template(self.bibou.id, exclude_list=["ue"]),
self.client.post(
self.ue_url, create_ue_comment_template(self.bibou.id, exclude_list=["ue"])
)
assert response.status_code == 200
assert not self.ue.has_user_already_commented(self.bibou)
response = self.client.post(
self.comment_create_url,
self.client.post(
self.ue_url,
create_ue_comment_template(self.bibou.id, exclude_list=["author"]),
)
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."""
@classmethod
@@ -594,7 +462,7 @@ class TestUECommentDelete(TestCase):
assert UEComment.objects.filter(id=self.comment.id).exists()
class TestUECommentUpdate(TestCase):
class TestUVCommentUpdate(TestCase):
"""Test UEComment update rights."""
@classmethod
@@ -668,7 +536,7 @@ class TestUECommentUpdate(TestCase):
self.assertEqual(self.comment.author, self.krophil)
class TestUEModerationForm(TestCase):
class TestUVModerationForm(TestCase):
"""Assert access rights and if the form works well."""
@classmethod
@@ -882,7 +750,7 @@ class TestUEModerationForm(TestCase):
assert UEComment.objects.filter(id=self.comment_2.id).exists()
class TestUECommentReportCreate(TestCase):
class TestUVCommentReportCreate(TestCase):
"""Test report creation view.
Assert access rights and if you can create with it.
+2 -8
View File
@@ -24,13 +24,12 @@
from django.urls import path
from pedagogy.views import (
UECommentCreateView,
UECommentDeleteView,
UECommentReportCreateView,
UECommentUpdateView,
UECreateView,
UEDeleteView,
UEDetailView,
UEDetailFormView,
UEGuideView,
UEModerationFormView,
UEUpdateView,
@@ -39,12 +38,7 @@ from pedagogy.views import (
urlpatterns = [
# Urls displaying the actual application for visitors
path("", UEGuideView.as_view(), name="guide"),
path("ue/<int:ue_id>/", UEDetailView.as_view(), name="ue_detail"),
path(
"ue/<int:ue_id>/comment",
UECommentCreateView.as_view(),
name="comment_create",
),
path("ue/<int:ue_id>/", UEDetailFormView.as_view(), name="ue_detail"),
path(
"comment/<int:comment_id>/edit/",
UECommentUpdateView.as_view(),
+33 -65
View File
@@ -16,7 +16,7 @@
# details.
#
# 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.
#
#
@@ -26,11 +26,9 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Exists, OuterRef
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
from django.utils.functional import cached_property
from django.views.generic import (
CreateView,
DeleteView,
DetailView,
FormView,
TemplateView,
UpdateView,
@@ -38,7 +36,7 @@ from django.views.generic import (
from core.auth.mixins import PermissionOrAuthorRequiredMixin
from core.models import Notification, User
from core.views.mixins import AllowFragment, FragmentMixin, UseFragmentsMixin
from core.views import DetailFormView
from pedagogy.forms import (
UECommentForm,
UECommentModerationForm,
@@ -48,56 +46,37 @@ from pedagogy.forms import (
from pedagogy.models import UE, UEComment, UECommentReport
class UECommentCreateView(PermissionRequiredMixin, FragmentMixin, CreateView):
model = UEComment
template_name = "pedagogy/fragments/ue_comment_form.jinja"
form_class = UECommentForm
permission_required = "pedagogy.add_uecomment"
object = None # Avoid initialisation bug with FragmentMixin
class UEDetailFormView(PermissionRequiredMixin, DetailFormView):
"""Display every comment of an UE and detailed infos about it.
@cached_property
def ue(self):
return get_object_or_404(UE, id=self.kwargs.get("ue_id"))
Allow to comment the UE.
"""
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):
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 super().has_permission()
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["author_id"] = self.request.user.id
kwargs["ue_id"] = self.ue.id
kwargs["ue_id"] = self.object.id
kwargs["is_creation"] = True
return kwargs
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"action": reverse("pedagogy:comment_create", kwargs={"ue_id": self.ue.id}),
"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"
template_name = "pedagogy/ue_detail.jinja"
permission_required = "pedagogy.view_ue"
fragments = {
"add_comment_form": UECommentCreateView,
}
def get_fragment_data(self):
return {
"add_comment_form": {"ue_id": self.object.id},
}
def form_valid(self, form):
form.save()
return super().form_valid(form)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
@@ -109,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."""
model = UEComment
form_class = UECommentForm
pk_url_kwarg = "comment_id"
template_name = "pedagogy/fragments/ue_comment_form.jinja"
template_name = "core/edit.jinja"
permission_required = "pedagogy.change_uecomment"
author_field = "author"
@@ -127,18 +111,11 @@ class UECommentUpdateView(PermissionOrAuthorRequiredMixin, AllowFragment, Update
kwargs["is_creation"] = False
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):
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."""
model = UEComment
@@ -158,34 +135,24 @@ class UEGuideView(PermissionRequiredMixin, TemplateView):
permission_required = "pedagogy.view_ue"
class UECommentReportCreateView(PermissionRequiredMixin, AllowFragment, CreateView):
class UECommentReportCreateView(PermissionRequiredMixin, CreateView):
"""Create a new report for an inappropriate comment."""
model = UECommentReport
form_class = UECommentReportForm
template_name = "pedagogy/fragments/comment_report.jinja"
template_name = "core/edit.jinja"
permission_required = "pedagogy.add_uecommentreport"
def dispatch(self, request, *args, **kwargs):
self.ue_comment = get_object_or_404(UEComment, pk=kwargs["comment_id"])
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):
kwargs = super().get_form_kwargs()
kwargs["reporter_id"] = self.request.user.id
kwargs["comment_id"] = self.ue_comment.id
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):
resp = super().form_valid(form)
# Send a message to moderation admins
@@ -201,10 +168,11 @@ class UECommentReportCreateView(PermissionRequiredMixin, AllowFragment, CreateVi
url=reverse("pedagogy:moderation"),
type="PEDAGOGY_MODERATION",
)
return resp
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):

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