diff --git a/accounting/tests.py b/accounting/tests.py index c66558e0..1140acc7 100644 --- a/accounting/tests.py +++ b/accounting/tests.py @@ -216,7 +216,7 @@ class TestOperation(TestCase): self.journal.operations.filter(target_label="Le fantome du jour").exists() ) - def test__operation_simple_accounting(self): + def test_operation_simple_accounting(self): sat = SimplifiedAccountingType.objects.all().first() response = self.client.post( reverse("accounting:op_new", args=[self.journal.id]), @@ -237,15 +237,14 @@ class TestOperation(TestCase): "done": False, }, ) - self.assertFalse(response.status_code == 403) - self.assertTrue(self.journal.operations.filter(amount=23).exists()) + assert response.status_code != 403 + assert self.journal.operations.filter(amount=23).exists() response_get = self.client.get( reverse("accounting:journal_details", args=[self.journal.id]) ) - self.assertTrue( - "Le fantome de l'aurore" in str(response_get.content) - ) - self.assertTrue( + assert "Le fantome de l'aurore" in str(response_get.content) + + assert ( self.journal.operations.filter(amount=23) .values("accounting_type") .first()["accounting_type"] diff --git a/accounting/views.py b/accounting/views.py index 928dc009..ce0ae45b 100644 --- a/accounting/views.py +++ b/accounting/views.py @@ -215,17 +215,14 @@ class JournalTabsMixin(TabedViewMixin): return _("Journal") def get_list_of_tabs(self): - tab_list = [] - tab_list.append( + return [ { "url": reverse( "accounting:journal_details", kwargs={"j_id": self.object.id} ), "slug": "journal", "name": _("Journal"), - } - ) - tab_list.append( + }, { "url": reverse( "accounting:journal_nature_statement", @@ -233,9 +230,7 @@ class JournalTabsMixin(TabedViewMixin): ), "slug": "nature_statement", "name": _("Statement by nature"), - } - ) - tab_list.append( + }, { "url": reverse( "accounting:journal_person_statement", @@ -243,9 +238,7 @@ class JournalTabsMixin(TabedViewMixin): ), "slug": "person_statement", "name": _("Statement by person"), - } - ) - tab_list.append( + }, { "url": reverse( "accounting:journal_accounting_statement", @@ -253,9 +246,8 @@ class JournalTabsMixin(TabedViewMixin): ), "slug": "accounting_statement", "name": _("Accounting statement"), - } - ) - return tab_list + }, + ] class JournalCreateView(CanCreateMixin, CreateView): diff --git a/club/migrations/0010_auto_20170912_2028.py b/club/migrations/0010_auto_20170912_2028.py index d6e28063..aa49fc4b 100644 --- a/club/migrations/0010_auto_20170912_2028.py +++ b/club/migrations/0010_auto_20170912_2028.py @@ -3,19 +3,6 @@ from __future__ import unicode_literals import django.db.models.deletion from django.db import migrations, models -from club.models import Club -from core.operations import PsqlRunOnly - - -def generate_club_pages(apps, schema_editor): - def recursive_generate_club_page(club): - club.make_page() - for child in Club.objects.filter(parent=club).all(): - recursive_generate_club_page(child) - - for club in Club.objects.filter(parent=None).all(): - recursive_generate_club_page(club) - class Migration(migrations.Migration): dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")] @@ -48,11 +35,4 @@ class Migration(migrations.Migration): null=True, ), ), - PsqlRunOnly( - "SET CONSTRAINTS ALL IMMEDIATE", reverse_sql=migrations.RunSQL.noop - ), - migrations.RunPython(generate_club_pages), - PsqlRunOnly( - migrations.RunSQL.noop, reverse_sql="SET CONSTRAINTS ALL IMMEDIATE" - ), ] diff --git a/club/models.py b/club/models.py index 573fd176..5300057d 100644 --- a/club/models.py +++ b/club/models.py @@ -31,14 +31,14 @@ from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import RegexValidator, validate_email from django.db import models, transaction -from django.db.models import Q +from django.db.models import Exists, OuterRef, Q from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property from django.utils.timezone import localdate from django.utils.translation import gettext_lazy as _ -from core.models import Group, MetaGroup, Notification, Page, RealGroup, SithFile, User +from core.models import Group, MetaGroup, Notification, Page, SithFile, User # Create your models here. @@ -438,19 +438,18 @@ class Mailing(models.Model): def save(self, *args, **kwargs): if not self.is_moderated: - for user in ( - RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID) - .first() - .users.all() + unread_notif_subquery = Notification.objects.filter( + user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False + ) + for user in User.objects.filter( + ~Exists(unread_notif_subquery), + groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID], ): - if not user.notifications.filter( - type="MAILING_MODERATION", viewed=False - ).exists(): - Notification( - user=user, - url=reverse("com:mailing_admin"), - type="MAILING_MODERATION", - ).save(*args, **kwargs) + Notification( + user=user, + url=reverse("com:mailing_admin"), + type="MAILING_MODERATION", + ).save(*args, **kwargs) super().save(*args, **kwargs) def clean(self): diff --git a/com/migrations/0007_alter_news_club_alter_news_content_and_more.py b/com/migrations/0007_alter_news_club_alter_news_content_and_more.py new file mode 100644 index 00000000..99145cb7 --- /dev/null +++ b/com/migrations/0007_alter_news_club_alter_news_content_and_more.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.17 on 2024-12-16 14:51 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("club", "0011_auto_20180426_2013"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("com", "0006_remove_sith_index_page"), + ] + + operations = [ + migrations.AlterField( + model_name="news", + name="club", + field=models.ForeignKey( + help_text="The club which organizes the event.", + on_delete=django.db.models.deletion.CASCADE, + related_name="news", + to="club.club", + verbose_name="club", + ), + ), + migrations.AlterField( + model_name="news", + name="content", + field=models.TextField( + blank=True, + default="", + help_text="A more detailed and exhaustive description of the event.", + verbose_name="content", + ), + ), + migrations.AlterField( + model_name="news", + name="moderator", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="moderated_news", + to=settings.AUTH_USER_MODEL, + verbose_name="moderator", + ), + ), + migrations.AlterField( + model_name="news", + name="summary", + field=models.TextField( + help_text="A description of the event (what is the activity ? is there an associated clic ? is there a inscription form ?)", + verbose_name="summary", + ), + ), + ] diff --git a/com/models.py b/com/models.py index 5c1466ca..f3076174 100644 --- a/com/models.py +++ b/com/models.py @@ -34,7 +34,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from club.models import Club -from core.models import Notification, Preferences, RealGroup, User +from core.models import Notification, Preferences, User class Sith(models.Model): @@ -62,16 +62,31 @@ NEWS_TYPES = [ class News(models.Model): - """The news class.""" + """News about club events.""" title = models.CharField(_("title"), max_length=64) - summary = models.TextField(_("summary")) - content = models.TextField(_("content")) + summary = models.TextField( + _("summary"), + help_text=_( + "A description of the event (what is the activity ? " + "is there an associated clic ? is there a inscription form ?)" + ), + ) + content = models.TextField( + _("content"), + blank=True, + default="", + help_text=_("A more detailed and exhaustive description of the event."), + ) type = models.CharField( _("type"), max_length=16, choices=NEWS_TYPES, default="EVENT" ) club = models.ForeignKey( - Club, related_name="news", verbose_name=_("club"), on_delete=models.CASCADE + Club, + related_name="news", + verbose_name=_("club"), + on_delete=models.CASCADE, + help_text=_("The club which organizes the event."), ) author = models.ForeignKey( User, @@ -85,7 +100,7 @@ class News(models.Model): related_name="moderated_news", verbose_name=_("moderator"), null=True, - on_delete=models.CASCADE, + on_delete=models.SET_NULL, ) def __str__(self): @@ -93,17 +108,15 @@ class News(models.Model): def save(self, *args, **kwargs): super().save(*args, **kwargs) - for u in ( - RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID) - .first() - .users.all() + for user in User.objects.filter( + groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID] ): - Notification( - user=u, + Notification.objects.create( + user=user, url=reverse("com:news_admin_list"), type="NEWS_MODERATION", param="1", - ).save() + ) def get_absolute_url(self): return reverse("com:news_detail", kwargs={"news_id": self.id}) @@ -321,16 +334,14 @@ class Poster(models.Model): def save(self, *args, **kwargs): if not self.is_moderated: - for u in ( - RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID) - .first() - .users.all() + for user in User.objects.filter( + groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID] ): - Notification( - user=u, + Notification.objects.create( + user=user, url=reverse("com:poster_moderate_list"), type="POSTER_MODERATION", - ).save() + ) return super().save(*args, **kwargs) def clean(self, *args, **kwargs): diff --git a/com/templates/com/news_edit.jinja b/com/templates/com/news_edit.jinja index 858ec9a8..74040dc8 100644 --- a/com/templates/com/news_edit.jinja +++ b/com/templates/com/news_edit.jinja @@ -34,43 +34,90 @@ {% csrf_token %} {{ form.non_field_errors() }} {{ form.author }} -

{{ form.type.errors }} +

+ {{ form.type.errors }} +

- {{ form.type }}

-

{{ form.start_date.errors }} {{ form.start_date }}

-

{{ form.end_date.errors }} {{ form.end_date }}

-

{{ form.until.errors }} {{ form.until }}

-

{{ form.title.errors }} {{ form.title }}

-

{{ form.club.errors }} {{ form.club }}

-

{{ form.summary.errors }} {{ form.summary }}

-

{{ form.content.errors }} {{ form.content }}

+ {{ form.type }} +

+

+ {{ form.start_date.errors }} + + {{ form.start_date }} +

+

+ {{ form.end_date.errors }} + + {{ form.end_date }} +

+

+ {{ form.until.errors }} + + {{ form.until }} +

+

+ {{ form.title.errors }} + + {{ form.title }} +

+

+ {{ form.club.errors }} + + {{ form.club.help_text }} + {{ form.club }} +

+

+ {{ form.summary.errors }} + + {{ form.summary.help_text }} + {{ form.summary }} +

+

+ {{ form.content.errors }} + + {{ form.content.help_text }} + {{ form.content }} +

{% if user.is_com_admin %} -

{{ form.automoderation.errors }} - {{ form.automoderation }}

+

+ {{ form.automoderation.errors }} + + {{ form.automoderation }} +

{% endif %} -

-

+

+

{% endblock %} {% block script %} {{ super() }} {% endblock %} diff --git a/com/tests.py b/com/tests.py index 1c39fa36..399eb0e8 100644 --- a/com/tests.py +++ b/com/tests.py @@ -23,7 +23,7 @@ from django.utils.translation import gettext as _ from club.models import Club, Membership from com.models import News, Poster, Sith, Weekmail, WeekmailArticle -from core.models import AnonymousUser, RealGroup, User +from core.models import AnonymousUser, Group, User @pytest.fixture() @@ -49,9 +49,7 @@ class TestCom(TestCase): @classmethod def setUpTestData(cls): cls.skia = User.objects.get(username="skia") - cls.com_group = RealGroup.objects.filter( - id=settings.SITH_GROUP_COM_ADMIN_ID - ).first() + cls.com_group = Group.objects.get(id=settings.SITH_GROUP_COM_ADMIN_ID) cls.skia.groups.set([cls.com_group]) def setUp(self): diff --git a/com/views.py b/com/views.py index 69ee7221..d4136d20 100644 --- a/com/views.py +++ b/com/views.py @@ -28,7 +28,7 @@ from smtplib import SMTPRecipientsRefused from django import forms from django.conf import settings from django.core.exceptions import PermissionDenied, ValidationError -from django.db.models import Max +from django.db.models import Exists, Max, OuterRef from django.forms.models import modelform_factory from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect @@ -42,7 +42,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView from club.models import Club, Mailing from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle -from core.models import Notification, RealGroup, User +from core.models import Notification, User from core.views import ( CanCreateMixin, CanEditMixin, @@ -223,15 +223,13 @@ class NewsForm(forms.ModelForm): ): self.add_error( "end_date", - ValidationError( - _("You crazy? You can not finish an event before starting it.") - ), + ValidationError(_("An event cannot end before its beginning.")), ) if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]: self.add_error("until", ValidationError(_("This field is required."))) return self.cleaned_data - def save(self): + def save(self, *args, **kwargs): ret = super().save() self.instance.dates.all().delete() if self.instance.type == "EVENT" or self.instance.type == "CALL": @@ -280,21 +278,18 @@ class NewsEditView(CanEditMixin, UpdateView): else: self.object.is_moderated = False self.object.save() - for u in ( - RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID) - .first() - .users.all() + unread_notif_subquery = Notification.objects.filter( + user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False + ) + for user in User.objects.filter( + ~Exists(unread_notif_subquery), + groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID], ): - if not u.notifications.filter( - type="NEWS_MODERATION", viewed=False - ).exists(): - Notification( - user=u, - url=reverse( - "com:news_detail", kwargs={"news_id": self.object.id} - ), - type="NEWS_MODERATION", - ).save() + Notification.objects.create( + user=user, + url=self.object.get_absolute_url(), + type="NEWS_MODERATION", + ) return super().form_valid(form) @@ -325,19 +320,18 @@ class NewsCreateView(CanCreateMixin, CreateView): self.object.is_moderated = True self.object.save() else: - for u in ( - RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID) - .first() - .users.all() + unread_notif_subquery = Notification.objects.filter( + user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False + ) + for user in User.objects.filter( + ~Exists(unread_notif_subquery), + groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID], ): - if not u.notifications.filter( - type="NEWS_MODERATION", viewed=False - ).exists(): - Notification( - user=u, - url=reverse("com:news_admin_list"), - type="NEWS_MODERATION", - ).save() + Notification.objects.create( + user=user, + url=reverse("com:news_admin_list"), + type="NEWS_MODERATION", + ) return super().form_valid(form) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 7098101a..5770c715 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -261,19 +261,19 @@ class Command(BaseCommand): User.groups.through.objects.bulk_create( [ User.groups.through( - realgroup_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter + group_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter ), User.groups.through( - realgroup_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable + group_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable ), User.groups.through( - realgroup_id=settings.SITH_GROUP_COM_ADMIN_ID, user=comunity + group_id=settings.SITH_GROUP_COM_ADMIN_ID, user=comunity ), User.groups.through( - realgroup_id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID, user=tutu + group_id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID, user=tutu ), User.groups.through( - realgroup_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia + group_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia ), ] ) diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py index eaac58c0..f8ac1cef 100644 --- a/core/management/commands/populate_more.py +++ b/core/management/commands/populate_more.py @@ -11,7 +11,7 @@ from django.utils.timezone import localdate, make_aware, now from faker import Faker from club.models import Club, Membership -from core.models import RealGroup, User +from core.models import Group, User from counter.models import ( Counter, Customer, @@ -225,9 +225,7 @@ class Command(BaseCommand): ae = Club.objects.get(unix_name="ae") other_clubs = random.sample(list(Club.objects.all()), k=3) groups = list( - RealGroup.objects.filter( - name__in=["Subscribers", "Old subscribers", "Public"] - ) + Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"]) ) counters = list( Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"]) diff --git a/core/migrations/0040_alter_user_options_user_user_permissions_and_more.py b/core/migrations/0040_alter_user_options_user_user_permissions_and_more.py new file mode 100644 index 00000000..43e4911c --- /dev/null +++ b/core/migrations/0040_alter_user_options_user_user_permissions_and_more.py @@ -0,0 +1,82 @@ +# Generated by Django 4.2.16 on 2024-11-20 16:22 + +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("core", "0039_alter_user_managers"), + ] + + operations = [ + migrations.AlterModelOptions( + name="user", + options={"verbose_name": "user", "verbose_name_plural": "users"}, + ), + migrations.AddField( + model_name="user", + name="user_permissions", + field=models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + migrations.AlterField( + model_name="user", + name="date_joined", + field=models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + migrations.AlterField( + model_name="user", + name="is_superuser", + field=models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + migrations.AlterField( + model_name="user", + name="username", + field=models.CharField( + error_messages={"unique": "A user with that username already exists."}, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], + verbose_name="username", + ), + ), + migrations.AlterField( + model_name="user", + name="groups", + field=models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + migrations.AlterField( + model_name="user", + name="groups", + field=models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="users", + to="core.group", + verbose_name="groups", + ), + ), + ] diff --git a/core/models.py b/core/models.py index 7a9d3a46..347e9bd4 100644 --- a/core/models.py +++ b/core/models.py @@ -30,19 +30,13 @@ import string import unicodedata from datetime import timedelta from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional, Self +from typing import TYPE_CHECKING, Optional, Self from django.conf import settings -from django.contrib.auth.models import AbstractBaseUser, UserManager -from django.contrib.auth.models import ( - AnonymousUser as AuthAnonymousUser, -) -from django.contrib.auth.models import ( - Group as AuthGroup, -) -from django.contrib.auth.models import ( - GroupManager as AuthGroupManager, -) +from django.contrib.auth.models import AbstractUser, UserManager +from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser +from django.contrib.auth.models import Group as AuthGroup +from django.contrib.auth.models import GroupManager as AuthGroupManager from django.contrib.staticfiles.storage import staticfiles_storage from django.core import validators from django.core.cache import cache @@ -242,7 +236,7 @@ class CustomUserManager(UserManager.from_queryset(UserQuerySet)): pass -class User(AbstractBaseUser): +class User(AbstractUser): """Defines the base user class, useable in every app. This is almost the same as the auth module AbstractUser since it inherits from it, @@ -253,51 +247,22 @@ class User(AbstractBaseUser): Required fields: email, first_name, last_name, date_of_birth """ - username = models.CharField( - _("username"), - max_length=254, - unique=True, - help_text=_( - "Required. 254 characters or fewer. Letters, digits and ./+/-/_ only." - ), - validators=[ - validators.RegexValidator( - r"^[\w.+-]+$", - _( - "Enter a valid username. This value may contain only " - "letters, numbers " - "and ./+/-/_ characters." - ), - ) - ], - error_messages={"unique": _("A user with that username already exists.")}, - ) first_name = models.CharField(_("first name"), max_length=64) last_name = models.CharField(_("last name"), max_length=64) email = models.EmailField(_("email address"), unique=True) date_of_birth = models.DateField(_("date of birth"), blank=True, null=True) nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True) - is_staff = models.BooleanField( - _("staff status"), - default=False, - help_text=_("Designates whether the user can log into this admin site."), - ) - is_active = models.BooleanField( - _("active"), - default=True, - help_text=_( - "Designates whether this user should be treated as active. " - "Unselect this instead of deleting accounts." - ), - ) - date_joined = models.DateField(_("date joined"), auto_now_add=True) last_update = models.DateTimeField(_("last update"), auto_now=True) - is_superuser = models.BooleanField( - _("superuser"), - default=False, - help_text=_("Designates whether this user is a superuser. "), + groups = models.ManyToManyField( + Group, + verbose_name=_("groups"), + help_text=_( + "The groups this user belongs to. A user will get all permissions " + "granted to each of their groups." + ), + related_name="users", + blank=True, ) - groups = models.ManyToManyField(RealGroup, related_name="users", blank=True) home = models.OneToOneField( "SithFile", related_name="home_of", @@ -401,8 +366,6 @@ class User(AbstractBaseUser): objects = CustomUserManager() - USERNAME_FIELD = "username" - def __str__(self): return self.get_display_name() @@ -422,22 +385,23 @@ class User(AbstractBaseUser): settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png" ).exists() - def has_module_perms(self, package_name: str) -> bool: - return self.is_active - - def has_perm(self, perm: str, obj: Any = None) -> bool: - return self.is_active and self.is_superuser - @cached_property def was_subscribed(self) -> bool: + if "is_subscribed" in self.__dict__ and self.is_subscribed: + # if the user is currently subscribed, he is an old subscriber too + # if the property has already been cached, avoid another request + return True return self.subscriptions.exists() @cached_property def is_subscribed(self) -> bool: - s = self.subscriptions.filter( + if "was_subscribed" in self.__dict__ and not self.was_subscribed: + # if the user never subscribed, he cannot be a subscriber now. + # if the property has already been cached, avoid another request + return False + return self.subscriptions.filter( subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now() - ) - return s.exists() + ).exists() @cached_property def account_balance(self): @@ -530,10 +494,8 @@ class User(AbstractBaseUser): @cached_property def can_create_subscription(self) -> bool: - from club.models import Membership - - return ( - Membership.objects.board() + return self.is_root or ( + self.memberships.board() .ongoing() .filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS) .exists() @@ -601,11 +563,6 @@ class User(AbstractBaseUser): "date_of_birth": self.date_of_birth, } - def get_full_name(self): - """Returns the first_name plus the last_name, with a space in between.""" - full_name = "%s %s" % (self.first_name, self.last_name) - return full_name.strip() - def get_short_name(self): """Returns the short name for the user.""" if self.nick_name: @@ -984,13 +941,11 @@ class SithFile(models.Model): if copy_rights: self.copy_rights() if self.is_in_sas: - for u in ( - RealGroup.objects.filter(id=settings.SITH_GROUP_SAS_ADMIN_ID) - .first() - .users.all() + for user in User.objects.filter( + groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID] ): Notification( - user=u, + user=user, url=reverse("sas:moderation"), type="SAS_MODERATION", param="1", diff --git a/core/static/bundled/alpine-index.js b/core/static/bundled/alpine-index.js index d07e0bf2..211600a5 100644 --- a/core/static/bundled/alpine-index.js +++ b/core/static/bundled/alpine-index.js @@ -1,5 +1,7 @@ +import sort from "@alpinejs/sort"; import Alpine from "alpinejs"; +Alpine.plugin(sort); window.Alpine = Alpine; window.addEventListener("DOMContentLoaded", () => { diff --git a/core/static/bundled/core/components/ajax-select-base.ts b/core/static/bundled/core/components/ajax-select-base.ts index 674b7b73..06c3508e 100644 --- a/core/static/bundled/core/components/ajax-select-base.ts +++ b/core/static/bundled/core/components/ajax-select-base.ts @@ -67,6 +67,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") { remove_button: { title: gettext("Remove"), }, + // biome-ignore lint/style/useNamingConvention: this is required by the api + restore_on_backspace: {}, }, persist: false, maxItems: this.node.multiple ? this.max : 1, diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss index 7dab0484..42a4d719 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -87,3 +87,38 @@ a:not(.button) { color: $primary-color; } } + +form { + margin: 0 auto 10px; + + .helptext { + margin-top: .25rem; + margin-bottom: .25rem; + font-size: 80%; + } + + fieldset { + margin-bottom: 1rem; + } + + .row { + label { + margin: unset; + } + } + + label { + display: block; + margin-bottom: 8px; + + &.required:after { + margin-left: 4px; + content: "*"; + color: red; + } + } + + .choose_file_widget { + display: none; + } +} diff --git a/core/static/core/style.scss b/core/static/core/style.scss index cbe8d326..d7a396d1 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -314,6 +314,17 @@ body { } } + .snackbar { + width: 250px; + margin-left: -125px; + box-sizing: border-box; + position: fixed; + z-index: 1; + left: 50%; + top: 60px; + text-align: center; + } + .tabs { border-radius: 5px; @@ -1401,21 +1412,6 @@ footer { } } -/*---------------------------------FORMS-------------------------------*/ - -form { - margin: 0 auto; - margin-bottom: 10px; -} - -label { - display: block; - margin-bottom: 8px; -} - -.choose_file_widget { - display: none; -} .ui-dialog .ui-dialog-buttonpane { bottom: 0; diff --git a/core/templates/core/macros_pages.jinja b/core/templates/core/macros_pages.jinja index c18bfe31..79228ae7 100644 --- a/core/templates/core/macros_pages.jinja +++ b/core/templates/core/macros_pages.jinja @@ -3,17 +3,18 @@ {% macro page_history(page) %}

{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}

{% endmacro %} diff --git a/core/templates/core/user_tools.jinja b/core/templates/core/user_tools.jinja index e00b24da..d9a6c0c7 100644 --- a/core/templates/core/user_tools.jinja +++ b/core/templates/core/user_tools.jinja @@ -52,7 +52,7 @@ %}
  • {% trans %}General counters management{% endtrans %}
  • {% trans %}Products management{% endtrans %}
  • -
  • {% trans %}Product types management{% endtrans %}
  • +
  • {% trans %}Product types management{% endtrans %}
  • {% trans %}Cash register summaries{% endtrans %}
  • {% trans %}Invoices call{% endtrans %}
  • {% trans %}Etickets{% endtrans %}
  • diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 9b70e886..a33a8705 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -118,7 +118,9 @@ class TestUserRegistration: response = client.post(reverse("core:register"), valid_payload) assert response.status_code == 200 - error_html = "
  • Un objet User avec ce champ Adresse email existe déjà.
  • " + error_html = ( + "
  • Un objet Utilisateur avec ce champ Adresse email existe déjà.
  • " + ) assertInHTML(error_html, str(response.content.decode())) def test_register_fail_with_not_existing_email( diff --git a/core/tests/test_files.py b/core/tests/test_files.py index 1f39fcd8..998ceab5 100644 --- a/core/tests/test_files.py +++ b/core/tests/test_files.py @@ -14,7 +14,7 @@ from PIL import Image from pytest_django.asserts import assertNumQueries from core.baker_recipes import board_user, old_subscriber_user, subscriber_user -from core.models import Group, RealGroup, SithFile, User +from core.models import Group, SithFile, User from sas.models import Picture from sith import settings @@ -26,12 +26,10 @@ class TestImageAccess: [ lambda: baker.make(User, is_superuser=True), lambda: baker.make( - User, - groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)], + User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] ), lambda: baker.make( - User, - groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)], + User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)] ), ], ) diff --git a/core/views/files.py b/core/views/files.py index 0d083a84..f8539080 100644 --- a/core/views/files.py +++ b/core/views/files.py @@ -21,6 +21,7 @@ from wsgiref.util import FileWrapper from django import forms from django.conf import settings from django.core.exceptions import PermissionDenied +from django.db.models import Exists, OuterRef from django.forms.models import modelform_factory from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect @@ -31,7 +32,7 @@ from django.views.generic import DetailView, ListView from django.views.generic.detail import SingleObjectMixin from django.views.generic.edit import DeleteView, FormMixin, UpdateView -from core.models import Notification, RealGroup, SithFile, User +from core.models import Notification, SithFile, User from core.views import ( AllowFragment, CanEditMixin, @@ -159,19 +160,18 @@ class AddFilesForm(forms.Form): % {"file_name": f, "msg": repr(e)}, ) if notif: - for u in ( - RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID) - .first() - .users.all() + unread_notif_subquery = Notification.objects.filter( + user=OuterRef("pk"), type="FILE_MODERATION", viewed=False + ) + for user in User.objects.filter( + ~Exists(unread_notif_subquery), + groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID], ): - if not u.notifications.filter( - type="FILE_MODERATION", viewed=False - ).exists(): - Notification( - user=u, - url=reverse("core:file_moderation"), - type="FILE_MODERATION", - ).save() + Notification.objects.create( + user=user, + url=reverse("core:file_moderation"), + type="FILE_MODERATION", + ) class FileListView(ListView): diff --git a/core/views/forms.py b/core/views/forms.py index de01f7aa..8a998ab0 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -44,7 +44,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget from PIL import Image from antispam.forms import AntiSpamEmailField -from core.models import Gift, Page, SithFile, User +from core.models import Gift, Page, RealGroup, SithFile, User from core.utils import resize_image from core.views.widgets.select import ( AutoCompleteSelect, @@ -167,9 +167,7 @@ class RegisteringForm(UserCreationForm): class Meta: model = User fields = ("first_name", "last_name", "email") - field_classes = { - "email": AntiSpamEmailField, - } + field_classes = {"email": AntiSpamEmailField} class UserProfileForm(forms.ModelForm): @@ -287,15 +285,19 @@ class UserProfileForm(forms.ModelForm): self._post_clean() -class UserPropForm(forms.ModelForm): +class UserGroupsForm(forms.ModelForm): error_css_class = "error" required_css_class = "required" + groups = forms.ModelMultipleChoiceField( + queryset=RealGroup.objects.all(), + widget=CheckboxSelectMultiple, + label=_("Groups"), + ) + class Meta: model = User fields = ["groups"] - help_texts = {"groups": "Which groups this user belongs to"} - widgets = {"groups": CheckboxSelectMultiple} class UserGodfathersForm(forms.Form): diff --git a/core/views/group.py b/core/views/group.py index abb0097f..b6e77b54 100644 --- a/core/views/group.py +++ b/core/views/group.py @@ -23,9 +23,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView from core.models import RealGroup, User from core.views import CanCreateMixin, CanEditMixin, DetailFormView -from core.views.widgets.select import ( - AutoCompleteSelectMultipleUser, -) +from core.views.widgets.select import AutoCompleteSelectMultipleUser # Forms diff --git a/core/views/user.py b/core/views/user.py index 9f724fca..264a8dd6 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -35,7 +35,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import PermissionDenied from django.db.models import DateField, QuerySet from django.db.models.functions import Trunc -from django.forms import CheckboxSelectMultiple from django.forms.models import modelform_factory from django.http import Http404 from django.shortcuts import get_object_or_404, redirect @@ -68,6 +67,7 @@ from core.views.forms import ( LoginForm, RegisteringForm, UserGodfathersForm, + UserGroupsForm, UserProfileForm, ) from counter.models import Refilling, Selling @@ -583,9 +583,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView): model = User pk_url_kwarg = "user_id" template_name = "core/user_group.jinja" - form_class = modelform_factory( - User, fields=["groups"], widgets={"groups": CheckboxSelectMultiple} - ) + form_class = UserGroupsForm context_object_name = "profile" current_tab = "groups" diff --git a/counter/admin.py b/counter/admin.py index b3e6a91a..5dc795f2 100644 --- a/counter/admin.py +++ b/counter/admin.py @@ -129,7 +129,7 @@ class PermanencyAdmin(SearchModelAdmin): @admin.register(ProductType) class ProductTypeAdmin(admin.ModelAdmin): - list_display = ("name", "priority") + list_display = ("name", "order") @admin.register(CashRegisterSummary) diff --git a/counter/api.py b/counter/api.py index f3f0f101..dd7b75f0 100644 --- a/counter/api.py +++ b/counter/api.py @@ -12,24 +12,33 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -from typing import Annotated - -from annotated_types import MinLen -from django.db.models import Q +from django.conf import settings +from django.db.models import F +from django.shortcuts import get_object_or_404 from ninja import Query from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema -from core.api_permissions import CanAccessLookup, CanView, IsRoot -from counter.models import Counter, Product +from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot +from counter.models import Counter, Product, ProductType from counter.schemas import ( CounterFilterSchema, CounterSchema, + ProductFilterSchema, ProductSchema, + ProductTypeSchema, + ReorderProductTypeSchema, + SimpleProductSchema, SimplifiedCounterSchema, ) +IsCounterAdmin = ( + IsRoot + | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID) + | IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) +) + @api_controller("/counter") class CounterController(ControllerBase): @@ -64,15 +73,72 @@ class CounterController(ControllerBase): class ProductController(ControllerBase): @route.get( "/search", - response=PaginatedResponseSchema[ProductSchema], + response=PaginatedResponseSchema[SimpleProductSchema], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) - def search_products(self, search: Annotated[str, MinLen(1)]): - return ( - Product.objects.filter( - Q(name__icontains=search) | Q(code__icontains=search) - ) - .filter(archived=False) - .values() + def search_products(self, filters: Query[ProductFilterSchema]): + return filters.filter( + Product.objects.order_by( + F("product_type__order").asc(nulls_last=True), + "product_type", + "name", + ).values() ) + + @route.get( + "/search/detailed", + response=PaginatedResponseSchema[ProductSchema], + permissions=[IsCounterAdmin], + url_name="search_products_detailed", + ) + @paginate(PageNumberPaginationExtra, page_size=50) + def search_products_detailed(self, filters: Query[ProductFilterSchema]): + """Get the detailed information about the products.""" + return filters.filter( + Product.objects.select_related("club") + .prefetch_related("buying_groups") + .select_related("product_type") + .order_by( + F("product_type__order").asc(nulls_last=True), + "product_type", + "name", + ) + ) + + +@api_controller("/product-type", permissions=[IsCounterAdmin]) +class ProductTypeController(ControllerBase): + @route.get("", response=list[ProductTypeSchema], url_name="fetch_product_types") + def fetch_all(self): + return ProductType.objects.order_by("order") + + @route.patch("/{type_id}/move") + def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]): + """Change the order of a product type. + + To use this route, give either the id of the product type + this one should be above of, + of the id of the product type this one should be below of. + + Order affects the display order of the product types. + + Examples: + ``` + GET /api/counter/product-type + => [<1: type A>, <2: type B>, <3: type C>] + + PATCH /api/counter/product-type/3/move?below=1 + + GET /api/counter/product-type + => [<1: type A>, <3: type C>, <2: type B>] + ``` + """ + product_type: ProductType = self.get_object_or_exception( + ProductType, pk=type_id + ) + other = get_object_or_404(ProductType, pk=other_id.above or other_id.below) + if other_id.below is not None: + product_type.below(other) + else: + product_type.above(other) diff --git a/counter/migrations/0013_customer_recorded_products.py b/counter/migrations/0013_customer_recorded_products.py index f2a48ba5..72825d16 100644 --- a/counter/migrations/0013_customer_recorded_products.py +++ b/counter/migrations/0013_customer_recorded_products.py @@ -1,38 +1,6 @@ from __future__ import unicode_literals -from django.conf import settings from django.db import migrations, models -from django.utils.translation import gettext_lazy as _ - -from core.models import User -from counter.models import Counter, Customer, Product, Selling - - -def balance_ecocups(apps, schema_editor): - for customer in Customer.objects.all(): - customer.recorded_products = 0 - for selling in customer.buyings.filter( - product__id__in=[settings.SITH_ECOCUP_CONS, settings.SITH_ECOCUP_DECO] - ).all(): - if selling.product.is_record_product: - customer.recorded_products += selling.quantity - elif selling.product.is_unrecord_product: - customer.recorded_products -= selling.quantity - if customer.recorded_products < -settings.SITH_ECOCUP_LIMIT: - qt = -(customer.recorded_products + settings.SITH_ECOCUP_LIMIT) - cons = Product.objects.get(id=settings.SITH_ECOCUP_CONS) - Selling( - label=_("Ecocup regularization"), - product=cons, - unit_price=cons.selling_price, - club=cons.club, - counter=Counter.objects.filter(name="Foyer").first(), - quantity=qt, - seller=User.objects.get(id=0), - customer=customer, - ).save(allow_negative=True) - customer.recorded_products += qt - customer.save() class Migration(migrations.Migration): @@ -44,5 +12,4 @@ class Migration(migrations.Migration): name="recorded_products", field=models.IntegerField(verbose_name="recorded items", default=0), ), - migrations.RunPython(balance_ecocups), ] diff --git a/counter/migrations/0028_alter_producttype_comment_and_more.py b/counter/migrations/0028_alter_producttype_comment_and_more.py new file mode 100644 index 00000000..f7fabb83 --- /dev/null +++ b/counter/migrations/0028_alter_producttype_comment_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 4.2.17 on 2024-12-15 17:53 + +from django.db import migrations, models +from django.db.migrations.state import StateApps + + +def move_priority_to_order(apps: StateApps, schema_editor): + """Migrate the previous homemade `priority` to `OrderedModel.order`. + + `priority` was a system were click managers set themselves the priority + of a ProductType. + The higher the priority, the higher it was to be displayed in the eboutic. + Multiple product types could share the same priority, in which + case they were ordered by alphabetic order. + + The new field is unique per object, and works in the other way : + the nearer from 0, the higher it should appear. + """ + ProductType = apps.get_model("counter", "ProductType") + product_types = list(ProductType.objects.order_by("-priority", "name")) + for order, product_type in enumerate(product_types): + product_type.order = order + ProductType.objects.bulk_update(product_types, ["order"]) + + +class Migration(migrations.Migration): + dependencies = [("counter", "0027_alter_refilling_payment_method")] + + operations = [ + migrations.AlterField( + model_name="producttype", + name="comment", + field=models.TextField( + default="", + help_text="A text that will be shown on the eboutic.", + verbose_name="comment", + ), + ), + migrations.AlterField( + model_name="producttype", + name="description", + field=models.TextField(default="", verbose_name="description"), + ), + migrations.AlterModelOptions( + name="producttype", + options={"ordering": ["order"], "verbose_name": "product type"}, + ), + migrations.AddField( + model_name="producttype", + name="order", + field=models.PositiveIntegerField( + db_index=True, default=0, editable=False, verbose_name="order" + ), + preserve_default=False, + ), + migrations.RunPython( + move_priority_to_order, + reverse_code=migrations.RunPython.noop, + elidable=True, + ), + migrations.RemoveField(model_name="producttype", name="priority"), + ] diff --git a/counter/models.py b/counter/models.py index 087baffc..48bb841f 100644 --- a/counter/models.py +++ b/counter/models.py @@ -35,6 +35,7 @@ from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from django_countries.fields import CountryField +from ordered_model.models import OrderedModel from phonenumber_field.modelfields import PhoneNumberField from accounting.models import CurrencyField @@ -289,32 +290,32 @@ class AccountDump(models.Model): ) -class ProductType(models.Model): +class ProductType(OrderedModel): """A product type. Useful only for categorizing. """ name = models.CharField(_("name"), max_length=30) - description = models.TextField(_("description"), null=True, blank=True) - comment = models.TextField(_("comment"), null=True, blank=True) + description = models.TextField(_("description"), default="") + comment = models.TextField( + _("comment"), + default="", + help_text=_("A text that will be shown on the eboutic."), + ) icon = ResizedImageField( height=70, force_format="WEBP", upload_to="products", null=True, blank=True ) - # priority holds no real backend logic but helps to handle the order in which - # the items are to be shown to the user - priority = models.PositiveIntegerField(default=0) - class Meta: verbose_name = _("product type") - ordering = ["-priority", "name"] + ordering = ["order"] def __str__(self): return self.name def get_absolute_url(self): - return reverse("counter:producttype_list") + return reverse("counter:product_type_list") def is_owned_by(self, user): """Method to see if that object can be edited by the given user.""" diff --git a/counter/schemas.py b/counter/schemas.py index ec1a842d..adc8094b 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -1,10 +1,13 @@ -from typing import Annotated +from typing import Annotated, Self from annotated_types import MinLen -from ninja import Field, FilterSchema, ModelSchema +from django.urls import reverse +from ninja import Field, FilterSchema, ModelSchema, Schema +from pydantic import model_validator -from core.schemas import SimpleUserSchema -from counter.models import Counter, Product +from club.schemas import ClubSchema +from core.schemas import GroupSchema, SimpleUserSchema +from counter.models import Counter, Product, ProductType class CounterSchema(ModelSchema): @@ -26,7 +29,72 @@ class SimplifiedCounterSchema(ModelSchema): fields = ["id", "name"] -class ProductSchema(ModelSchema): +class ProductTypeSchema(ModelSchema): + class Meta: + model = ProductType + fields = ["id", "name", "description", "comment", "icon", "order"] + + url: str + + @staticmethod + def resolve_url(obj: ProductType) -> str: + return reverse("counter:product_type_edit", kwargs={"type_id": obj.id}) + + +class SimpleProductTypeSchema(ModelSchema): + class Meta: + model = ProductType + fields = ["id", "name"] + + +class ReorderProductTypeSchema(Schema): + below: int | None = None + above: int | None = None + + @model_validator(mode="after") + def validate_exclusive(self) -> Self: + if self.below is None and self.above is None: + raise ValueError("Either 'below' or 'above' must be set.") + if self.below is not None and self.above is not None: + raise ValueError("Only one of 'below' or 'above' must be set.") + return self + + +class SimpleProductSchema(ModelSchema): class Meta: model = Product fields = ["id", "name", "code"] + + +class ProductSchema(ModelSchema): + class Meta: + model = Product + fields = [ + "id", + "name", + "code", + "description", + "purchase_price", + "selling_price", + "icon", + "limit_age", + "archived", + ] + + buying_groups: list[GroupSchema] + club: ClubSchema + product_type: SimpleProductTypeSchema | None + url: str + + @staticmethod + def resolve_url(obj: Product) -> str: + return reverse("counter:product_edit", kwargs={"product_id": obj.id}) + + +class ProductFilterSchema(FilterSchema): + search: Annotated[str, MinLen(1)] | None = Field( + None, q=["name__icontains", "code__icontains"] + ) + is_archived: bool | None = Field(None, q="archived") + buying_groups: set[int] | None = Field(None, q="buying_groups__in") + product_type: set[int] | None = Field(None, q="product_type__in") diff --git a/counter/static/bundled/counter/components/ajax-select-index.ts b/counter/static/bundled/counter/components/ajax-select-index.ts index 147e4733..a2d61a48 100644 --- a/counter/static/bundled/counter/components/ajax-select-index.ts +++ b/counter/static/bundled/counter/components/ajax-select-index.ts @@ -4,7 +4,7 @@ import type { TomOption } from "tom-select/dist/types/types"; import type { escape_html } from "tom-select/dist/types/utils"; import { type CounterSchema, - type ProductSchema, + type SimpleProductSchema, counterSearchCounter, productSearchProducts, } from "#openapi"; @@ -23,13 +23,13 @@ export class ProductAjaxSelect extends AjaxSelect { return []; } - protected renderOption(item: ProductSchema, sanitize: typeof escape_html) { + protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) { return `
    ${sanitize(item.code)} - ${sanitize(item.name)}
    `; } - protected renderItem(item: ProductSchema, sanitize: typeof escape_html) { + protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) { return `${sanitize(item.code)} - ${sanitize(item.name)}`; } } diff --git a/counter/static/bundled/counter/product-type-index.ts b/counter/static/bundled/counter/product-type-index.ts new file mode 100644 index 00000000..4d1bdda4 --- /dev/null +++ b/counter/static/bundled/counter/product-type-index.ts @@ -0,0 +1,64 @@ +import Alpine from "alpinejs"; +import { producttypeReorder } from "#openapi"; + +document.addEventListener("alpine:init", () => { + Alpine.data("productTypesList", () => ({ + loading: false, + alertMessage: { + open: false, + success: true, + content: "", + timeout: null, + }, + + async reorder(itemId: number, newPosition: number) { + // The sort plugin of Alpine doesn't manage dynamic lists with x-sort + // (cf. https://github.com/alpinejs/alpine/discussions/4157). + // There is an open PR that fixes this issue + // (cf. https://github.com/alpinejs/alpine/pull/4361). + // However, it hasn't been merged yet. + // To overcome this, I get the list of DOM elements + // And fetch the `x-sort:item` attribute, which value is + // the id of the object in database. + // Please make this a little bit cleaner when the fix has been merged + // into the main Alpine repo. + this.loading = true; + const productTypes = this.$refs.productTypes + .childNodes as NodeListOf; + const getId = (elem: HTMLLIElement) => + Number.parseInt(elem.getAttribute("x-sort:item")); + const query = + newPosition === 0 + ? { above: getId(productTypes.item(1)) } + : { below: getId(productTypes.item(newPosition - 1)) }; + const response = await producttypeReorder({ + // biome-ignore lint/style/useNamingConvention: api is snake_case + path: { type_id: itemId }, + query: query, + }); + this.openAlertMessage(response.response); + this.loading = false; + }, + + openAlertMessage(response: Response) { + if (response.ok) { + this.alertMessage.success = true; + this.alertMessage.content = gettext("Products types successfully reordered"); + } else { + this.alertMessage.success = false; + this.alertMessage.content = interpolate( + gettext("Product type reorganisation failed with status code : %d"), + [response.status], + ); + } + this.alertMessage.open = true; + if (this.alertMessage.timeout !== null) { + clearTimeout(this.alertMessage.timeout); + } + this.alertMessage.timeout = setTimeout(() => { + this.alertMessage.open = false; + }, 2000); + this.loading = false; + }, + })); +}); diff --git a/counter/static/counter/css/product_type.scss b/counter/static/counter/css/product_type.scss new file mode 100644 index 00000000..16bd43a9 --- /dev/null +++ b/counter/static/counter/css/product_type.scss @@ -0,0 +1,15 @@ +.product-type-list { + li { + list-style: none; + margin-bottom: 10px; + + i { + cursor: grab; + visibility: hidden; + } + } +} + +body:not(.sorting) .product-type-list li:hover i { + visibility: visible; +} \ No newline at end of file diff --git a/counter/templates/counter/product_type_list.jinja b/counter/templates/counter/product_type_list.jinja new file mode 100644 index 00000000..68548829 --- /dev/null +++ b/counter/templates/counter/product_type_list.jinja @@ -0,0 +1,64 @@ +{% extends "core/base.jinja" %} + +{% block title %} + {% trans %}Product type list{% endtrans %} +{% endblock %} + +{% block additional_css %} + +{% endblock %} + +{% block additional_js %} + +{% endblock %} + +{% block content %} +

    + + {% trans %}New product type{% endtrans %} + + +

    + {% if product_types %} + +
    +

    +

    {% trans %}Product type list{% endtrans %}

    + +
    + {% else %} +

    + {% trans %}There are no product types in this website.{% endtrans %} +

    + {% endif %} +{% endblock %} diff --git a/counter/templates/counter/producttype_list.jinja b/counter/templates/counter/producttype_list.jinja deleted file mode 100644 index 0c4ff0c5..00000000 --- a/counter/templates/counter/producttype_list.jinja +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "core/base.jinja" %} - -{% block title %} - {% trans %}Product type list{% endtrans %} -{% endblock %} - -{% block content %} -

    {% trans %}New product type{% endtrans %}

    - {% if producttype_list %} -

    {% trans %}Product type list{% endtrans %}

    - - {% else %} - {% trans %}There is no product types in this website.{% endtrans %} - {% endif %} -{% endblock %} - - - - - diff --git a/counter/tests/test_product.py b/counter/tests/test_product.py index d18c6f11..8daab2b1 100644 --- a/counter/tests/test_product.py +++ b/counter/tests/test_product.py @@ -1,11 +1,19 @@ from io import BytesIO +from typing import Callable from uuid import uuid4 import pytest +from django.conf import settings +from django.core.cache import cache from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import Client +from django.urls import reverse from model_bakery import baker from PIL import Image +from pytest_django.asserts import assertNumQueries +from core.baker_recipes import board_user, subscriber_user +from core.models import Group, User from counter.models import Product, ProductType @@ -31,3 +39,48 @@ def test_resize_product_icon(model): assert product.icon.height == 70 assert product.icon.name == f"products/{name}.webp" assert Image.open(product.icon).format == "WEBP" + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("user_factory", "status_code"), + [ + (lambda: baker.make(User, is_superuser=True), 200), + (board_user.make, 403), + (subscriber_user.make, 403), + ( + lambda: baker.make( + User, + groups=[Group.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)], + ), + 200, + ), + ( + lambda: baker.make( + User, + groups=[Group.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)], + ), + 200, + ), + ], +) +def test_fetch_product_access( + client: Client, user_factory: Callable[[], User], status_code: int +): + """Test that only authorized users can use the `GET /product` route.""" + client.force_login(user_factory()) + assert ( + client.get(reverse("api:search_products_detailed")).status_code == status_code + ) + + +@pytest.mark.django_db +def test_fetch_product_nb_queries(client: Client): + client.force_login(baker.make(User, is_superuser=True)) + cache.clear() + with assertNumQueries(5): + # - 2 for authentication + # - 1 for pagination + # - 1 for the actual request + # - 1 to prefetch the related buying_groups + client.get(reverse("api:search_products_detailed")) diff --git a/counter/tests/test_product_type.py b/counter/tests/test_product_type.py new file mode 100644 index 00000000..16dc40dd --- /dev/null +++ b/counter/tests/test_product_type.py @@ -0,0 +1,89 @@ +import pytest +from django.conf import settings +from django.test import Client +from django.urls import reverse +from model_bakery import baker, seq +from ninja_extra.testing import TestClient + +from core.baker_recipes import board_user, subscriber_user +from core.models import Group, User +from counter.api import ProductTypeController +from counter.models import ProductType + + +@pytest.fixture +def product_types(db) -> list[ProductType]: + """All existing product types, ordered by their `order` field""" + # delete product types that have been created in the `populate` command + ProductType.objects.all().delete() + return baker.make(ProductType, _quantity=5, order=seq(0)) + + +@pytest.mark.django_db +def test_fetch_product_types(product_types: list[ProductType]): + """Test that the API returns the right products in the right order""" + client = TestClient(ProductTypeController) + response = client.get("") + assert response.status_code == 200 + assert [i["id"] for i in response.json()] == [t.id for t in product_types] + + +@pytest.mark.django_db +def test_move_below_product_type(product_types: list[ProductType]): + """Test that moving a product below another works""" + client = TestClient(ProductTypeController) + response = client.patch( + f"/{product_types[-1].id}/move", query={"below": product_types[0].id} + ) + assert response.status_code == 200 + new_order = [i["id"] for i in client.get("").json()] + assert new_order == [ + product_types[0].id, + product_types[-1].id, + *[t.id for t in product_types[1:-1]], + ] + + +@pytest.mark.django_db +def test_move_above_product_type(product_types: list[ProductType]): + """Test that moving a product above another works""" + client = TestClient(ProductTypeController) + response = client.patch( + f"/{product_types[1].id}/move", query={"above": product_types[0].id} + ) + assert response.status_code == 200 + new_order = [i["id"] for i in client.get("").json()] + assert new_order == [ + product_types[1].id, + product_types[0].id, + *[t.id for t in product_types[2:]], + ] + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("user_factory", "status_code"), + [ + (lambda: baker.make(User, is_superuser=True), 200), + (subscriber_user.make, 403), + (board_user.make, 403), + ( + lambda: baker.make( + User, + groups=[Group.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)], + ), + 200, + ), + ( + lambda: baker.make( + User, + groups=[Group.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)], + ), + 200, + ), + ], +) +def test_controller_permissions(client: Client, user_factory, status_code): + client.force_login(user_factory()) + response = client.get(reverse("api:fetch_product_types")) + assert response.status_code == status_code diff --git a/counter/urls.py b/counter/urls.py index 0ca77f73..91564a8b 100644 --- a/counter/urls.py +++ b/counter/urls.py @@ -121,19 +121,19 @@ urlpatterns = [ name="product_edit", ), path( - "admin/producttype/list/", + "admin/product-type/list/", ProductTypeListView.as_view(), - name="producttype_list", + name="product_type_list", ), path( - "admin/producttype/create/", + "admin/product-type/create/", ProductTypeCreateView.as_view(), - name="new_producttype", + name="new_product_type", ), path( - "admin/producttype//", + "admin/product-type//", ProductTypeEditView.as_view(), - name="producttype_edit", + name="product_type_edit", ), path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"), path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"), diff --git a/counter/views/admin.py b/counter/views/admin.py index fbf466b3..aa7e2c50 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -101,15 +101,16 @@ class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): """A list view for the admins.""" model = ProductType - template_name = "counter/producttype_list.jinja" + template_name = "counter/product_type_list.jinja" current_tab = "product_types" + context_object_name = "product_types" class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): """A create view for the admins.""" model = ProductType - fields = ["name", "description", "comment", "icon", "priority"] + fields = ["name", "description", "comment", "icon"] template_name = "core/create.jinja" current_tab = "products" @@ -119,7 +120,7 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): model = ProductType template_name = "core/edit.jinja" - fields = ["name", "description", "comment", "icon", "priority"] + fields = ["name", "description", "comment", "icon"] pk_url_kwarg = "type_id" current_tab = "products" @@ -129,7 +130,7 @@ class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): queryset = Product.objects.values("id", "name", "code", "product_type__name") template_name = "counter/product_list.jinja" ordering = [ - F("product_type__priority").desc(nulls_last=True), + F("product_type__order").asc(nulls_last=True), "product_type", "name", ] diff --git a/counter/views/mixins.py b/counter/views/mixins.py index 4a07d848..2e88f54c 100644 --- a/counter/views/mixins.py +++ b/counter/views/mixins.py @@ -99,7 +99,7 @@ class CounterAdminTabsMixin(TabedViewMixin): "name": _("Archived products"), }, { - "url": reverse_lazy("counter:producttype_list"), + "url": reverse_lazy("counter:product_type_list"), "slug": "product_types", "name": _("Product types"), }, diff --git a/counter/widgets/select.py b/counter/widgets/select.py index 68b0bfc1..78c92862 100644 --- a/counter/widgets/select.py +++ b/counter/widgets/select.py @@ -2,7 +2,7 @@ from pydantic import TypeAdapter from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple from counter.models import Counter, Product -from counter.schemas import ProductSchema, SimplifiedCounterSchema +from counter.schemas import SimpleProductSchema, SimplifiedCounterSchema _js = ["bundled/counter/components/ajax-select-index.ts"] @@ -24,12 +24,12 @@ class AutoCompleteSelectMultipleCounter(AutoCompleteSelectMultiple): class AutoCompleteSelectProduct(AutoCompleteSelect): component_name = "product-ajax-select" model = Product - adapter = TypeAdapter(list[ProductSchema]) + adapter = TypeAdapter(list[SimpleProductSchema]) js = _js class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple): component_name = "product-ajax-select" model = Product - adapter = TypeAdapter(list[ProductSchema]) + adapter = TypeAdapter(list[SimpleProductSchema]) js = _js diff --git a/docs/howto/translation.md b/docs/howto/translation.md index 6ae299ed..02f9f87b 100644 --- a/docs/howto/translation.md +++ b/docs/howto/translation.md @@ -37,8 +37,11 @@ Il faut d'abord générer un fichier de traductions, l'éditer et enfin le compiler au format binaire pour qu'il soit lu par le serveur. ```bash -./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules # Pour le backend -./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules # Pour le frontend +# Pour le backend +./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules + +# Pour le frontend +./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules --ignore=staticfiles/generated ``` ## Éditer le fichier django.po diff --git a/eboutic/models.py b/eboutic/models.py index 7ec9deef..7f7282b1 100644 --- a/eboutic/models.py +++ b/eboutic/models.py @@ -36,7 +36,7 @@ def get_eboutic_products(user: User) -> list[Product]: .products.filter(product_type__isnull=False) .filter(archived=False) .filter(limit_age__lte=user.age) - .annotate(priority=F("product_type__priority")) + .annotate(order=F("product_type__order")) .annotate(category=F("product_type__name")) .annotate(category_comment=F("product_type__comment")) .prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to` diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja index bf5d7556..b71eb434 100644 --- a/eboutic/templates/eboutic/eboutic_main.jinja +++ b/eboutic/templates/eboutic/eboutic_main.jinja @@ -88,7 +88,7 @@ {% endif %} - {% for priority_groups in products|groupby('priority')|reverse %} + {% for priority_groups in products|groupby('order') %} {% for category, items in priority_groups.list|groupby('category') %} {% if items|count > 0 %}
    diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 1f74ddaa..015e3dd7 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-12-17 10:53+0100\n" +"POT-Creation-Date: 2024-12-19 10:43+0100\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -17,9 +17,9 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n > 1);\n" #: accounting/models.py:62 accounting/models.py:101 accounting/models.py:132 -#: accounting/models.py:190 club/models.py:55 com/models.py:274 -#: com/models.py:293 counter/models.py:298 counter/models.py:329 -#: counter/models.py:480 forum/models.py:60 launderette/models.py:29 +#: accounting/models.py:190 club/models.py:55 com/models.py:287 +#: com/models.py:306 counter/models.py:299 counter/models.py:330 +#: counter/models.py:481 forum/models.py:60 launderette/models.py:29 #: launderette/models.py:80 launderette/models.py:116 msgid "name" msgstr "nom" @@ -40,7 +40,7 @@ msgstr "code postal" msgid "country" msgstr "pays" -#: accounting/models.py:67 core/models.py:391 +#: accounting/models.py:67 core/models.py:356 msgid "phone" msgstr "téléphone" @@ -65,8 +65,8 @@ msgid "account number" msgstr "numéro de compte" #: accounting/models.py:107 accounting/models.py:136 club/models.py:345 -#: com/models.py:74 com/models.py:259 com/models.py:299 counter/models.py:358 -#: counter/models.py:482 trombi/models.py:209 +#: com/models.py:87 com/models.py:272 com/models.py:312 counter/models.py:359 +#: counter/models.py:483 trombi/models.py:209 msgid "club" msgstr "club" @@ -87,12 +87,12 @@ msgstr "Compte club" msgid "%(club_account)s on %(bank_account)s" msgstr "%(club_account)s sur %(bank_account)s" -#: accounting/models.py:188 club/models.py:351 counter/models.py:965 +#: accounting/models.py:188 club/models.py:351 counter/models.py:966 #: election/models.py:16 launderette/models.py:165 msgid "start date" msgstr "date de début" -#: accounting/models.py:189 club/models.py:352 counter/models.py:966 +#: accounting/models.py:189 club/models.py:352 counter/models.py:967 #: election/models.py:17 msgid "end date" msgstr "date de fin" @@ -105,8 +105,8 @@ msgstr "est fermé" msgid "club account" msgstr "compte club" -#: accounting/models.py:199 accounting/models.py:255 counter/models.py:92 -#: counter/models.py:683 +#: accounting/models.py:199 accounting/models.py:255 counter/models.py:93 +#: counter/models.py:684 msgid "amount" msgstr "montant" @@ -126,20 +126,20 @@ msgstr "numéro" msgid "journal" msgstr "classeur" -#: accounting/models.py:256 core/models.py:956 core/models.py:1467 -#: core/models.py:1512 core/models.py:1541 core/models.py:1565 -#: counter/models.py:693 counter/models.py:797 counter/models.py:1001 +#: accounting/models.py:256 core/models.py:913 core/models.py:1422 +#: core/models.py:1467 core/models.py:1496 core/models.py:1520 +#: counter/models.py:694 counter/models.py:798 counter/models.py:1002 #: eboutic/models.py:57 eboutic/models.py:193 forum/models.py:312 #: forum/models.py:413 msgid "date" msgstr "date" -#: accounting/models.py:257 counter/models.py:300 counter/models.py:1002 +#: accounting/models.py:257 counter/models.py:302 counter/models.py:1003 #: pedagogy/models.py:208 msgid "comment" msgstr "commentaire" -#: accounting/models.py:259 counter/models.py:695 counter/models.py:799 +#: accounting/models.py:259 counter/models.py:696 counter/models.py:800 #: subscription/models.py:56 msgid "payment method" msgstr "méthode de paiement" @@ -165,8 +165,8 @@ msgid "accounting type" msgstr "type comptable" #: accounting/models.py:294 accounting/models.py:429 accounting/models.py:460 -#: accounting/models.py:492 core/models.py:1540 core/models.py:1566 -#: counter/models.py:763 +#: accounting/models.py:492 core/models.py:1495 core/models.py:1521 +#: counter/models.py:764 msgid "label" msgstr "étiquette" @@ -174,7 +174,7 @@ msgstr "étiquette" msgid "target type" msgstr "type de cible" -#: accounting/models.py:303 club/models.py:505 +#: accounting/models.py:303 club/models.py:504 #: club/templates/club/club_members.jinja:17 #: club/templates/club/club_old_members.jinja:8 #: club/templates/club/mailing.jinja:41 @@ -218,7 +218,7 @@ msgstr "Compte" msgid "Company" msgstr "Entreprise" -#: accounting/models.py:307 core/models.py:338 sith/settings.py:423 +#: accounting/models.py:307 core/models.py:303 sith/settings.py:423 msgid "Other" msgstr "Autre" @@ -264,7 +264,7 @@ msgstr "" "Vous devez fournir soit un type comptable simplifié ou un type comptable " "standard" -#: accounting/models.py:421 counter/models.py:339 pedagogy/models.py:41 +#: accounting/models.py:421 counter/models.py:340 pedagogy/models.py:41 msgid "code" msgstr "code" @@ -279,14 +279,14 @@ msgstr "type de mouvement" #: accounting/models.py:433 #: accounting/templates/accounting/journal_statement_nature.jinja:9 #: accounting/templates/accounting/journal_statement_person.jinja:12 -#: accounting/views.py:574 +#: accounting/views.py:566 msgid "Credit" msgstr "Crédit" #: accounting/models.py:434 #: accounting/templates/accounting/journal_statement_nature.jinja:28 #: accounting/templates/accounting/journal_statement_person.jinja:40 -#: accounting/views.py:574 +#: accounting/views.py:566 msgid "Debit" msgstr "Débit" @@ -760,7 +760,7 @@ msgid "Linked operation:" msgstr "Opération liée : " #: accounting/templates/accounting/operation_edit.jinja:55 -#: com/templates/com/news_edit.jinja:57 com/templates/com/poster_edit.jinja:33 +#: com/templates/com/news_edit.jinja:103 com/templates/com/poster_edit.jinja:33 #: com/templates/com/screen_edit.jinja:25 com/templates/com/weekmail.jinja:74 #: core/templates/core/create.jinja:12 core/templates/core/edit.jinja:7 #: core/templates/core/edit.jinja:15 core/templates/core/edit.jinja:20 @@ -782,7 +782,7 @@ msgstr "Sauver" #: accounting/templates/accounting/refound_account.jinja:4 #: accounting/templates/accounting/refound_account.jinja:9 -#: accounting/views.py:892 +#: accounting/views.py:884 msgid "Refound account" msgstr "Remboursement de compte" @@ -803,87 +803,87 @@ msgstr "Types simplifiés" msgid "New simplified type" msgstr "Nouveau type simplifié" -#: accounting/views.py:215 accounting/views.py:225 accounting/views.py:549 +#: accounting/views.py:215 accounting/views.py:224 accounting/views.py:541 msgid "Journal" msgstr "Classeur" -#: accounting/views.py:235 +#: accounting/views.py:232 msgid "Statement by nature" msgstr "Bilan par nature" -#: accounting/views.py:245 +#: accounting/views.py:240 msgid "Statement by person" msgstr "Bilan par personne" -#: accounting/views.py:255 +#: accounting/views.py:248 msgid "Accounting statement" msgstr "Bilan comptable" -#: accounting/views.py:369 +#: accounting/views.py:361 msgid "Link this operation to the target account" msgstr "Lier cette opération au compte cible" -#: accounting/views.py:399 +#: accounting/views.py:391 msgid "The target must be set." msgstr "La cible doit être indiquée." -#: accounting/views.py:414 +#: accounting/views.py:406 msgid "The amount must be set." msgstr "Le montant doit être indiqué." -#: accounting/views.py:543 accounting/views.py:549 +#: accounting/views.py:535 accounting/views.py:541 msgid "Operation" msgstr "Opération" -#: accounting/views.py:558 +#: accounting/views.py:550 msgid "Financial proof: " msgstr "Justificatif de libellé : " -#: accounting/views.py:561 +#: accounting/views.py:553 #, python-format msgid "Club: %(club_name)s" msgstr "Club : %(club_name)s" -#: accounting/views.py:566 +#: accounting/views.py:558 #, python-format msgid "Label: %(op_label)s" msgstr "Libellé : %(op_label)s" -#: accounting/views.py:569 +#: accounting/views.py:561 #, python-format msgid "Date: %(date)s" msgstr "Date : %(date)s" -#: accounting/views.py:577 +#: accounting/views.py:569 #, python-format msgid "Amount: %(amount).2f €" msgstr "Montant : %(amount).2f €" -#: accounting/views.py:592 +#: accounting/views.py:584 msgid "Debtor" msgstr "Débiteur" -#: accounting/views.py:592 +#: accounting/views.py:584 msgid "Creditor" msgstr "Créditeur" -#: accounting/views.py:597 +#: accounting/views.py:589 msgid "Comment:" msgstr "Commentaire :" -#: accounting/views.py:622 +#: accounting/views.py:614 msgid "Signature:" msgstr "Signature :" -#: accounting/views.py:686 +#: accounting/views.py:678 msgid "General statement" msgstr "Bilan général" -#: accounting/views.py:693 +#: accounting/views.py:685 msgid "No label operations" msgstr "Opérations sans étiquette" -#: accounting/views.py:846 +#: accounting/views.py:838 msgid "Refound this account" msgstr "Rembourser ce compte" @@ -910,7 +910,7 @@ msgstr "" msgid "Users to add" msgstr "Utilisateurs à ajouter" -#: club/forms.py:55 club/forms.py:181 core/views/group.py:42 +#: club/forms.py:55 club/forms.py:181 core/views/group.py:40 msgid "Search users to add (one or more)." msgstr "Recherche les utilisateurs à ajouter (un ou plus)." @@ -1025,11 +1025,11 @@ msgstr "actif" msgid "short description" msgstr "description courte" -#: club/models.py:81 core/models.py:393 +#: club/models.py:81 core/models.py:358 msgid "address" msgstr "Adresse" -#: club/models.py:98 core/models.py:304 +#: club/models.py:98 core/models.py:269 msgid "home" msgstr "home" @@ -1041,25 +1041,25 @@ msgstr "Vous ne pouvez pas faire de boucles dans les clubs" msgid "A club with that unix_name already exists" msgstr "Un club avec ce nom UNIX existe déjà." -#: club/models.py:337 counter/models.py:956 counter/models.py:992 +#: club/models.py:337 counter/models.py:957 counter/models.py:993 #: eboutic/models.py:53 eboutic/models.py:189 election/models.py:183 #: launderette/models.py:130 launderette/models.py:184 sas/models.py:273 #: trombi/models.py:205 msgid "user" -msgstr "nom d'utilisateur" +msgstr "utilisateur" -#: club/models.py:354 core/models.py:357 election/models.py:178 +#: club/models.py:354 core/models.py:322 election/models.py:178 #: election/models.py:212 trombi/models.py:210 msgid "role" msgstr "rôle" -#: club/models.py:359 core/models.py:90 counter/models.py:299 -#: counter/models.py:330 election/models.py:13 election/models.py:115 +#: club/models.py:359 core/models.py:84 counter/models.py:300 +#: counter/models.py:331 election/models.py:13 election/models.py:115 #: election/models.py:188 forum/models.py:61 forum/models.py:245 msgid "description" msgstr "description" -#: club/models.py:415 club/models.py:511 +#: club/models.py:415 club/models.py:510 msgid "Email address" msgstr "Adresse email" @@ -1068,31 +1068,31 @@ msgid "Enter a valid address. Only the root of the address is needed." msgstr "" "Entrez une adresse valide. Seule la racine de l'adresse est nécessaire." -#: club/models.py:427 com/models.py:82 com/models.py:309 core/models.py:957 +#: club/models.py:427 com/models.py:97 com/models.py:322 core/models.py:914 msgid "is moderated" msgstr "est modéré" -#: club/models.py:431 com/models.py:86 com/models.py:313 +#: club/models.py:431 com/models.py:101 com/models.py:326 msgid "moderator" msgstr "modérateur" -#: club/models.py:458 +#: club/models.py:457 msgid "This mailing list already exists." msgstr "Cette liste de diffusion existe déjà." -#: club/models.py:497 club/templates/club/mailing.jinja:23 +#: club/models.py:496 club/templates/club/mailing.jinja:23 msgid "Mailing" msgstr "Liste de diffusion" -#: club/models.py:521 +#: club/models.py:520 msgid "At least user or email is required" msgstr "Au moins un utilisateur ou un email est nécessaire" -#: club/models.py:529 club/tests.py:770 +#: club/models.py:528 club/tests.py:770 msgid "This email is already suscribed in this mailing" msgstr "Cet email est déjà abonné à cette mailing" -#: club/models.py:557 +#: club/models.py:556 msgid "Unregistered user" msgstr "Utilisateur non enregistré" @@ -1146,7 +1146,7 @@ msgid "There are no members in this club." msgstr "Il n'y a pas de membres dans ce club." #: club/templates/club/club_members.jinja:80 -#: core/templates/core/file_detail.jinja:19 core/views/forms.py:307 +#: core/templates/core/file_detail.jinja:19 core/views/forms.py:305 #: launderette/views.py:208 trombi/templates/trombi/detail.jinja:19 msgid "Add" msgstr "Ajouter" @@ -1426,80 +1426,96 @@ msgstr "Hebdomadaire" msgid "Call" msgstr "Appel" -#: com/models.py:67 com/models.py:174 com/models.py:248 +#: com/models.py:67 com/models.py:187 com/models.py:261 #: core/templates/core/macros.jinja:301 election/models.py:12 #: election/models.py:114 election/models.py:152 forum/models.py:256 #: forum/models.py:310 pedagogy/models.py:97 msgid "title" msgstr "titre" -#: com/models.py:68 +#: com/models.py:69 msgid "summary" msgstr "résumé" -#: com/models.py:69 com/models.py:249 trombi/models.py:188 +#: com/models.py:71 +msgid "" +"A description of the event (what is the activity ? is there an associated " +"clic ? is there a inscription form ?)" +msgstr "" +"Une description de l'évènement (quelle est l'activité ? Y a-t-il un clic " +"associé ? Y-a-t'il un formulaire d'inscription ?)" + +#: com/models.py:76 com/models.py:262 trombi/models.py:188 msgid "content" msgstr "contenu" -#: com/models.py:71 core/models.py:1510 launderette/models.py:88 +#: com/models.py:79 +msgid "A more detailed and exhaustive description of the event." +msgstr "Une description plus détaillée et exhaustive de l'évènement." + +#: com/models.py:82 core/models.py:1465 launderette/models.py:88 #: launderette/models.py:124 launderette/models.py:167 msgid "type" msgstr "type" -#: com/models.py:79 com/models.py:253 pedagogy/models.py:57 +#: com/models.py:89 +msgid "The club which organizes the event." +msgstr "Le club qui organise l'évènement." + +#: com/models.py:94 com/models.py:266 pedagogy/models.py:57 #: pedagogy/models.py:200 trombi/models.py:178 msgid "author" msgstr "auteur" -#: com/models.py:153 +#: com/models.py:166 msgid "news_date" msgstr "date de la nouvelle" -#: com/models.py:156 +#: com/models.py:169 msgid "start_date" msgstr "date de début" -#: com/models.py:157 +#: com/models.py:170 msgid "end_date" msgstr "date de fin" -#: com/models.py:175 +#: com/models.py:188 msgid "intro" msgstr "intro" -#: com/models.py:176 +#: com/models.py:189 msgid "joke" msgstr "blague" -#: com/models.py:177 +#: com/models.py:190 msgid "protip" msgstr "astuce" -#: com/models.py:178 +#: com/models.py:191 msgid "conclusion" msgstr "conclusion" -#: com/models.py:179 +#: com/models.py:192 msgid "sent" msgstr "envoyé" -#: com/models.py:244 +#: com/models.py:257 msgid "weekmail" msgstr "weekmail" -#: com/models.py:262 +#: com/models.py:275 msgid "rank" msgstr "rang" -#: com/models.py:295 core/models.py:922 core/models.py:972 +#: com/models.py:308 core/models.py:879 core/models.py:929 msgid "file" msgstr "fichier" -#: com/models.py:307 +#: com/models.py:320 msgid "display time" msgstr "temps d'affichage" -#: com/models.py:338 +#: com/models.py:349 msgid "Begin date should be before end date" msgstr "La date de début doit être avant celle de fin" @@ -1690,15 +1706,15 @@ msgstr "Éditer (sera soumise de nouveau à la modération)" msgid "Edit news" msgstr "Éditer la nouvelle" -#: com/templates/com/news_edit.jinja:39 +#: com/templates/com/news_edit.jinja:41 msgid "Notice: Information, election result - no date" msgstr "Information, résultat d'élection - sans date" -#: com/templates/com/news_edit.jinja:40 +#: com/templates/com/news_edit.jinja:42 msgid "Event: punctual event, associated with one date" msgstr "Événement : événement ponctuel associé à une date" -#: com/templates/com/news_edit.jinja:41 +#: com/templates/com/news_edit.jinja:44 msgid "" "Weekly: recurrent event, associated with many dates (specify the first one, " "and a deadline)" @@ -1706,14 +1722,14 @@ msgstr "" "Hebdomadaire : événement récurrent, associé à plusieurs dates (spécifier la " "première, ainsi que la date de fin)" -#: com/templates/com/news_edit.jinja:42 +#: com/templates/com/news_edit.jinja:50 msgid "" -"Call: long time event, associated with a long date (election appliance, ...)" +"Call: long time event, associated with a long date (like election appliance)" msgstr "" -"Appel : événement de longue durée, associé à une longue date (candidature, " -"concours, ...)" +"Appel : événement de longue durée, associé à une longue date (comme des " +"candidatures à une élection)" -#: com/templates/com/news_edit.jinja:56 com/templates/com/weekmail.jinja:10 +#: com/templates/com/news_edit.jinja:102 com/templates/com/weekmail.jinja:10 msgid "Preview" msgstr "Prévisualiser" @@ -1750,7 +1766,7 @@ msgstr "Anniversaires" msgid "%(age)s year old" msgstr "%(age)s ans" -#: com/templates/com/news_list.jinja:156 com/tests.py:103 com/tests.py:113 +#: com/templates/com/news_list.jinja:156 com/tests.py:101 com/tests.py:111 msgid "You need an up to date subscription to access this content" msgstr "Votre cotisation doit être à jour pour accéder à cette section" @@ -1952,261 +1968,219 @@ msgstr "Jusqu'à" msgid "Automoderation" msgstr "Automodération" -#: com/views.py:213 com/views.py:217 com/views.py:231 +#: com/views.py:213 com/views.py:217 com/views.py:229 msgid "This field is required." msgstr "Ce champ est obligatoire." -#: com/views.py:227 -msgid "You crazy? You can not finish an event before starting it." -msgstr "T'es fou? Un événement ne peut pas finir avant même de commencer." +#: com/views.py:226 +msgid "An event cannot end before its beginning." +msgstr "Un évènement ne peut pas se finir avant d'avoir commencé." -#: com/views.py:451 +#: com/views.py:445 msgid "Delete and save to regenerate" msgstr "Supprimer et sauver pour régénérer" -#: com/views.py:466 +#: com/views.py:460 msgid "Weekmail of the " msgstr "Weekmail du " -#: com/views.py:570 +#: com/views.py:564 msgid "" "You must be a board member of the selected club to post in the Weekmail." msgstr "" "Vous devez êtres un membre du bureau du club sélectionné pour poster dans le " "Weekmail." -#: core/models.py:85 +#: core/models.py:79 msgid "meta group status" msgstr "status du meta-groupe" -#: core/models.py:87 +#: core/models.py:81 msgid "Whether a group is a meta group or not" msgstr "Si un groupe est un meta-groupe ou pas" -#: core/models.py:173 +#: core/models.py:167 #, python-format msgid "%(value)s is not a valid promo (between 0 and %(end)s)" msgstr "%(value)s n'est pas une promo valide (doit être entre 0 et %(end)s)" -#: core/models.py:257 -msgid "username" -msgstr "nom d'utilisateur" - -#: core/models.py:261 -msgid "Required. 254 characters or fewer. Letters, digits and ./+/-/_ only." -msgstr "" -"Requis. Pas plus de 254 caractères. Uniquement des lettres, numéros, et ./" -"+/-/_" - -#: core/models.py:267 -msgid "" -"Enter a valid username. This value may contain only letters, numbers and ./" -"+/-/_ characters." -msgstr "" -"Entrez un nom d'utilisateur correct. Uniquement des lettres, numéros, et ./" -"+/-/_" - -#: core/models.py:273 -msgid "A user with that username already exists." -msgstr "Un utilisateur de ce nom existe déjà" - -#: core/models.py:275 +#: core/models.py:250 msgid "first name" msgstr "Prénom" -#: core/models.py:276 +#: core/models.py:251 msgid "last name" msgstr "Nom" -#: core/models.py:277 +#: core/models.py:252 msgid "email address" msgstr "adresse email" -#: core/models.py:278 +#: core/models.py:253 msgid "date of birth" msgstr "date de naissance" -#: core/models.py:279 +#: core/models.py:254 msgid "nick name" msgstr "surnom" -#: core/models.py:281 -msgid "staff status" -msgstr "status \"staff\"" - -#: core/models.py:283 -msgid "Designates whether the user can log into this admin site." -msgstr "Est-ce que l'utilisateur peut se logger à la partie admin du site." - -#: core/models.py:286 -msgid "active" -msgstr "actif" - -#: core/models.py:289 -msgid "" -"Designates whether this user should be treated as active. Unselect this " -"instead of deleting accounts." -msgstr "" -"Est-ce que l'utilisateur doit être traité comme actif. Désélectionnez au " -"lieu de supprimer les comptes." - -#: core/models.py:293 -msgid "date joined" -msgstr "date d'inscription" - -#: core/models.py:294 +#: core/models.py:255 msgid "last update" msgstr "dernière mise à jour" -#: core/models.py:296 -msgid "superuser" -msgstr "super-utilisateur" +#: core/models.py:258 +msgid "groups" +msgstr "groupes" -#: core/models.py:298 -msgid "Designates whether this user is a superuser. " -msgstr "Est-ce que l'utilisateur est super-utilisateur." +#: core/models.py:260 +msgid "" +"The groups this user belongs to. A user will get all permissions granted to " +"each of their groups." +msgstr "" +"Les groupes auxquels cet utilisateur appartient. Un utilisateur aura toutes les " +"permissions de chacun de ses groupes." -#: core/models.py:312 +#: core/models.py:277 msgid "profile" msgstr "profil" -#: core/models.py:320 +#: core/models.py:285 msgid "avatar" msgstr "avatar" -#: core/models.py:328 +#: core/models.py:293 msgid "scrub" msgstr "blouse" -#: core/models.py:334 +#: core/models.py:299 msgid "sex" msgstr "Genre" -#: core/models.py:338 +#: core/models.py:303 msgid "Man" msgstr "Homme" -#: core/models.py:338 +#: core/models.py:303 msgid "Woman" msgstr "Femme" -#: core/models.py:340 +#: core/models.py:305 msgid "pronouns" msgstr "pronoms" -#: core/models.py:342 +#: core/models.py:307 msgid "tshirt size" msgstr "taille de t-shirt" -#: core/models.py:345 +#: core/models.py:310 msgid "-" msgstr "-" -#: core/models.py:346 +#: core/models.py:311 msgid "XS" msgstr "XS" -#: core/models.py:347 +#: core/models.py:312 msgid "S" msgstr "S" -#: core/models.py:348 +#: core/models.py:313 msgid "M" msgstr "M" -#: core/models.py:349 +#: core/models.py:314 msgid "L" msgstr "L" -#: core/models.py:350 +#: core/models.py:315 msgid "XL" msgstr "XL" -#: core/models.py:351 +#: core/models.py:316 msgid "XXL" msgstr "XXL" -#: core/models.py:352 +#: core/models.py:317 msgid "XXXL" msgstr "XXXL" -#: core/models.py:360 +#: core/models.py:325 msgid "Student" msgstr "Étudiant" -#: core/models.py:361 +#: core/models.py:326 msgid "Administrative agent" msgstr "Personnel administratif" -#: core/models.py:362 +#: core/models.py:327 msgid "Teacher" msgstr "Enseignant" -#: core/models.py:363 +#: core/models.py:328 msgid "Agent" msgstr "Personnel" -#: core/models.py:364 +#: core/models.py:329 msgid "Doctor" msgstr "Doctorant" -#: core/models.py:365 +#: core/models.py:330 msgid "Former student" msgstr "Ancien étudiant" -#: core/models.py:366 +#: core/models.py:331 msgid "Service" msgstr "Service" -#: core/models.py:372 +#: core/models.py:337 msgid "department" msgstr "département" -#: core/models.py:379 +#: core/models.py:344 msgid "dpt option" msgstr "Filière" -#: core/models.py:381 pedagogy/models.py:70 pedagogy/models.py:294 +#: core/models.py:346 pedagogy/models.py:70 pedagogy/models.py:294 msgid "semester" msgstr "semestre" -#: core/models.py:382 +#: core/models.py:347 msgid "quote" msgstr "citation" -#: core/models.py:383 +#: core/models.py:348 msgid "school" msgstr "école" -#: core/models.py:385 +#: core/models.py:350 msgid "promo" msgstr "promo" -#: core/models.py:388 +#: core/models.py:353 msgid "forum signature" msgstr "signature du forum" -#: core/models.py:390 +#: core/models.py:355 msgid "second email address" msgstr "adresse email secondaire" -#: core/models.py:392 +#: core/models.py:357 msgid "parent phone" msgstr "téléphone des parents" -#: core/models.py:395 +#: core/models.py:360 msgid "parent address" msgstr "adresse des parents" -#: core/models.py:398 +#: core/models.py:363 msgid "is subscriber viewable" msgstr "profil visible par les cotisants" -#: core/models.py:594 +#: core/models.py:556 msgid "A user with that username already exists" msgstr "Un utilisateur de ce nom d'utilisateur existe déjà" -#: core/models.py:761 core/templates/core/macros.jinja:80 +#: core/models.py:718 core/templates/core/macros.jinja:80 #: core/templates/core/macros.jinja:84 core/templates/core/macros.jinja:85 #: core/templates/core/user_detail.jinja:100 #: core/templates/core/user_detail.jinja:101 @@ -2226,101 +2200,101 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà" msgid "Profile" msgstr "Profil" -#: core/models.py:872 +#: core/models.py:829 msgid "Visitor" msgstr "Visiteur" -#: core/models.py:879 +#: core/models.py:836 msgid "receive the Weekmail" msgstr "recevoir le Weekmail" -#: core/models.py:880 +#: core/models.py:837 msgid "show your stats to others" msgstr "montrez vos statistiques aux autres" -#: core/models.py:882 +#: core/models.py:839 msgid "get a notification for every click" msgstr "avoir une notification pour chaque click" -#: core/models.py:885 +#: core/models.py:842 msgid "get a notification for every refilling" msgstr "avoir une notification pour chaque rechargement" -#: core/models.py:911 sas/forms.py:81 +#: core/models.py:868 sas/forms.py:81 msgid "file name" msgstr "nom du fichier" -#: core/models.py:915 core/models.py:1268 +#: core/models.py:872 core/models.py:1223 msgid "parent" msgstr "parent" -#: core/models.py:929 +#: core/models.py:886 msgid "compressed file" msgstr "version allégée" -#: core/models.py:936 +#: core/models.py:893 msgid "thumbnail" msgstr "miniature" -#: core/models.py:944 core/models.py:961 +#: core/models.py:901 core/models.py:918 msgid "owner" msgstr "propriétaire" -#: core/models.py:948 core/models.py:1285 +#: core/models.py:905 core/models.py:1240 msgid "edit group" msgstr "groupe d'édition" -#: core/models.py:951 core/models.py:1288 +#: core/models.py:908 core/models.py:1243 msgid "view group" msgstr "groupe de vue" -#: core/models.py:953 +#: core/models.py:910 msgid "is folder" msgstr "est un dossier" -#: core/models.py:954 +#: core/models.py:911 msgid "mime type" msgstr "type mime" -#: core/models.py:955 +#: core/models.py:912 msgid "size" msgstr "taille" -#: core/models.py:966 +#: core/models.py:923 msgid "asked for removal" msgstr "retrait demandé" -#: core/models.py:968 +#: core/models.py:925 msgid "is in the SAS" msgstr "est dans le SAS" -#: core/models.py:1037 +#: core/models.py:992 msgid "Character '/' not authorized in name" msgstr "Le caractère '/' n'est pas autorisé dans les noms de fichier" -#: core/models.py:1039 core/models.py:1043 +#: core/models.py:994 core/models.py:998 msgid "Loop in folder tree" msgstr "Boucle dans l'arborescence des dossiers" -#: core/models.py:1046 +#: core/models.py:1001 msgid "You can not make a file be a children of a non folder file" msgstr "" "Vous ne pouvez pas mettre un fichier enfant de quelque chose qui n'est pas " "un dossier" -#: core/models.py:1057 +#: core/models.py:1012 msgid "Duplicate file" msgstr "Un fichier de ce nom existe déjà" -#: core/models.py:1074 +#: core/models.py:1029 msgid "You must provide a file" msgstr "Vous devez fournir un fichier" -#: core/models.py:1251 +#: core/models.py:1206 msgid "page unix name" msgstr "nom unix de la page" -#: core/models.py:1257 +#: core/models.py:1212 msgid "" "Enter a valid page name. This value may contain only unaccented letters, " "numbers and ./+/-/_ characters." @@ -2328,55 +2302,55 @@ msgstr "" "Entrez un nom de page correct. Uniquement des lettres non accentuées, " "numéros, et ./+/-/_" -#: core/models.py:1275 +#: core/models.py:1230 msgid "page name" msgstr "nom de la page" -#: core/models.py:1280 +#: core/models.py:1235 msgid "owner group" msgstr "groupe propriétaire" -#: core/models.py:1293 +#: core/models.py:1248 msgid "lock user" msgstr "utilisateur bloquant" -#: core/models.py:1300 +#: core/models.py:1255 msgid "lock_timeout" msgstr "décompte du déblocage" -#: core/models.py:1350 +#: core/models.py:1305 msgid "Duplicate page" msgstr "Une page de ce nom existe déjà" -#: core/models.py:1353 +#: core/models.py:1308 msgid "Loop in page tree" msgstr "Boucle dans l'arborescence des pages" -#: core/models.py:1464 +#: core/models.py:1419 msgid "revision" msgstr "révision" -#: core/models.py:1465 +#: core/models.py:1420 msgid "page title" msgstr "titre de la page" -#: core/models.py:1466 +#: core/models.py:1421 msgid "page content" msgstr "contenu de la page" -#: core/models.py:1507 +#: core/models.py:1462 msgid "url" msgstr "url" -#: core/models.py:1508 +#: core/models.py:1463 msgid "param" msgstr "param" -#: core/models.py:1513 +#: core/models.py:1468 msgid "viewed" msgstr "vue" -#: core/models.py:1571 +#: core/models.py:1526 msgid "operation type" msgstr "type d'opération" @@ -2501,7 +2475,7 @@ msgstr "Forum" msgid "Gallery" msgstr "Photos" -#: core/templates/core/base/navbar.jinja:22 counter/models.py:490 +#: core/templates/core/base/navbar.jinja:22 counter/models.py:491 #: counter/templates/counter/counter_list.jinja:11 #: eboutic/templates/eboutic/eboutic_main.jinja:4 #: eboutic/templates/eboutic/eboutic_main.jinja:22 @@ -2527,7 +2501,7 @@ msgid "Launderette" msgstr "Laverie" #: core/templates/core/base/navbar.jinja:28 core/templates/core/file.jinja:24 -#: core/views/files.py:121 +#: core/views/files.py:122 msgid "Files" msgstr "Fichiers" @@ -3492,16 +3466,16 @@ msgid_plural "%(nb_days)d days, %(remainder)s" msgstr[0] "" msgstr[1] "" -#: core/views/files.py:118 +#: core/views/files.py:119 msgid "Add a new folder" msgstr "Ajouter un nouveau dossier" -#: core/views/files.py:138 +#: core/views/files.py:139 #, python-format msgid "Error creating folder %(folder_name)s: %(msg)s" msgstr "Erreur de création du dossier %(folder_name)s : %(msg)s" -#: core/views/files.py:158 core/views/forms.py:272 core/views/forms.py:279 +#: core/views/files.py:159 core/views/forms.py:270 core/views/forms.py:277 #: sas/forms.py:60 #, python-format msgid "Error uploading file %(file_name)s: %(msg)s" @@ -3523,7 +3497,7 @@ msgstr "Choisir un utilisateur" msgid "Username, email, or account number" msgstr "Nom d'utilisateur, email, ou numéro de compte AE" -#: core/views/forms.py:222 +#: core/views/forms.py:220 msgid "" "Profile: you need to be visible on the picture, in order to be recognized (e." "g. by the barmen)" @@ -3531,53 +3505,53 @@ msgstr "" "Photo de profil: vous devez être visible sur la photo afin d'être reconnu " "(par exemple par les barmen)" -#: core/views/forms.py:227 +#: core/views/forms.py:225 msgid "Avatar: used on the forum" msgstr "Avatar : utilisé sur le forum" -#: core/views/forms.py:231 +#: core/views/forms.py:229 msgid "Scrub: let other know how your scrub looks like!" msgstr "Blouse : montrez aux autres à quoi ressemble votre blouse !" -#: core/views/forms.py:283 +#: core/views/forms.py:281 msgid "Bad image format, only jpeg, png, webp and gif are accepted" msgstr "Mauvais format d'image, seuls les jpeg, png, webp et gif sont acceptés" -#: core/views/forms.py:304 +#: core/views/forms.py:302 msgid "Godfather / Godmother" msgstr "Parrain / Marraine" -#: core/views/forms.py:305 +#: core/views/forms.py:303 msgid "Godchild" msgstr "Fillot / Fillote" -#: core/views/forms.py:310 counter/forms.py:78 trombi/views.py:151 +#: core/views/forms.py:308 counter/forms.py:78 trombi/views.py:151 msgid "Select user" msgstr "Choisir un utilisateur" -#: core/views/forms.py:324 +#: core/views/forms.py:322 msgid "This user does not exist" msgstr "Cet utilisateur n'existe pas" -#: core/views/forms.py:326 +#: core/views/forms.py:324 msgid "You cannot be related to yourself" msgstr "Vous ne pouvez pas être relié à vous-même" -#: core/views/forms.py:338 +#: core/views/forms.py:336 #, python-format msgid "%s is already your godfather" msgstr "%s est déjà votre parrain/marraine" -#: core/views/forms.py:344 +#: core/views/forms.py:342 #, python-format msgid "%s is already your godchild" msgstr "%s est déjà votre fillot/fillote" -#: core/views/group.py:41 +#: core/views/group.py:39 msgid "Users to add to group" msgstr "Utilisateurs à ajouter au groupe" -#: core/views/group.py:50 +#: core/views/group.py:48 msgid "Users to remove from group" msgstr "Utilisateurs à retirer du groupe" @@ -3607,13 +3581,13 @@ msgstr "Chèque" msgid "Cash" msgstr "Espèces" -#: counter/apps.py:30 counter/models.py:801 sith/settings.py:415 +#: counter/apps.py:30 counter/models.py:802 sith/settings.py:415 #: sith/settings.py:420 msgid "Credit card" msgstr "Carte bancaire" -#: counter/apps.py:36 counter/models.py:506 counter/models.py:962 -#: counter/models.py:998 launderette/models.py:32 +#: counter/apps.py:36 counter/models.py:507 counter/models.py:963 +#: counter/models.py:999 launderette/models.py:32 msgid "counter" msgstr "comptoir" @@ -3633,184 +3607,184 @@ msgstr "Votre compte AE a été vidé" msgid "Clearing of your AE account" msgstr "Vidange de votre compte AE" -#: counter/migrations/0013_customer_recorded_products.py:25 -msgid "Ecocup regularization" -msgstr "Régularization des ecocups" - -#: counter/models.py:91 +#: counter/models.py:92 msgid "account id" msgstr "numéro de compte" -#: counter/models.py:93 +#: counter/models.py:94 msgid "recorded product" msgstr "produits consignés" -#: counter/models.py:98 +#: counter/models.py:99 msgid "customer" msgstr "client" -#: counter/models.py:99 +#: counter/models.py:100 msgid "customers" msgstr "clients" -#: counter/models.py:111 counter/views/click.py:68 +#: counter/models.py:112 counter/views/click.py:68 msgid "Not enough money" msgstr "Solde insuffisant" -#: counter/models.py:197 +#: counter/models.py:198 msgid "First name" msgstr "Prénom" -#: counter/models.py:198 +#: counter/models.py:199 msgid "Last name" msgstr "Nom de famille" -#: counter/models.py:199 +#: counter/models.py:200 msgid "Address 1" msgstr "Adresse 1" -#: counter/models.py:200 +#: counter/models.py:201 msgid "Address 2" msgstr "Adresse 2" -#: counter/models.py:201 +#: counter/models.py:202 msgid "Zip code" msgstr "Code postal" -#: counter/models.py:202 +#: counter/models.py:203 msgid "City" msgstr "Ville" -#: counter/models.py:203 +#: counter/models.py:204 msgid "Country" msgstr "Pays" -#: counter/models.py:211 +#: counter/models.py:212 msgid "Phone number" msgstr "Numéro de téléphone" -#: counter/models.py:253 +#: counter/models.py:254 msgid "When the mail warning that the account was about to be dumped was sent." msgstr "Quand le mail d'avertissement de la vidange du compte a été envoyé." -#: counter/models.py:258 +#: counter/models.py:259 msgid "Set this to True if the warning mail received an error" msgstr "Mettre à True si le mail a reçu une erreur" -#: counter/models.py:265 +#: counter/models.py:266 msgid "The operation that emptied the account." msgstr "L'opération qui a vidé le compte." -#: counter/models.py:310 counter/models.py:334 +#: counter/models.py:304 +msgid "A text that will be shown on the eboutic." +msgstr "Un texte qui sera affiché sur l'eboutic." + +#: counter/models.py:311 counter/models.py:335 msgid "product type" msgstr "type du produit" -#: counter/models.py:341 +#: counter/models.py:342 msgid "purchase price" msgstr "prix d'achat" -#: counter/models.py:342 +#: counter/models.py:343 msgid "Initial cost of purchasing the product" msgstr "Coût initial d'achat du produit" -#: counter/models.py:344 +#: counter/models.py:345 msgid "selling price" msgstr "prix de vente" -#: counter/models.py:346 +#: counter/models.py:347 msgid "special selling price" msgstr "prix de vente spécial" -#: counter/models.py:347 +#: counter/models.py:348 msgid "Price for barmen during their permanence" msgstr "Prix pour les barmen durant leur permanence" -#: counter/models.py:355 +#: counter/models.py:356 msgid "icon" msgstr "icône" -#: counter/models.py:360 +#: counter/models.py:361 msgid "limit age" msgstr "âge limite" -#: counter/models.py:361 +#: counter/models.py:362 msgid "tray price" msgstr "prix plateau" -#: counter/models.py:363 +#: counter/models.py:364 msgid "buying groups" msgstr "groupe d'achat" -#: counter/models.py:365 election/models.py:50 +#: counter/models.py:366 election/models.py:50 msgid "archived" msgstr "archivé" -#: counter/models.py:368 counter/models.py:1096 +#: counter/models.py:369 counter/models.py:1097 msgid "product" msgstr "produit" -#: counter/models.py:485 +#: counter/models.py:486 msgid "products" msgstr "produits" -#: counter/models.py:488 +#: counter/models.py:489 msgid "counter type" msgstr "type de comptoir" -#: counter/models.py:490 +#: counter/models.py:491 msgid "Bar" msgstr "Bar" -#: counter/models.py:490 +#: counter/models.py:491 msgid "Office" msgstr "Bureau" -#: counter/models.py:493 +#: counter/models.py:494 msgid "sellers" msgstr "vendeurs" -#: counter/models.py:501 launderette/models.py:178 +#: counter/models.py:502 launderette/models.py:178 msgid "token" msgstr "jeton" -#: counter/models.py:701 +#: counter/models.py:702 msgid "bank" msgstr "banque" -#: counter/models.py:703 counter/models.py:804 +#: counter/models.py:704 counter/models.py:805 msgid "is validated" msgstr "est validé" -#: counter/models.py:708 +#: counter/models.py:709 msgid "refilling" msgstr "rechargement" -#: counter/models.py:781 eboutic/models.py:249 +#: counter/models.py:782 eboutic/models.py:249 msgid "unit price" msgstr "prix unitaire" -#: counter/models.py:782 counter/models.py:1076 eboutic/models.py:250 +#: counter/models.py:783 counter/models.py:1077 eboutic/models.py:250 msgid "quantity" msgstr "quantité" -#: counter/models.py:801 +#: counter/models.py:802 msgid "Sith account" msgstr "Compte utilisateur" -#: counter/models.py:809 +#: counter/models.py:810 msgid "selling" msgstr "vente" -#: counter/models.py:913 +#: counter/models.py:914 msgid "Unknown event" msgstr "Événement inconnu" -#: counter/models.py:914 +#: counter/models.py:915 #, python-format msgid "Eticket bought for the event %(event)s" msgstr "Eticket acheté pour l'événement %(event)s" -#: counter/models.py:916 counter/models.py:929 +#: counter/models.py:917 counter/models.py:930 #, python-format msgid "" "You bought an eticket for the event %(event)s.\n" @@ -3822,67 +3796,67 @@ msgstr "" "Vous pouvez également retrouver tous vos e-tickets sur votre page de compte " "%(url)s." -#: counter/models.py:967 +#: counter/models.py:968 msgid "last activity date" msgstr "dernière activité" -#: counter/models.py:970 +#: counter/models.py:971 msgid "permanency" msgstr "permanence" -#: counter/models.py:1003 +#: counter/models.py:1004 msgid "emptied" msgstr "coffre vidée" -#: counter/models.py:1006 +#: counter/models.py:1007 msgid "cash register summary" msgstr "relevé de caisse" -#: counter/models.py:1072 +#: counter/models.py:1073 msgid "cash summary" msgstr "relevé" -#: counter/models.py:1075 +#: counter/models.py:1076 msgid "value" msgstr "valeur" -#: counter/models.py:1078 +#: counter/models.py:1079 msgid "check" msgstr "chèque" -#: counter/models.py:1080 +#: counter/models.py:1081 msgid "True if this is a bank check, else False" msgstr "Vrai si c'est un chèque, sinon Faux." -#: counter/models.py:1084 +#: counter/models.py:1085 msgid "cash register summary item" msgstr "élément de relevé de caisse" -#: counter/models.py:1100 +#: counter/models.py:1101 msgid "banner" msgstr "bannière" -#: counter/models.py:1102 +#: counter/models.py:1103 msgid "event date" msgstr "date de l'événement" -#: counter/models.py:1104 +#: counter/models.py:1105 msgid "event title" msgstr "titre de l'événement" -#: counter/models.py:1106 +#: counter/models.py:1107 msgid "secret" msgstr "secret" -#: counter/models.py:1145 +#: counter/models.py:1146 msgid "uid" msgstr "uid" -#: counter/models.py:1150 counter/models.py:1155 +#: counter/models.py:1151 counter/models.py:1156 msgid "student card" msgstr "carte étudiante" -#: counter/models.py:1156 +#: counter/models.py:1157 msgid "student cards" msgstr "cartes étudiantes" @@ -4193,17 +4167,30 @@ msgstr "Sans catégorie" msgid "There is no products in this website." msgstr "Il n'y a pas de produits dans ce site web." -#: counter/templates/counter/producttype_list.jinja:4 -#: counter/templates/counter/producttype_list.jinja:10 +#: counter/templates/counter/product_type_list.jinja:4 +#: counter/templates/counter/product_type_list.jinja:42 msgid "Product type list" msgstr "Liste des types de produit" -#: counter/templates/counter/producttype_list.jinja:8 +#: counter/templates/counter/product_type_list.jinja:18 msgid "New product type" msgstr "Nouveau type de produit" -#: counter/templates/counter/producttype_list.jinja:17 -msgid "There is no product types in this website." +#: counter/templates/counter/product_type_list.jinja:25 +msgid "Product types are in the same order on this page and on the eboutic." +msgstr "" +"Les types de produit sont dans le même ordre sur cette page et sur l'eboutic." + +#: counter/templates/counter/product_type_list.jinja:28 +msgid "" +"You can reorder them here by drag-and-drop. The changes will then be applied " +"globally immediately." +msgstr "" +"Vous pouvez les réorganiser ici. Les changements seront alors immédiatement " +"appliqués globalement." + +#: counter/templates/counter/product_type_list.jinja:61 +msgid "There are no product types in this website." msgstr "Il n'y a pas de types de produit dans ce site web." #: counter/templates/counter/refilling_list.jinja:15 diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index e907e571..414bb603 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-14 10:24+0100\n" +"POT-Creation-Date: 2024-12-17 00:46+0100\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -17,119 +17,128 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" +#: core/static/bundled/core/components/ajax-select-base.ts:68 +msgid "Remove" +msgstr "Retirer" + +#: core/static/bundled/core/components/ajax-select-base.ts:90 +msgid "You need to type %(number)s more characters" +msgstr "Vous devez taper %(number)s caractères de plus" + +#: core/static/bundled/core/components/ajax-select-base.ts:94 +msgid "No results found" +msgstr "Aucun résultat trouvé" + +#: core/static/bundled/core/components/easymde-index.ts:38 +msgid "Heading" +msgstr "Titre" + +#: core/static/bundled/core/components/easymde-index.ts:44 +msgid "Italic" +msgstr "Italique" + +#: core/static/bundled/core/components/easymde-index.ts:50 +msgid "Bold" +msgstr "Gras" + +#: core/static/bundled/core/components/easymde-index.ts:56 +msgid "Strikethrough" +msgstr "Barré" + +#: core/static/bundled/core/components/easymde-index.ts:65 +msgid "Underline" +msgstr "Souligné" + +#: core/static/bundled/core/components/easymde-index.ts:74 +msgid "Superscript" +msgstr "Exposant" + +#: core/static/bundled/core/components/easymde-index.ts:83 +msgid "Subscript" +msgstr "Indice" + +#: core/static/bundled/core/components/easymde-index.ts:89 +msgid "Code" +msgstr "Code" + +#: core/static/bundled/core/components/easymde-index.ts:96 +msgid "Quote" +msgstr "Citation" + +#: core/static/bundled/core/components/easymde-index.ts:102 +msgid "Unordered list" +msgstr "Liste non ordonnée" + +#: core/static/bundled/core/components/easymde-index.ts:108 +msgid "Ordered list" +msgstr "Liste ordonnée" + +#: core/static/bundled/core/components/easymde-index.ts:115 +msgid "Insert link" +msgstr "Insérer lien" + +#: core/static/bundled/core/components/easymde-index.ts:121 +msgid "Insert image" +msgstr "Insérer image" + +#: core/static/bundled/core/components/easymde-index.ts:127 +msgid "Insert table" +msgstr "Insérer tableau" + +#: core/static/bundled/core/components/easymde-index.ts:134 +msgid "Clean block" +msgstr "Nettoyer bloc" + +#: core/static/bundled/core/components/easymde-index.ts:141 +msgid "Toggle preview" +msgstr "Activer la prévisualisation" + +#: core/static/bundled/core/components/easymde-index.ts:147 +msgid "Toggle side by side" +msgstr "Activer la vue côte à côte" + +#: core/static/bundled/core/components/easymde-index.ts:153 +msgid "Toggle fullscreen" +msgstr "Activer le plein écran" + +#: core/static/bundled/core/components/easymde-index.ts:160 +msgid "Markdown guide" +msgstr "Guide markdown" + +#: core/static/bundled/core/components/nfc-input-index.ts:26 +msgid "Unsupported NFC card" +msgstr "Carte NFC non supportée" + +#: core/static/bundled/user/family-graph-index.js:233 +msgid "family_tree.%(extension)s" +msgstr "arbre_genealogique.%(extension)s" + +#: core/static/bundled/user/pictures-index.js:76 +msgid "pictures.%(extension)s" +msgstr "photos.%(extension)s" + #: core/static/user/js/user_edit.js:91 #, javascript-format msgid "captured.%s" msgstr "capture.%s" -#: core/static/webpack/core/components/ajax-select-base.ts:68 -msgid "Remove" -msgstr "Retirer" +#: counter/static/bundled/counter/product-type-index.ts:36 +msgid "Products types reordered!" +msgstr "Types de produits réordonnés !" -#: core/static/webpack/core/components/ajax-select-base.ts:88 -msgid "You need to type %(number)s more characters" -msgstr "Vous devez taper %(number)s caractères de plus" - -#: core/static/webpack/core/components/ajax-select-base.ts:92 -msgid "No results found" -msgstr "Aucun résultat trouvé" - -#: core/static/webpack/core/components/easymde-index.ts:38 -msgid "Heading" -msgstr "Titre" - -#: core/static/webpack/core/components/easymde-index.ts:44 -msgid "Italic" -msgstr "Italique" - -#: core/static/webpack/core/components/easymde-index.ts:50 -msgid "Bold" -msgstr "Gras" - -#: core/static/webpack/core/components/easymde-index.ts:56 -msgid "Strikethrough" -msgstr "Barré" - -#: core/static/webpack/core/components/easymde-index.ts:65 -msgid "Underline" -msgstr "Souligné" - -#: core/static/webpack/core/components/easymde-index.ts:74 -msgid "Superscript" -msgstr "Exposant" - -#: core/static/webpack/core/components/easymde-index.ts:83 -msgid "Subscript" -msgstr "Indice" - -#: core/static/webpack/core/components/easymde-index.ts:89 -msgid "Code" -msgstr "Code" - -#: core/static/webpack/core/components/easymde-index.ts:96 -msgid "Quote" -msgstr "Citation" - -#: core/static/webpack/core/components/easymde-index.ts:102 -msgid "Unordered list" -msgstr "Liste non ordonnée" - -#: core/static/webpack/core/components/easymde-index.ts:108 -msgid "Ordered list" -msgstr "Liste ordonnée" - -#: core/static/webpack/core/components/easymde-index.ts:115 -msgid "Insert link" -msgstr "Insérer lien" - -#: core/static/webpack/core/components/easymde-index.ts:121 -msgid "Insert image" -msgstr "Insérer image" - -#: core/static/webpack/core/components/easymde-index.ts:127 -msgid "Insert table" -msgstr "Insérer tableau" - -#: core/static/webpack/core/components/easymde-index.ts:134 -msgid "Clean block" -msgstr "Nettoyer bloc" - -#: core/static/webpack/core/components/easymde-index.ts:141 -msgid "Toggle preview" -msgstr "Activer la prévisualisation" - -#: core/static/webpack/core/components/easymde-index.ts:147 -msgid "Toggle side by side" -msgstr "Activer la vue côte à côte" - -#: core/static/webpack/core/components/easymde-index.ts:153 -msgid "Toggle fullscreen" -msgstr "Activer le plein écran" - -#: core/static/webpack/core/components/easymde-index.ts:160 -msgid "Markdown guide" -msgstr "Guide markdown" - -#: core/static/webpack/core/components/nfc-input-index.ts:24 -msgid "Unsupported NFC card" -msgstr "Carte NFC non supportée" - -#: core/static/webpack/user/family-graph-index.js:233 -msgid "family_tree.%(extension)s" -msgstr "arbre_genealogique.%(extension)s" - -#: core/static/webpack/user/pictures-index.js:76 -msgid "pictures.%(extension)s" -msgstr "photos.%(extension)s" +#: counter/static/bundled/counter/product-type-index.ts:40 +#, javascript-format +msgid "Product type reorganisation failed with status code : %d" +msgstr "La réorganisation des types de produit a échoué avec le code : %d" #: eboutic/static/eboutic/js/makecommand.js:56 msgid "Incorrect value" msgstr "Valeur incorrecte" -#: sas/static/webpack/sas/viewer-index.ts:271 +#: sas/static/bundled/sas/viewer-index.ts:271 msgid "Couldn't moderate picture" msgstr "Il n'a pas été possible de modérer l'image" -#: sas/static/webpack/sas/viewer-index.ts:284 +#: sas/static/bundled/sas/viewer-index.ts:284 msgid "Couldn't delete picture" msgstr "Il n'a pas été possible de supprimer l'image" diff --git a/package-lock.json b/package-lock.json index 05418a69..c46ef180 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,13 @@ "version": "3", "license": "GPL-3.0-only", "dependencies": { + "@alpinejs/sort": "^3.14.7", "@fortawesome/fontawesome-free": "^6.6.0", "@hey-api/client-fetch": "^0.4.0", "@sentry/browser": "^8.34.0", "@zip.js/zip.js": "^2.7.52", "3d-force-graph": "^1.73.4", - "alpinejs": "^3.14.1", + "alpinejs": "^3.14.7", "chart.js": "^4.4.4", "cytoscape": "^3.30.2", "cytoscape-cxtmenu": "^3.5.0", @@ -44,6 +45,12 @@ "vite-plugin-static-copy": "^2.1.0" } }, + "node_modules/@alpinejs/sort": { + "version": "3.14.7", + "resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.14.7.tgz", + "integrity": "sha512-EJzxTBSoKvOxKHAUFeTSgxJR4rJQQPm10b4dB38kGcsxjUtOeNkbBF3xV4nlc0ZyTv7DarTWdppdoR/iP8jfdQ==", + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -3064,9 +3071,10 @@ } }, "node_modules/alpinejs": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.1.tgz", - "integrity": "sha512-ICar8UsnRZAYvv/fCNfNeKMXNoXGUfwHrjx7LqXd08zIP95G2d9bAOuaL97re+1mgt/HojqHsfdOLo/A5LuWgQ==", + "version": "3.14.7", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.7.tgz", + "integrity": "sha512-ScnbydNBcWVnCiVupD3wWUvoMPm8244xkvDNMxVCspgmap9m4QuJ7pjc+77UtByU+1+Ejg0wzYkP4mQaOMcvng==", + "license": "MIT", "dependencies": { "@vue/reactivity": "~3.1.1" } diff --git a/package.json b/package.json index 2ca46967..77572a6f 100644 --- a/package.json +++ b/package.json @@ -33,12 +33,13 @@ "vite-plugin-static-copy": "^2.1.0" }, "dependencies": { + "@alpinejs/sort": "^3.14.7", "@fortawesome/fontawesome-free": "^6.6.0", "@hey-api/client-fetch": "^0.4.0", "@sentry/browser": "^8.34.0", "@zip.js/zip.js": "^2.7.52", "3d-force-graph": "^1.73.4", - "alpinejs": "^3.14.1", + "alpinejs": "^3.14.7", "chart.js": "^4.4.4", "cytoscape": "^3.30.2", "cytoscape-cxtmenu": "^3.5.0", diff --git a/pedagogy/migrations/0002_auto_20190827_2251.py b/pedagogy/migrations/0002_auto_20190827_2251.py index 2b1f4b36..7fbe05a8 100644 --- a/pedagogy/migrations/0002_auto_20190827_2251.py +++ b/pedagogy/migrations/0002_auto_20190827_2251.py @@ -25,26 +25,11 @@ from __future__ import unicode_literals from django.db import migrations -from core.models import User - - -def remove_multiples_comments_from_same_user(apps, schema_editor): - for user in User.objects.exclude(uv_comments=None).prefetch_related("uv_comments"): - for uv in user.uv_comments.values("uv").distinct(): - last = ( - user.uv_comments.filter(uv__id=uv["uv"]) - .order_by("-publish_date") - .first() - ) - user.uv_comments.filter(uv__id=uv["uv"]).exclude(pk=last.pk).delete() - class Migration(migrations.Migration): dependencies = [("pedagogy", "0001_initial")] - operations = [ - migrations.RunPython( - remove_multiples_comments_from_same_user, - reverse_code=migrations.RunPython.noop, - ) - ] + # This migration contained just a RunPython operation + # Which has since been removed. + # The migration itself is kept in order not to break the migration tree + operations = [] diff --git a/pedagogy/tests/test_api.py b/pedagogy/tests/test_api.py index b8fb90b4..cbb99c18 100644 --- a/pedagogy/tests/test_api.py +++ b/pedagogy/tests/test_api.py @@ -8,7 +8,7 @@ from model_bakery import baker from model_bakery.recipe import Recipe from core.baker_recipes import subscriber_user -from core.models import RealGroup, User +from core.models import Group, User from pedagogy.models import UV @@ -80,9 +80,7 @@ class TestUVSearch(TestCase): subscriber_user.make(), baker.make( User, - groups=[ - RealGroup.objects.get(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID) - ], + groups=[Group.objects.get(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)], ), ): # users that have right diff --git a/pedagogy/views.py b/pedagogy/views.py index ca2c712e..99dd8168 100644 --- a/pedagogy/views.py +++ b/pedagogy/views.py @@ -24,6 +24,7 @@ from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import PermissionDenied +from django.db.models import Exists, OuterRef from django.shortcuts import get_object_or_404 from django.urls import reverse, reverse_lazy from django.views.generic import ( @@ -34,7 +35,7 @@ from django.views.generic import ( UpdateView, ) -from core.models import Notification, RealGroup +from core.models import Notification, User from core.views import ( CanCreateMixin, CanEditPropMixin, @@ -156,21 +157,19 @@ class UVCommentReportCreateView(CanCreateMixin, CreateView): def form_valid(self, form): resp = super().form_valid(form) - # Send a message to moderation admins - for user in ( - RealGroup.objects.filter(id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID) - .first() - .users.all() + unread_notif_subquery = Notification.objects.filter( + user=OuterRef("pk"), type="PEDAGOGY_MODERATION", viewed=False + ) + for user in User.objects.filter( + ~Exists(unread_notif_subquery), + groups__id__in=[settings.SITH_GROUP_PEDAGOGY_ADMIN_ID], ): - if not user.notifications.filter( - type="PEDAGOGY_MODERATION", viewed=False - ).exists(): - Notification( - user=user, - url=reverse("pedagogy:moderation"), - type="PEDAGOGY_MODERATION", - ).save() + Notification.objects.create( + user=user, + url=reverse("pedagogy:moderation"), + type="PEDAGOGY_MODERATION", + ) return resp diff --git a/rootplace/tests.py b/rootplace/tests.py index 0d0f1542..a2bbee81 100644 --- a/rootplace/tests.py +++ b/rootplace/tests.py @@ -19,7 +19,7 @@ from django.urls import reverse from django.utils.timezone import localtime, now from club.models import Club -from core.models import RealGroup, User +from core.models import Group, User from counter.models import Counter, Customer, Product, Refilling, Selling from subscription.models import Subscription @@ -50,9 +50,9 @@ class TestMergeUser(TestCase): self.to_keep.address = "Jerusalem" self.to_delete.parent_address = "Rome" self.to_delete.address = "Rome" - subscribers = RealGroup.objects.get(name="Subscribers") - mde_admin = RealGroup.objects.get(name="MDE admin") - sas_admin = RealGroup.objects.get(name="SAS admin") + subscribers = Group.objects.get(name="Subscribers") + mde_admin = Group.objects.get(name="MDE admin") + sas_admin = Group.objects.get(name="SAS admin") self.to_keep.groups.add(subscribers.id) self.to_delete.groups.add(mde_admin.id) self.to_keep.groups.add(sas_admin.id) diff --git a/sas/tests/test_api.py b/sas/tests/test_api.py index 733838d2..9b24688b 100644 --- a/sas/tests/test_api.py +++ b/sas/tests/test_api.py @@ -7,7 +7,7 @@ from model_bakery import baker from model_bakery.recipe import Recipe from core.baker_recipes import old_subscriber_user, subscriber_user -from core.models import RealGroup, SithFile, User +from core.models import Group, SithFile, User from sas.baker_recipes import picture_recipe from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest @@ -155,7 +155,7 @@ class TestPictureRelation(TestSas): def test_delete_relation_with_authorized_users(self): """Test that deletion works as intended when called by an authorized user.""" relation: PeoplePictureRelation = self.user_a.pictures.first() - sas_admin_group = RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID) + sas_admin_group = Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID) sas_admin = baker.make(User, groups=[sas_admin_group]) root = baker.make(User, is_superuser=True) for user in sas_admin, root, self.user_a: @@ -189,7 +189,7 @@ class TestPictureModeration(TestSas): def setUpTestData(cls): super().setUpTestData() cls.sas_admin = baker.make( - User, groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] + User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] ) cls.picture = Picture.objects.filter(parent=cls.album_a)[0] cls.picture.is_moderated = False diff --git a/sas/tests/test_views.py b/sas/tests/test_views.py index 2a73b90b..ff8dd21d 100644 --- a/sas/tests/test_views.py +++ b/sas/tests/test_views.py @@ -23,7 +23,7 @@ from model_bakery import baker from pytest_django.asserts import assertInHTML, assertRedirects from core.baker_recipes import old_subscriber_user, subscriber_user -from core.models import RealGroup, User +from core.models import Group, User from sas.baker_recipes import picture_recipe from sas.models import Album, Picture @@ -38,7 +38,7 @@ from sas.models import Album, Picture old_subscriber_user.make, lambda: baker.make(User, is_superuser=True), lambda: baker.make( - User, groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] + User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] ), lambda: baker.make(User), ], @@ -80,7 +80,7 @@ class TestSasModeration(TestCase): cls.to_moderate.is_moderated = False cls.to_moderate.save() cls.moderator = baker.make( - User, groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] + User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] ) cls.simple_user = subscriber_user.make() diff --git a/subscription/tests/test_new_susbcription.py b/subscription/tests/test_new_susbcription.py index 8ea51d68..ccdff407 100644 --- a/subscription/tests/test_new_susbcription.py +++ b/subscription/tests/test_new_susbcription.py @@ -90,13 +90,20 @@ def test_form_new_user(settings: SettingsWrapper): @pytest.mark.django_db @pytest.mark.parametrize( - "user_factory", [lambda: baker.make(User, is_superuser=True), board_user.make] + ("user_factory", "status_code"), + [ + (lambda: baker.make(User, is_superuser=True), 200), + (board_user.make, 200), + (subscriber_user.make, 403), + ], ) -def test_load_page(client: Client, user_factory: Callable[[], User]): - """Just check the page doesn't crash.""" +def test_page_access( + client: Client, user_factory: Callable[[], User], status_code: int +): + """Check that only authorized users may access this page.""" client.force_login(user_factory()) res = client.get(reverse("subscription:subscription")) - assert res.status_code == 200 + assert res.status_code == status_code @pytest.mark.django_db